Volver al índice

src/main/java/ar/com/companeros/memory/MemoryStore.java

package ar.com.companeros.memory;

import ar.com.companeros.action.ToolResult;
import ar.com.companeros.identity.AgentRegistry;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.StringTag;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.saveddata.SavedData;

/** Memoria global del mundo, individual por UUID. Todas las mutaciones usan el hilo servidor. */
public final class MemoryStore extends SavedData {
    // Límites de almacenamiento y recuperación; recuerdos importantes no se borran por edad.
    public static final int SCHEMA_VERSION = 1;
    public static final int MAX_PER_AGENT = 512;
    public static final int MAX_TOTAL = 8192;
    private final UUID worldId;
    private final Map<UUID, MemoryRecord> records = new LinkedHashMap<>();
    private long refusedRecords;
    private long evictedRecords;

    public MemoryStore(UUID worldId) { this(worldId, true); }
    private MemoryStore(UUID worldId, boolean markDirty) {
        this.worldId = java.util.Objects.requireNonNull(worldId);
        if (markDirty) setDirty();
    }

    public static MemoryStore get(MinecraftServer server) {
        if (!server.isSameThread())
            throw new IllegalStateException("La memoria se usa en el hilo del servidor");
        UUID worldId = AgentRegistry.get(server).worldId();
        MemoryStore data = server.overworld().getDataStorage().computeIfAbsent(
            MemoryStore::load, () -> new MemoryStore(worldId), "companeros_memorias");
        if (!data.worldId.equals(worldId))
            throw new IllegalStateException("La memoria pertenece a otro mundo");
        return data;
    }

    public UUID worldId() { return worldId; }
    public int count(UUID agentId) { return (int) records.values().stream().filter(r -> r.agentId().equals(agentId)).count(); }
    public long refusedRecords() { return refusedRecords; }
    public long evictedRecords() { return evictedRecords; }
    public Optional<MemoryRecord> find(UUID id) { return Optional.ofNullable(records.get(id)); }
    public List<MemoryRecord> recordsFor(UUID agent) {
        return records.values().stream().filter(record -> record.agentId().equals(agent)).toList();
    }

    // Escritura y verificación: duplicar una entrega no duplica el recuerdo.
    public boolean record(MemoryRecord record) {
        if (!record.worldId().equals(worldId)) throw new IllegalArgumentException("Recuerdo de otro mundo");
        MemoryRecord existing = records.get(record.id());
        if (existing != null) {
            if (!existing.equals(record)) throw new IllegalArgumentException("ID de recuerdo ya utilizado");
            return true;
        }
        MemoryRecord superseded = record.supersedes() == null ? null : records.get(record.supersedes());
        if (superseded != null && !superseded.agentId().equals(record.agentId()))
            throw new IllegalArgumentException("No se puede reemplazar memoria ajena");
        UUID ancestor = record.supersedes();
        Set<UUID> visited = new HashSet<>();
        while (ancestor != null && visited.add(ancestor)) {
            if (ancestor.equals(record.id())) throw new IllegalArgumentException("Ciclo de reemplazo en memoria");
            MemoryRecord known = records.get(ancestor);
            ancestor = known == null ? null : known.supersedes();
        }
        boolean ownFull = count(record.agentId()) >= MAX_PER_AGENT;
        if (ownFull || records.size() >= MAX_TOTAL) {
            MemoryRecord victim = records.values().stream()
                .filter(candidate -> !candidate.protectedFromEviction())
                .filter(candidate -> !ownFull || candidate.agentId().equals(record.agentId()))
                .min(Comparator.comparingDouble((MemoryRecord candidate) -> retentionScore(candidate, record.gameTime()))
                    .thenComparingLong(MemoryRecord::occurredAtUtc)).orElse(null);
            if (victim == null) { refusedRecords++; setDirty(); return false; }
            records.remove(victim.id()); evictedRecords++;
        }
        records.put(record.id(), record); setDirty(); return true;
    }

    public MemoryRecord updateVerification(UUID id, double confidence, long gameTime, long verifiedAtUtc) {
        MemoryRecord current = records.get(id);
        if (current == null) throw new IllegalArgumentException("Recuerdo inexistente");
        if (verifiedAtUtc < current.lastVerifiedAtUtc())
            throw new IllegalArgumentException("Verificación anterior a la registrada");
        MemoryRecord next = current.verified(confidence, verifiedAtUtc, gameTime);
        records.put(id, next); setDirty(); return next;
    }

