Volver al índice

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

package ar.com.companeros.memory;

import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import net.minecraft.nbt.*;

/** Memoria por jugador fuera del mundo. Ubicaciones antiguas no se recuperan como recursos actuales. */
public final class PersistentMemoryArchive {
    private static final int MAX_RECORDS = 8192;
    private final Path directory;
    private final java.util.concurrent.ConcurrentMap<UUID, Map<UUID, MemoryRecord>> loaded = new java.util.concurrent.ConcurrentHashMap<>();
    public PersistentMemoryArchive(Path directory) { this.directory = directory.toAbsolutePath().normalize(); }

    // Lectura perezosa y acotada: cada jugador carga únicamente su propio archivo.
    private Map<UUID, MemoryRecord> records(UUID agent) throws IOException {
        Map<UUID, MemoryRecord> existing = loaded.get(agent); if (existing != null) return existing;
        Path path = directory.resolve(agent + ".dat"); LinkedHashMap<UUID, MemoryRecord> records = new LinkedHashMap<>();
        if (Files.exists(path)) {
            if (Files.isSymbolicLink(path) || Files.size(path) > 16_777_216) throw new IOException("Archivo de memoria fuera de límite");
            CompoundTag root = NbtIo.readCompressed(path.toFile());
            if (root.getInt("Schema") != 1 || !root.getUUID("Agent").equals(agent)) throw new IOException("Identidad de memoria incorrecta");
            ListTag entries = root.getList("Records", Tag.TAG_COMPOUND);
            if (entries.size() > MAX_RECORDS) throw new IOException("Demasiados recuerdos archivados");
            for (Tag entry : entries) {
                MemoryRecord record = MemoryStore.readRecord((CompoundTag)entry);
                if (!record.agentId().equals(agent) || records.putIfAbsent(record.id(), record) != null)
                    throw new IOException("Archivo de memoria contiene identidad ajena o duplicada");
            }
        }
        Map<UUID, MemoryRecord> immutable = Collections.unmodifiableMap(records);
        Map<UUID, MemoryRecord> previous = loaded.putIfAbsent(agent, immutable); return previous == null ? immutable : previous;
    }

    // Mezcla idempotente: no borra recuerdos de otros mundos al guardar el actual.
    public synchronized void sync(UUID agent, List<MemoryRecord> current) throws IOException {
        var records = new LinkedHashMap<>(records(agent)); boolean changed = !Files.exists(directory.resolve(agent + ".dat"));
        for (MemoryRecord memory : current) {
            if (!memory.agentId().equals(agent)) throw new IllegalArgumentException("Memoria de otro jugador");
            if (memory.equals(records.get(memory.id()))) continue;
            if (!records.containsKey(memory.id()) && records.size() >= MAX_RECORDS) {
                MemoryRecord removable = records.values().stream().filter(record -> !record.protectedFromEviction())
                    .min(Comparator.comparingDouble((MemoryRecord record) -> record.importance() * record.confidence())
                        .thenComparingLong(MemoryRecord::occurredAtUtc)).orElse(null);
                if (removable == null) throw new IOException("Archivo lleno de recuerdos protegidos; se conserva el original");
                records.remove(removable.id());
            }
            records.put(memory.id(), memory); changed = true;
        }
        if (!changed) return;
        Files.createDirectories(directory); Path file = directory.resolve(agent + ".dat"), temporary = directory.resolve(agent + ".tmp");
        if (Files.isSymbolicLink(file) || Files.isSymbolicLink(temporary)) throw new IOException("Enlace de memoria no permitido");
        CompoundTag root = new CompoundTag(); root.putInt("Schema", 1); root.putUUID("Agent", agent);
        ListTag entries = new ListTag(); records.values().forEach(record -> entries.add(MemoryStore.writeRecord(record))); root.put("Records", entries);
        try {
            NbtIo.writeCompressed(root, temporary.toFile());
            try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
            catch (AtomicMoveNotSupportedException unsupported) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); }
            loaded.put(agent, Collections.unmodifiableMap(records));
        } finally { Files.deleteIfExists(temporary); }
    }

    // Experiencias transferibles: no son evidencia de recursos, posiciones o condiciones del mundo nuevo.
    public List<MemoryRecord> historical(UUID agent, UUID currentWorld, String query) throws IOException {
        Set<String> words = new HashSet<>(Arrays.asList(query.toLowerCase(Locale.ROOT).split("[^\\p{L}\\p{N}_]+")));
        long now = System.currentTimeMillis();
        return records(agent).values().stream().filter(memory -> !memory.worldId().equals(currentWorld)
            && memory.type() != MemoryType.PLACE && memory.type() != MemoryType.RESOURCE)
            .sorted(Comparator.comparingDouble((MemoryRecord memory) -> historicalScore(memory, words, now)).reversed())
            .limit(3).toList();
    }
    private static double historicalScore(MemoryRecord memory, Set<String> words, long now) {
        double relevant = words.stream().filter(word -> word.length() > 2 && memory.content().toLowerCase(Locale.ROOT).contains(word)).count();
        double ageDays = Math.max(0, now - memory.occurredAtUtc()) / 86_400_000.0;
        return relevant + memory.importance() * 3 + memory.confidence() / (1 + ageDays / 60)
            + (memory.protectedFromEviction() ? 2 : 0);
    }
}