Volver al índice
src/main/java/ar/com/companeros/tools/ToolLearningService.java
package ar.com.companeros.tools;
import ar.com.companeros.action.ToolResult;
import ar.com.companeros.body.PlayerSessions;
import ar.com.companeros.identity.AgentRoles;
import ar.com.companeros.world.WorldPrimitiveExecutor;
import com.google.gson.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.*;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.phys.Vec3;
/** Propuesta → validación sin efectos → prueba física → certificación compartida. */
public final class ToolLearningService {
public static final int REQUIRED_SUCCESSES = 3;
public static final int MAX_TRIAL_STEPS = 16;
public static final int MAX_TRIAL_TICKS = 600;
private static final int MAX_ENTRIES = 512;
private static final int MAX_LEDGER_BYTES = 32 * 1024 * 1024;
private static final UUID REVIEWER = new UUID(0, 2);
private static final Set<String> CONTROL_PRIMITIVES = Set.of("propose_tool", "research_web");
private static final Set<ToolPermission> PRODUCTION = Set.of(ToolPermission.MINE, ToolPermission.PLACE, ToolPermission.CRAFT);
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
private final ToolRegistry registry;
private final Path directory;
private final byte[] signingKey;
private final Map<String, Learned> entries = new LinkedHashMap<>();
private final Map<UUID, Trial> active = new HashMap<>();
private String persistenceError = "";
public ToolLearningService(ToolRegistry registry) throws IOException {
this.registry = Objects.requireNonNull(registry);
directory = registry.directory();
checkDirectory();
signingKey = readOrCreateKey();
restore();
registry.loadProposals();
}
// El autor y las aprobaciones provienen del servidor; ningún campo JSON puede elegirlos.
public synchronized ToolRegistry.Proposal propose(UUID actor, String source) throws IOException {
Objects.requireNonNull(actor);
ToolManifest candidate = ToolManifest.parse(source, actor);
validateTrialCandidate(candidate);
Learned previous = entries.get(candidate.sha256());
if (previous != null) return new ToolRegistry.Proposal(previous.manifest, previous.createdAt);
if (entries.size() >= MAX_ENTRIES || trialTools(actor).size() >= 16)
throw ToolManifest.invalid("Límite de herramientas propuestas alcanzado");
for (ToolManifest existing : registry.certifiedTools()) {
if (existing.id().equals(candidate.id()) && (existing.author().equals(new UUID(0, 1))
|| existing.version() == candidate.version()))
throw ToolManifest.invalid("Ese identificador o versión ya pertenece a una herramienta certificada");
}
for (ToolRegistry.Proposal pending : registry.pendingProposals())
if (pending.manifest().id().equals(candidate.id()) && pending.manifest().version() == candidate.version()
&& !pending.manifest().sha256().equals(candidate.sha256()))
throw ToolManifest.invalid("Una propuesta registrada es inmutable; elegí otra versión");
ToolRegistry.Proposal proposal = registry.propose(actor, source);
entries.put(candidate.sha256(), new Learned(proposal.manifest(), proposal.createdAt()));
try { save(); }
catch (IOException failure) { entries.remove(candidate.sha256()); throw failure; }
return proposal;
}
public synchronized List<ToolManifest> trialTools(UUID actor) {
return registry.pendingProposals().stream().map(ToolRegistry.Proposal::manifest)
.filter(tool -> tool.author().equals(actor))
.filter(tool -> !entries.containsKey(tool.sha256()) || !entries.get(tool.sha256()).certified)
.filter(tool -> !entries.containsKey(tool.sha256()) || entries.get(tool.sha256()).failures < 8)
.filter(this::isTrialCandidate)
.sorted(Comparator.comparing(ToolManifest::id).thenComparingInt(ToolManifest::version)).toList();
}
public synchronized Optional<ToolManifest> pending(UUID actor, String id, int version) {
return trialTools(actor).stream().filter(tool -> tool.id().equals(id) && tool.version() == version).findFirst();
}
public synchronized Statistics statistics(String hash) {
Learned item = entries.get(hash);
return item == null ? new Statistics(0, 0, 0, false, "")
: new Statistics(item.successes, item.failures, item.cancellations, item.certified, item.lastResult);
}
public synchronized String lastPersistenceError() { return persistenceError; }
// Una prueba pendiente usa el mismo jugador, inventario, alcance y protecciones que una acción normal.
public synchronized ToolExecution startTrial(String hash, ToolRegistry.ExecutionContext context,
MinecraftServer server, Consumer<ToolResult> observed) {
if (server == null || !server.isSameThread()) throw ToolManifest.invalid("La prueba exige el hilo del servidor");
if (context == null || hash == null || !hash.matches("[0-9a-f]{64}")) throw ToolManifest.invalid("Prueba inválida");
if (context.startTick() != server.getTickCount()) throw ToolManifest.invalid("La prueba necesita una observación actual");
ToolManifest manifest = trialTools(context.actorId()).stream().filter(tool -> tool.sha256().equals(hash))
.findFirst().orElseThrow(() -> ToolManifest.invalid("Propuesta pendiente ajena o desconocida"));
validateTrialCandidate(manifest);
if (!context.permissions().containsAll(manifest.permissions())) throw ToolManifest.invalid("Permisos insuficientes para la prueba");
if (AgentRoles.get(server).role(context.actorId()) == AgentRoles.Role.INVESTIGADOR
&& manifest.permissions().stream().anyMatch(PRODUCTION::contains))
throw ToolManifest.invalid("El rol investigador delega esa producción");
if (PlayerSessions.find(server, context.actorId()) == null) throw ToolManifest.invalid("El autor debe estar conectado");
if (active.size() >= 4 || active.values().stream().anyMatch(trial -> trial.actor.equals(context.actorId())))
throw ToolManifest.invalid("Ya hay una prueba activa o se alcanzó el cupo del servidor");
registry.validateInputs(manifest, context); // Compilación y resolución: todavía no toca el mundo.
Learned state = entries.computeIfAbsent(hash, ignored -> new Learned(manifest, Instant.now()));
if (state.failures >= 8) throw ToolManifest.invalid("Demasiados fallos: corregí la herramienta en una versión nueva");
Trial trial = new Trial(hash, context.actorId(), server.overworld().getGameTime());
WorldPrimitiveExecutor physical = new WorldPrimitiveExecutor(server, result -> {
recordEvidence(trial, server, result);
if (observed != null) observed.accept(result);
});
PrimitiveExecutor witnessed = (actor, invocation, primitive, args) -> {
ServerPlayer body = PlayerSessions.find(server, actor);
trial.invocations.put(invocation, new Invocation(primitive, body == null ? null : body.position()));
return physical.begin(actor, invocation, primitive, args);
};
ToolExecution execution = registry.startTrial(manifest, context, witnessed, status -> finish(trial, status));
trial.execution = execution;
active.put(execution.id(), trial);
return execution;
}
/** Relectura de evidencia del servidor; no acepta un hash y un booleano enviados por el modelo. */
public synchronized Statistics reportTrial(ToolExecution execution) {
if (execution == null) throw ToolManifest.invalid("Falta la ejecución observada");
Trial running = active.get(execution.id());
if (running != null && running.execution != execution) throw ToolManifest.invalid("Ejecución ajena");
Learned state = entries.values().stream().filter(item -> item.receipts.stream()
.anyMatch(receipt -> receipt.execution.equals(execution.id()))).findFirst().orElse(null);
if (state == null && running == null) throw ToolManifest.invalid("La ejecución no tiene evidencia de prueba");
return statistics(running != null ? running.hash : state.manifest.sha256());
}
private synchronized void recordEvidence(Trial trial, MinecraftServer server, ToolResult result) {
Invocation invocation = trial.invocations.get(result.actionId());
if (invocation == null || invocation.result != null) return;
invocation.result = result;
if (!result.succeeded() || result.location() == null) return;
boolean meaningful = result.inventoryDelta().values().stream().anyMatch(value -> value != 0);
meaningful |= result.actualEffects().stream().anyMatch(effect -> effect.startsWith("mined:")
|| effect.startsWith("placed:") || effect.startsWith("attack:") || effect.startsWith("recipe=")
|| effect.matches("internal_message:[1-9][0-9]*") || effect.equals("global_published"));
if (invocation.primitive.equals("move_to")) {
ServerPlayer body = PlayerSessions.find(server, trial.actor);
meaningful |= body != null && invocation.before != null && body.position().distanceToSqr(invocation.before) > .04;
}
if (meaningful) trial.meaningful++;
}
// Tres resultados útiles reales y una tasa mínima permiten compartir el hash exacto.
private synchronized void finish(Trial trial, ToolExecution.Status status) {
if (active.remove(trial.execution.id()) != trial) return;
Learned state = entries.get(trial.hash);
if (state == null) return;
long oldSuccess = state.successes, oldFailure = state.failures, oldCancelled = state.cancellations;
boolean oldCertified = state.certified;
String oldResult = state.lastResult;
boolean complete = status == ToolExecution.Status.SUCCEEDED && trial.meaningful > 0
&& trial.invocations.size() == trial.execution.completedSteps()
&& trial.invocations.values().stream().allMatch(call -> call.result != null && call.result.succeeded());
if (complete) state.successes++;
else if (status == ToolExecution.Status.CANCELLED) state.cancellations++;
else state.failures++;
state.lastResult = complete ? "SUCCEEDED" : status == ToolExecution.Status.SUCCEEDED ? "NO_USEFUL_CHANGE" : status.name();
Receipt receipt = new Receipt(trial.execution.id(), trial.actor, trial.startedWorldTick,
Instant.now(), state.lastResult, trial.invocations.size(), trial.meaningful);
state.receipts.add(receipt);
if (state.receipts.size() > 32) state.receipts.remove(0);
state.certified = state.successes >= REQUIRED_SUCCESSES
&& (double)state.successes / (state.successes + state.failures) >= .8;
try {
save(); // El certificado se escribe antes de concederlo en memoria.
if (state.certified) registry.certify(trial.hash, REVIEWER,
new ToolRegistry.TestEvidence(trial.hash, "physical_trials_3_successes", true, Instant.now()));
} catch (IOException | RuntimeException failure) {
state.successes = oldSuccess; state.failures = oldFailure; state.cancellations = oldCancelled;
state.certified = oldCertified; state.lastResult = oldResult; state.receipts.remove(receipt);
persistenceError = "No se pudo guardar el resultado de una prueba";
com.mojang.logging.LogUtils.getLogger().error("No se pudo persistir la prueba de {}", state.manifest.id(), failure);
}
}
private void validateLearningManifest(ToolManifest manifest, Set<String> visiting) {
if (!visiting.add(manifest.sha256())) throw ToolManifest.invalid("Dependencia circular");
try {
for (ToolManifest.Step step : manifest.steps()) {
if (step.primitive() != null && CONTROL_PRIMITIVES.contains(step.primitive()))
throw ToolManifest.invalid("Una herramienta propuesta no puede crear herramientas ni activar consultas web");
if (step.tool() != null) {
ToolManifest.Dependency dependency = step.tool();
ToolManifest child = registry.certifiedTools().stream().filter(tool -> tool.id().equals(dependency.id())
&& tool.version() == dependency.version() && tool.sha256().equals(dependency.sha256())).findFirst()
.orElseThrow(() -> ToolManifest.invalid("Dependencia no certificada"));
validateLearningManifest(child, visiting);
}
}
} finally { visiting.remove(manifest.sha256()); }
}
private static void validateTrialLimits(ToolManifest manifest) {
if (manifest.maxTicks() > MAX_TRIAL_TICKS || manifest.maxSteps() > MAX_TRIAL_STEPS)
throw ToolManifest.invalid("Las herramientas aprendidas admiten hasta 16 pasos y 600 ticks");
}
private void validateTrialCandidate(ToolManifest manifest) {
validateTrialLimits(manifest);
if (registry.certifiedTools().stream().anyMatch(existing -> existing.id().equals(manifest.id())
&& existing.author().equals(new UUID(0, 1))))
throw ToolManifest.invalid("No se puede sustituir una capacidad instalada por el mod");
validateLearningManifest(manifest, new HashSet<>());
}
private boolean isTrialCandidate(ToolManifest manifest) {
try { validateTrialCandidate(manifest); return true; }
catch (IllegalArgumentException rejected) { return false; }
}
// Ledger autenticado: estadísticas editadas o manifiestos sustituidos nunca conceden certificación.
public synchronized void save() throws IOException {
checkDirectory();
JsonObject content = new JsonObject(); content.addProperty("schema", 1);
JsonArray tools = new JsonArray();
for (Learned state : entries.values()) {
JsonObject row = new JsonObject();
row.addProperty("author", state.manifest.author().toString());
row.addProperty("sha256", state.manifest.sha256());
row.add("manifest", state.manifest.toJson()); row.addProperty("createdAt", state.createdAt.toString());
row.addProperty("successes", state.successes); row.addProperty("failures", state.failures);
row.addProperty("cancellations", state.cancellations); row.addProperty("certified", state.certified);
row.addProperty("lastResult", state.lastResult);
JsonArray receipts = new JsonArray();
for (Receipt receipt : state.receipts) {
JsonObject result = new JsonObject(); result.addProperty("execution", receipt.execution.toString());
result.addProperty("actor", receipt.actor.toString()); result.addProperty("startedWorldTick", receipt.startedWorldTick);
result.addProperty("completedAt", receipt.completedAt.toString()); result.addProperty("result", receipt.result);
result.addProperty("steps", receipt.steps); result.addProperty("usefulEffects", receipt.usefulEffects);
receipts.add(result);
}
row.add("trials", receipts); tools.add(row);
}
content.add("tools", tools);
JsonObject envelope = new JsonObject(); envelope.add("content", content); envelope.addProperty("hmac", signature(content));
writeAtomically(directory.resolve("learning.json"), JSON.toJson(envelope));
persistenceError = "";
}
private void restore() throws IOException {
Path file = directory.resolve("learning.json");
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return;
checkFile(file, MAX_LEDGER_BYTES);
try {
JsonObject envelope = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject();
ToolManifest.rejectUnknown(envelope, Set.of("content", "hmac"));
JsonObject content = envelope.getAsJsonObject("content");
if (!MessageDigest.isEqual(signature(content).getBytes(StandardCharsets.US_ASCII),
envelope.get("hmac").getAsString().getBytes(StandardCharsets.US_ASCII)))
throw ToolManifest.invalid("El registro de certificación fue adulterado");
ToolManifest.rejectUnknown(content, Set.of("schema", "tools"));
if (content.get("schema").getAsInt() != 1 || content.getAsJsonArray("tools").size() > MAX_ENTRIES)
throw ToolManifest.invalid("Registro de aprendizaje incompatible");
Set<String> versions = new HashSet<>(), executionIds = new HashSet<>();
for (JsonElement item : content.getAsJsonArray("tools")) {
JsonObject row = item.getAsJsonObject();
ToolManifest.rejectUnknown(row, Set.of("author", "sha256", "manifest", "createdAt", "successes",
"failures", "cancellations", "certified", "lastResult", "trials"));
ToolManifest manifest = ToolManifest.parse(row.get("manifest").toString(), UUID.fromString(row.get("author").getAsString()));
if (!manifest.sha256().equals(row.get("sha256").getAsString()) || !versions.add(manifest.id() + ":" + manifest.version()))
throw ToolManifest.invalid("Hash o versión alterados");
Learned state = new Learned(manifest, Instant.parse(row.get("createdAt").getAsString()));
validateTrialLimits(manifest);
state.successes = nonnegative(row, "successes"); state.failures = nonnegative(row, "failures");
state.cancellations = nonnegative(row, "cancellations"); state.lastResult = row.get("lastResult").getAsString();
state.certified = row.get("certified").getAsBoolean();
JsonArray receipts = row.getAsJsonArray("trials");
if (receipts.size() > 32) throw ToolManifest.invalid("Demasiadas evidencias");
for (JsonElement evidence : receipts) {
JsonObject result = evidence.getAsJsonObject();
UUID execution = UUID.fromString(result.get("execution").getAsString());
if (!executionIds.add(execution.toString())) throw ToolManifest.invalid("Prueba duplicada");
UUID actor = UUID.fromString(result.get("actor").getAsString());
if (!actor.equals(manifest.author())) throw ToolManifest.invalid("La prueba no pertenece al autor");
long worldTick = result.get("startedWorldTick").getAsBigDecimal().longValueExact();
if (worldTick < 0) throw ToolManifest.invalid("Fecha de mundo inválida");
state.receipts.add(new Receipt(execution, actor, worldTick,
Instant.parse(result.get("completedAt").getAsString()), result.get("result").getAsString(),
Math.toIntExact(nonnegative(result, "steps")), Math.toIntExact(nonnegative(result, "usefulEffects"))));
}
if (state.certified && (state.successes < REQUIRED_SUCCESSES
|| (double)state.successes / (state.successes + state.failures) < .8
|| state.receipts.stream().filter(receipt -> receipt.result.equals("SUCCEEDED") && receipt.usefulEffects > 0).count() < REQUIRED_SUCCESSES))
throw ToolManifest.invalid("Certificación sin pruebas suficientes");
entries.put(manifest.sha256(), state);
}
List<Learned> unresolved = new ArrayList<>(entries.values().stream().filter(state -> state.certified).toList());
for (int depth = 0; depth < 5 && !unresolved.isEmpty(); depth++) {
Iterator<Learned> iterator = unresolved.iterator();
while (iterator.hasNext()) {
Learned state = iterator.next();
try { validateLearningManifest(state.manifest, new HashSet<>()); registry.registerCertified(state.manifest); iterator.remove(); }
catch (IllegalArgumentException waiting) { }
}
}
if (!unresolved.isEmpty()) throw ToolManifest.invalid("Dependencias certificadas no disponibles");
for (Learned state : entries.values()) if (!state.certified) {
validateLearningManifest(state.manifest, new HashSet<>());
registry.propose(state.manifest.author(), state.manifest.toJson().toString());
}
} catch (RuntimeException | StackOverflowError failure) { throw new IOException("Registro de aprendizaje inválido; no se concedieron nuevas certificaciones", failure); }
}
private byte[] readOrCreateKey() throws IOException {
Path file = directory.resolve("certification.key");
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
checkFile(file, 32);
byte[] bytes = Files.readAllBytes(file);
if (bytes.length != 32) throw new IOException("Clave de certificación inválida");
return bytes;
}
if (Files.exists(directory.resolve("learning.json"), LinkOption.NOFOLLOW_LINKS))
throw new IOException("Falta la clave del registro de certificación existente");
byte[] bytes = new byte[32]; new SecureRandom().nextBytes(bytes);
Files.write(file, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
return bytes;
}
private String signature(JsonObject content) {
try {
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(signingKey, "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(ToolManifest.canonical(content).getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException exception) { throw new IllegalStateException(exception); }
}
private void checkDirectory() throws IOException {
for (Path current = directory; current != null; current = current.getParent()) {
if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) continue;
BasicFileAttributes attributes = Files.readAttributes(current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (attributes.isSymbolicLink() || attributes.isOther() || !current.toRealPath().equals(current))
throw new IOException("La carpeta Tools no puede atravesar enlaces o redirecciones");
}
}
private static void checkFile(Path file, long maximum) throws IOException {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) || Files.size(file) > maximum)
throw new IOException("Archivo de aprendizaje inválido o demasiado grande");
}
private void writeAtomically(Path file, String source) throws IOException {
if (!file.normalize().getParent().equals(directory) || Files.isSymbolicLink(file)) throw new IOException("Ruta fuera de Tools");
if (source.getBytes(StandardCharsets.UTF_8).length > MAX_LEDGER_BYTES) throw new IOException("Registro de aprendizaje lleno");
Path temporary = directory.resolve("learning_write_" + UUID.randomUUID() + ".tmp");
try {
Files.writeString(temporary, source, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); }
} finally { Files.deleteIfExists(temporary); }
}
private static long nonnegative(JsonObject row, String key) {
long number = row.get(key).getAsBigDecimal().longValueExact();
if (number < 0 || number > 1_000_000) throw ToolManifest.invalid("Contador fuera del límite");
return number;
}
public record Statistics(long successes, long failures, long cancellations, boolean certified, String lastResult) { }
private static final class Learned {
final ToolManifest manifest; final Instant createdAt; final List<Receipt> receipts = new ArrayList<>();
long successes, failures, cancellations; boolean certified; String lastResult = "";
Learned(ToolManifest manifest, Instant createdAt) { this.manifest = manifest; this.createdAt = createdAt; }
}
private record Receipt(UUID execution, UUID actor, long startedWorldTick, Instant completedAt, String result, int steps, int usefulEffects) { }
private static final class Invocation {
final String primitive; final Vec3 before; ToolResult result;
Invocation(String primitive, Vec3 before) { this.primitive = primitive; this.before = before; }
}
private static final class Trial {
final String hash; final UUID actor; final long startedWorldTick; final Map<UUID, Invocation> invocations = new LinkedHashMap<>();
ToolExecution execution; int meaningful;
Trial(String hash, UUID actor, long startedWorldTick) { this.hash = hash; this.actor = actor; this.startedWorldTick = startedWorldTick; }
}
}