    public boolean recordResult(UUID agentId, ToolResult result, long gameTime, String description) {
        if (!result.finished()) return false;
        Set<String> keys = new HashSet<>();
        keys.add(result.status().name().toLowerCase(Locale.ROOT));
        keys.add(result.reason().name().toLowerCase(Locale.ROOT));
        keys.addAll(result.inventoryDelta().keySet().stream().filter(key -> key.length() <= 64).limit(30).toList());
        String observed = description + " [" + result.status() + "/" + result.reason()
            + "; inventario=" + result.inventoryDelta() + "; efectos=" + result.actualEffects() + "]";
        if (observed.length() > MemoryRecord.MAX_CONTENT_CHARS)
            observed = observed.substring(0, MemoryRecord.MAX_CONTENT_CHARS);
        MemoryRecord record = new MemoryRecord(result.actionId(), agentId, worldId, result.location(),
            result.finishedAtUtc(), gameTime, MemoryType.EXPERIENCE, result.succeeded() ? .3 : .55, 1,
            result.finishedAtUtc(), gameTime, MemoryOrigin.RESULT, result.actionId().toString(),
            result.evidenceIds(), observed, keys, Set.of(), null, false);
        return record(record);
    }

    // Selección: sin acceso a recuerdos de otros agentes, con presupuesto de texto y diversidad.
    public List<MemoryRecord> query(UUID agentId, MemoryQuery query) {
        Set<UUID> replaced = new HashSet<>();
        if (!query.includeSuperseded()) records.values().stream()
            .filter(record -> record.agentId().equals(agentId) && record.supersedes() != null)
            .forEach(record -> replaced.add(record.supersedes()));
        List<MemoryRecord> ordered = records.values().stream()
            .filter(record -> record.agentId().equals(agentId) && !replaced.contains(record.id()))
            .filter(record -> query.types().isEmpty() || query.types().contains(record.type()))
            .sorted(Comparator.comparingDouble((MemoryRecord record) -> relevance(record, query)).reversed()
                .thenComparing(Comparator.comparingLong(MemoryRecord::occurredAtUtc).reversed()))
            .toList();
        List<MemoryRecord> selected = new ArrayList<>();
        Set<String> seen = new HashSet<>();
        int chars = 0;
        for (MemoryRecord record : ordered) {
            String signature = record.type() + "|" + record.location() + "|" + record.content().toLowerCase(Locale.ROOT);
            if (seen.contains(signature)) continue;
            int recordChars = record.content().length() + record.sourceId().length() + 512
                + record.keywords().stream().mapToInt(String::length).sum()
                + 40 * (record.evidenceIds().size() + record.relatedAgents().size());
            if (chars + recordChars > query.maxChars()) continue;
            selected.add(record); seen.add(signature); chars += recordChars;
            if (selected.size() >= query.limit()) break;
        }
        return List.copyOf(selected);
    }

    private static double relevance(MemoryRecord record, MemoryQuery query) {
        double score = retentionScore(record, query.nowGameTime());
        String content = record.content().toLowerCase(Locale.ROOT);
        for (String term : query.text().toLowerCase(Locale.ROOT).lines()
                .flatMap(line -> java.util.Arrays.stream(line.split("\\s+"))).distinct().limit(16).toList()) {
            if (term.length() >= 2 && (content.contains(term)
                    || record.keywords().stream().anyMatch(key -> key.toLowerCase(Locale.ROOT).contains(term))))
                score += .6 * (.25 + .75 * record.confidence());
        }
        if (query.near() != null && record.location() != null
                && query.near().dimension().equals(record.location().dimension())) {
            double distance = Math.sqrt(query.near().pos().distSqr(record.location().pos()));
            score += .5 / (1 + distance / 64);
        }
        if (query.relatedAgent() != null && record.relatedAgents().contains(query.relatedAgent())) score += .6;
        return score;
    }

    private static double retentionScore(MemoryRecord record, long nowGameTime) {
        long verified = record.lastVerifiedAtUtc() > 0 ? record.lastVerifiedGameTime() : record.gameTime();
        long age = Math.max(0, nowGameTime - verified);
        double halfLife = switch (record.type()) {
            case DANGER -> 6000; case RESOURCE, COMMITMENT -> 24000;
            case PLACE, RELATION, STRATEGY -> 240000; default -> 480000;
        };
        double decayed = Math.pow(.5, age / halfLife);
        double floor = record.protectedFromEviction() ? .35 : .1 * record.importance();
        return record.importance() + (.25 + .75 * record.confidence()) * Math.max(floor, decayed);
    }

