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 CONTROL_PRIMITIVES = Set.of("propose_tool", "research_web"); private static final Set 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 entries = new LinkedHashMap<>(); private final Map 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 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 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 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 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 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 unresolved = new ArrayList<>(entries.values().stream().filter(state -> state.certified).toList()); for (int depth = 0; depth < 5 && !unresolved.isEmpty(); depth++) { Iterator 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 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 invocations = new LinkedHashMap<>(); ToolExecution execution; int meaningful; Trial(String hash, UUID actor, long startedWorldTick) { this.hash = hash; this.actor = actor; this.startedWorldTick = startedWorldTick; } } }