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;
}
}