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 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 find(UUID id) { return Optional.ofNullable(records.get(id)); } public List 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 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 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 query(UUID agentId, MemoryQuery query) { Set 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 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 selected = new ArrayList<>(); Set 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 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 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 values) { ListTag list = new ListTag(); values.forEach(id -> list.add(StringTag.valueOf(id.toString()))); return list; } private static List readUuids(ListTag list) { List values = new ArrayList<>(); list.forEach(value -> values.add(UUID.fromString(value.getAsString()))); return values; } }