    // Persistencia NBT versionada. Datos inválidos fallan explícitamente, sin reset silencioso.
    @Override public CompoundTag save(CompoundTag tag) {
        tag.putInt("SchemaVersion", SCHEMA_VERSION); tag.putUUID("WorldId", worldId);
        tag.putLong("RefusedRecords", refusedRecords); tag.putLong("EvictedRecords", evictedRecords);
        ListTag list = new ListTag();
        for (MemoryRecord record : records.values()) list.add(writeRecord(record));
        tag.put("Records", list); return tag;
    }

    public static MemoryStore load(CompoundTag tag) {
        if (tag.getInt("SchemaVersion") > SCHEMA_VERSION || !tag.hasUUID("WorldId")
                || !tag.contains("Records", 9))
            throw new IllegalStateException("Formato de memoria no compatible");
        MemoryStore data = new MemoryStore(tag.getUUID("WorldId"), false);
        data.refusedRecords = Math.max(0, tag.getLong("RefusedRecords"));
        data.evictedRecords = Math.max(0, tag.getLong("EvictedRecords"));
        ListTag list = tag.getList("Records", 10);
        if (list.size() > MAX_TOTAL) throw new IllegalStateException("Memoria fuera de límite");
        Map<UUID, Integer> counts = new HashMap<>();
        for (int i = 0; i < list.size(); i++) {
            MemoryRecord record = readRecord(list.getCompound(i));
            if (!record.worldId().equals(data.worldId) || counts.merge(record.agentId(), 1, Integer::sum) > MAX_PER_AGENT
                    || data.records.putIfAbsent(record.id(), record) != null)
                throw new IllegalStateException("Registro de memoria inválido o duplicado");
        }
        return data;
    }

    static CompoundTag writeRecord(MemoryRecord record) {
        CompoundTag tag = new CompoundTag();
        tag.putUUID("Id", record.id()); tag.putUUID("AgentId", record.agentId()); tag.putUUID("WorldId", record.worldId());
        if (record.location() != null) {
            tag.putString("Dimension", record.location().dimension().location().toString());
            tag.putLong("Position", record.location().pos().asLong());
        }
        tag.putLong("OccurredAtUtc", record.occurredAtUtc()); tag.putLong("GameTime", record.gameTime());
        tag.putString("Type", record.type().name()); tag.putDouble("Importance", record.importance());
        tag.putDouble("Confidence", record.confidence()); tag.putLong("LastVerifiedAtUtc", record.lastVerifiedAtUtc());
        tag.putLong("LastVerifiedGameTime", record.lastVerifiedGameTime()); tag.putString("Origin", record.origin().name());
        tag.putString("SourceId", record.sourceId()); tag.putString("Content", record.content());
        tag.put("EvidenceIds", uuidList(record.evidenceIds())); tag.put("RelatedAgents", uuidList(record.relatedAgents()));
        ListTag keys = new ListTag(); record.keywords().stream().sorted().forEach(key -> keys.add(StringTag.valueOf(key)));
        tag.put("Keywords", keys); tag.putBoolean("Pinned", record.pinned());
        if (record.supersedes() != null) tag.putUUID("Supersedes", record.supersedes());
        return tag;
    }

    static MemoryRecord readRecord(CompoundTag tag) {
        GlobalPos location = tag.contains("Position") ? GlobalPos.of(ResourceKey.create(Registries.DIMENSION,
            new ResourceLocation(tag.getString("Dimension"))), BlockPos.of(tag.getLong("Position"))) : null;
        Set<String> keys = new HashSet<>(); tag.getList("Keywords", 8).forEach(value -> keys.add(value.getAsString()));
        return new MemoryRecord(tag.getUUID("Id"), tag.getUUID("AgentId"), tag.getUUID("WorldId"), location,
            tag.getLong("OccurredAtUtc"), tag.getLong("GameTime"), MemoryType.valueOf(tag.getString("Type")),
            tag.getDouble("Importance"), tag.getDouble("Confidence"), tag.getLong("LastVerifiedAtUtc"),
            tag.getLong("LastVerifiedGameTime"), MemoryOrigin.valueOf(tag.getString("Origin")), tag.getString("SourceId"),
            readUuids(tag.getList("EvidenceIds", 8)), tag.getString("Content"), keys,
            Set.copyOf(readUuids(tag.getList("RelatedAgents", 8))), tag.hasUUID("Supersedes") ? tag.getUUID("Supersedes") : null,
            tag.getBoolean("Pinned"));
    }

    private static ListTag uuidList(Iterable<UUID> values) {
        ListTag list = new ListTag(); values.forEach(id -> list.add(StringTag.valueOf(id.toString()))); return list;
    }
    private static List<UUID> readUuids(ListTag list) {
        List<UUID> values = new ArrayList<>(); list.forEach(value -> values.add(UUID.fromString(value.getAsString()))); return values;
    }
}