Volver al índice
src/main/java/ar/com/companeros/horror/tools/InfectedTools.java
package ar.com.companeros.horror.tools;
import ar.com.companeros.decision.ToolCapability;
import ar.com.companeros.horror.*;
import ar.com.companeros.tools.ToolManifest;
import com.google.gson.*;
import com.mojang.logging.LogUtils;
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.concurrent.CompletableFuture;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.fml.loading.FMLPaths;
/** Aprendizaje corporal: JSON validado, acciones del cuerpo y evidencia real antes de compartir. */
public final class InfectedTools {
public enum Outcome { SUCCEEDED, FAILED, CANCELLED }
private static final Map<MinecraftServer, InfectedTools> SERVICES = new WeakHashMap<>();
private static final int MAX_ENTRIES = 64, MAX_LEDGER_BYTES = 1_048_576, AUTO_COOLDOWN = 1200;
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
private final Path directory;
private final byte[] signingKey;
private final Map<String, Learned> entries = new LinkedHashMap<>();
private Execution active;
private UUID observedBody, lastVisibleTarget;
private Vec3 lastPosition;
private float lastHealth;
private int blockedTicks;
private long nextAutomaticAt;
// En producción la ruta es fija. El constructor con ruta es únicamente para pruebas internas.
InfectedTools(Path folder) throws IOException {
directory = folder.toAbsolutePath().normalize(); checkDirectory(); Files.createDirectories(directory); checkDirectory();
signingKey = readOrCreateKey(); restore();
}
public static InfectedTools get(MinecraftServer server) {
if (!server.isSameThread()) throw new IllegalStateException("Las herramientas del huésped usan el hilo del servidor");
InfectedTools found = SERVICES.get(server);
if (found != null) return found;
try {
found = new InfectedTools(FMLPaths.CONFIGDIR.get().resolve("raps/Tools/infected")); SERVICES.put(server, found); return found;
} catch (IOException failure) { throw new IllegalStateException("No se pudo cargar Tools/infected; no se concedieron capacidades", failure); }
}
public static void tick(MinecraftServer server) {
InfectedTools service = SERVICES.get(server);
if (service == null && HorrorModule.findParasite(server).filter(ParasiteEntity::isAlive).isPresent()) service = get(server);
if (service != null) service.advance(server);
}
public static void close(MinecraftServer server) {
InfectedTools service = SERVICES.remove(server);
if (service == null) return;
if (service.active != null) service.finish(service.active, Outcome.CANCELLED, "SERVER_STOPPING");
}
public boolean busy() { return active != null; }
public void cancelCurrent() { if (active != null) finish(active, Outcome.CANCELLED, "CANCELLED_BY_OPERATOR"); }
// El autor proviene del cuerpo canónico, nunca de un UUID enviado por el modelo.
public InfectedToolManifest propose(ParasiteEntity body, String source) {
canonical(body);
try { return proposeForAuthor(body.hostId().orElseThrow(), source); }
catch (IOException failure) { throw new IllegalStateException("No se guardó la propuesta corporal", failure); }
}
InfectedToolManifest proposeForAuthor(UUID author, String source) throws IOException {
InfectedToolManifest manifest = InfectedToolManifest.parse(source, author);
validateComposition(manifest, new HashSet<>(), 0);
String key = key(manifest.id(), manifest.version()); Learned old = entries.get(key);
if (old != null) {
if (!old.manifest.sha256().equals(manifest.sha256())) throw invalid("La versión ya existe con otro contenido o autor");
return old.manifest;
}
if (entries.size() >= MAX_ENTRIES) throw invalid("Tools/infected alcanzó el límite de herramientas");
Learned candidate = new Learned(manifest); entries.put(key, candidate);
try { save(); }
catch (IOException failure) { entries.remove(key); throw failure; }
JsonObject readable = new JsonObject(); readable.addProperty("author", author.toString());
readable.addProperty("sha256", manifest.sha256()); readable.add("manifest", manifest.toJson());
try { write(directory.resolve(manifest.id() + "_v" + manifest.version() + ".json"), JSON.toJson(readable)); }
catch (IOException failure) { LogUtils.getLogger().warn("El manifiesto corporal quedó guardado en el ledger, pero no se pudo exportar su copia {}", manifest.id(), failure); }
log("PROPOSED", manifest, author, "Sin certificación ni ejecuciones inventadas"); return manifest;
}
public List<InfectedToolManifest> available(UUID actor) {
return entries.values().stream().filter(row -> row.certified || row.manifest.author().equals(actor) && row.failures < 8)
.map(row -> row.manifest).toList();
}
public Map<String, ToolCapability> catalog(UUID actor) {
Map<String, ToolCapability> result = new LinkedHashMap<>();
for (InfectedToolManifest manifest : available(actor)) {
ToolCapability capability = new ToolCapability(manifest.id(), manifest.version(),
manifest.description().substring(0, Math.min(390, manifest.description().length())) + " sha256=" + manifest.sha256()
+ (entries.get(key(manifest.id(), manifest.version())).certified ? " CERTIFIED" : " AUTHOR_TRIAL"),
manifest.permissions().stream().map(Enum::name).collect(java.util.stream.Collectors.toSet()));
ToolCapability old = result.get(manifest.id());
if (old == null || capability.version() > old.version()) result.put(manifest.id(), capability);
}
return Map.copyOf(result);
}
public Statistics statistics(String id, int version) {
Learned row = entries.get(key(id, version));
if (row == null) throw invalid("Herramienta corporal desconocida");
return new Statistics(row.successes, row.failures, row.cancellations, row.reuseSuccesses, row.reuseFailures, row.certified, row.lastResult);
}
// Sólo una ejecución corporal. La composición se compila por completo antes de tocar el mundo.
public CompletableFuture<Outcome> start(ParasiteEntity body, String id, int version, Map<String, String> inputs) {
MinecraftServer server = canonical(body); UUID actor = body.hostId().orElseThrow();
if (active != null) throw invalid("El cuerpo ya está ejecutando una herramienta");
Learned row = entries.get(key(id, version));
if (row == null || !row.certified && !row.manifest.author().equals(actor)) throw invalid("La herramienta no está disponible para este huésped");
if (!row.certified && row.failures >= 8) throw invalid("La herramienta requiere una versión corregida tras ocho fallos");
if (inputs.size() > 16) throw invalid("Demasiadas entradas corporales");
JsonObject values = new JsonObject();
for (var entry : inputs.entrySet()) {
if (!entry.getKey().matches("[a-z][a-zA-Z0-9_]{0,47}") || entry.getValue().length() > 512) throw invalid("Entrada corporal inválida");
values.addProperty(entry.getKey(), entry.getValue());
}
List<Compiled> plan = new ArrayList<>(); compile(row.manifest, values, plan, new HashSet<>(), 0);
active = new Execution(row, body, actor, server.overworld().getGameTime(), List.copyOf(plan), !row.certified);
log("STARTED", row.manifest, actor, active.id.toString());
return active.result;
}
public CompletableFuture<Outcome> startLatest(ParasiteEntity body, String id) {
InfectedToolManifest manifest = available(body.hostId().orElseThrow()).stream().filter(value -> value.id().equals(id))
.max(Comparator.comparingInt(InfectedToolManifest::version)).orElseThrow(() -> invalid("Herramienta corporal desconocida"));
return start(body, id, manifest.version(), Map.of());
}
private static MinecraftServer canonical(ParasiteEntity body) {
if (!(body.level() instanceof ServerLevel level) || !level.getServer().isSameThread() || !body.isAlive()) throw invalid("Falta un huésped vivo del servidor");
MinecraftServer server = level.getServer(); HorrorState state = HorrorState.get(server);
if (!HorrorModule.config(server).enabled() || body.hostId().isEmpty() || !state.host().filter(body.hostId().get()::equals).isPresent()
|| !state.entityId().filter(body.getUUID()::equals).isPresent() || HorrorModule.findParasite(server).filter(entity -> entity == body).isEmpty())
throw invalid("Sólo el cuerpo canónico del bot infectado puede usar Tools/infected");
return server;
}
private Counts validateComposition(InfectedToolManifest manifest, Set<String> visiting, int depth) {
if (depth > 4 || !visiting.add(manifest.sha256())) throw invalid("Dependencia circular o demasiado profunda");
int steps = 0, ticks = 0;
try {
for (ToolManifest.Dependency dependency : manifest.dependencies()) certified(dependency);
for (ToolManifest.Step step : manifest.steps()) {
if (step.primitive() != null) {
InfectedToolManifest.Permission needed = step.primitive().equals("speak") ? InfectedToolManifest.Permission.BODY_VOICE : InfectedToolManifest.Permission.BODY_MOVE;
if (!manifest.permissions().contains(needed)) throw invalid("La primitiva amplía permisos corporales");
steps++; ticks += step.timeoutTicks();
} else {
InfectedToolManifest child = certified(step.tool());
if (!manifest.permissions().containsAll(child.permissions())) throw invalid("La dependencia amplía permisos corporales");
Counts nested = validateComposition(child, visiting, depth + 1);
if (nested.ticks > step.timeoutTicks()) throw invalid("El límite del paso no alcanza para la dependencia");
steps += nested.steps; ticks += nested.ticks;
}
}
if (steps > manifest.maxSteps() || ticks > manifest.maxTicks()) throw invalid("La composición expandida excede sus límites");
return new Counts(steps, ticks);
} finally { visiting.remove(manifest.sha256()); }
}
private InfectedToolManifest certified(ToolManifest.Dependency dependency) {
Learned child = entries.get(key(dependency.id(), dependency.version()));
if (child == null || !child.certified || !child.manifest.sha256().equals(dependency.sha256())) throw invalid("Dependencia corporal no certificada o hash distinto");
return child.manifest;
}
private void compile(InfectedToolManifest manifest, JsonObject inputs, List<Compiled> output, Set<String> visiting, int depth) {
validateComposition(manifest, new HashSet<>(), 0);
if (depth > 4 || !visiting.add(manifest.sha256())) throw invalid("Dependencia circular");
try {
for (ToolManifest.Step step : manifest.steps()) {
JsonObject args = InfectedToolManifest.resolve(step.arguments(), inputs);
if (step.primitive() != null) {
InfectedToolManifest.validatePrimitive(step.primitive(), args, step.timeoutTicks(), false);
output.add(new Compiled(step.primitive(), args, step.timeoutTicks()));
} else compile(certified(step.tool()), args, output, visiting, depth + 1);
}
} finally { visiting.remove(manifest.sha256()); }
if (output.size() > InfectedToolManifest.MAX_STEPS) throw invalid("Demasiados pasos corporales expandidos");
}
// Tick local independiente de Luna. Cada paso conserva su plazo y un testigo del efecto real.
void advance(MinecraftServer server) {
if (!server.isSameThread()) throw new IllegalStateException("Ejecución corporal fuera del hilo del servidor");
if (active != null) advanceExecution(server);
if (active == null) automatic(server);
}
private void advanceExecution(MinecraftServer server) {
Execution run = active; long time = server.overworld().getGameTime();
try { if (canonical(run.body) != server || !run.actor.equals(run.body.hostId().orElse(null))) throw invalid("El cuerpo cambió"); }
catch (RuntimeException missing) { finish(run, Outcome.CANCELLED, "BODY_UNAVAILABLE"); return; }
if (time - run.started > run.row.manifest.maxTicks()) { finish(run, Outcome.FAILED, "TOOL_TIMEOUT"); return; }
if (run.step != null) {
long elapsed = time - run.stepStarted;
if (elapsed > run.step.timeoutTicks) { finish(run, Outcome.FAILED, "STEP_TIMEOUT"); return; }
boolean done;
if (run.voice != null) {
if (!run.voice.isDone()) return;
boolean delivered;
try { delivered = Boolean.TRUE.equals(run.voice.join()); }
catch (RuntimeException failure) { delivered = false; }
if (!delivered) { finish(run, Outcome.FAILED, "VOICE_NOT_DELIVERED"); return; }
run.useful++; done = true;
} else done = elapsed >= run.duration;
if (!done) return;
if (InfectedToolManifest.isIntent(run.step.primitive) && usefulIntent(run)) run.useful++;
run.completed++; run.step = null; run.voice = null;
}
if (run.completed == run.plan.size()) {
finish(run, run.useful > 0 ? Outcome.SUCCEEDED : Outcome.FAILED, run.useful > 0 ? "SUCCEEDED" : "NO_USEFUL_EFFECT"); return;
}
if (time - run.started >= run.row.manifest.maxTicks()) { finish(run, Outcome.FAILED, "TOOL_TIMEOUT"); return; }
run.step = run.plan.get(run.completed); run.stepStarted = time; run.before = run.body.position();
run.targetBefore = run.body.getTarget() == null ? null : run.body.getTarget().position();
run.attacksBefore = run.body.successfulAttackCount(); run.scaresBefore = run.body.deliveredScareCount();
if (InfectedToolManifest.isIntent(run.step.primitive)) {
run.duration = InfectedToolManifest.integer(run.step.args.get("durationTicks"), 20, 1200);
run.body.setIntent(ParasiteEntity.Intent.valueOf(run.step.primitive.toUpperCase(Locale.ROOT)), run.duration);
} else if (run.step.primitive.equals("wait")) {
run.duration = InfectedToolManifest.integer(run.step.args.get("durationTicks"), 1, 1200); run.body.pauseForTool(run.duration);
} else if (run.step.primitive.equals("posture")) {
int duration = run.step.args.has("durationTicks")
? InfectedToolManifest.integer(run.step.args.get("durationTicks"), 20, ParasiteEntity.MAX_POSTURE_TICKS)
: ParasiteEntity.DEFAULT_POSTURE_TICKS;
if (!run.body.requestBodyPosture(run.step.args.get("mode").getAsString(), duration)) {
finish(run, Outcome.FAILED, "POSTURE_NO_REAL_SUPPORT"); return;
}
run.duration = 1;
} else {
run.duration = 0; run.voice = HorrorSpeech.speak(run.body, InfectedToolManifest.text(run.step.args.get("text")));
}
}
private static boolean usefulIntent(Execution run) {
if (run.body.successfulAttackCount() > run.attacksBefore || run.body.deliveredScareCount() > run.scaresBefore) return true;
Vec3 after = run.body.position();
if (after.distanceToSqr(run.before) <= .04) return false;
if (run.step.primitive.equals("climb")) return after.y - run.before.y > .2;
if (run.step.primitive.equals("retreat") || run.step.primitive.equals("ambush")) {
if (run.targetBefore == null) return false;
double beforeDistance = run.before.distanceTo(run.targetBefore), afterDistance = after.distanceTo(run.targetBefore);
return run.step.primitive.equals("retreat") ? afterDistance - beforeDistance > .2 : beforeDistance - afterDistance > .2;
}
return true;
}
private void finish(Execution run, Outcome outcome, String reason) {
if (active != run) return;
active = null;
if (run.voice != null && !run.voice.isDone()) run.voice.cancel(false);
if (run.body.isAlive()) {
run.body.pauseForTool(0);
if (outcome != Outcome.SUCCEEDED) { run.body.getNavigation().stop(); run.body.setIntent(ParasiteEntity.Intent.STALK, 20); }
}
Learned row = run.row;
if (run.trial) {
if (outcome == Outcome.SUCCEEDED) row.successes++;
else if (outcome == Outcome.CANCELLED) row.cancellations++;
else row.failures++;
row.receipts.add(new Receipt(run.id, run.actor, run.started, outcome.name(), run.completed, run.useful));
if (row.receipts.size() > 32) {
Receipt removable = row.receipts.stream().filter(receipt -> !receipt.result.equals("SUCCEEDED")).findFirst().orElse(row.receipts.get(0));
row.receipts.remove(removable);
}
row.certified = row.successes >= 3 && (double) row.successes / (row.successes + row.failures) >= .8;
} else if (outcome == Outcome.SUCCEEDED) row.reuseSuccesses++;
else row.reuseFailures++;
row.lastResult = reason;
try { save(); log(outcome.name(), row.manifest, run.actor, run.id + " steps=" + run.completed + " useful=" + run.useful + " " + reason); }
catch (IOException failure) {
// Una persistencia fallida nunca entrega una nueva certificación.
row.certified = false; outcome = Outcome.FAILED; row.lastResult = "PERSISTENCE_FAILED";
LogUtils.getLogger().error("No se pudo guardar la ejecución corporal {}", row.manifest.id(), failure);
}
run.result.complete(outcome);
}
// Políticas retenidas y certificadas: sólo eventos reales, sin nuevas llamadas al modelo.
private void automatic(MinecraftServer server) {
ParasiteEntity body = HorrorModule.findParasite(server).orElse(null); if (body == null || !body.isAlive()) return;
try { canonical(body); } catch (RuntimeException unavailable) { return; }
ServerPlayer target = body.getTarget() instanceof ServerPlayer human && HorrorModule.mayTarget(server, human) && body.hasLineOfSight(human) ? human : null;
UUID visible = target == null ? null : target.getUUID(); EnumSet<InfectedToolManifest.Trigger> events = EnumSet.noneOf(InfectedToolManifest.Trigger.class);
if (!body.getUUID().equals(observedBody)) {
observedBody = body.getUUID(); lastVisibleTarget = null; lastPosition = body.position(); lastHealth = body.getHealth(); blockedTicks = 0;
}
if (visible != null && !visible.equals(lastVisibleTarget)) events.add(InfectedToolManifest.Trigger.ON_TARGET_VISIBLE);
if (body.getHealth() + .1 < lastHealth) events.add(InfectedToolManifest.Trigger.ON_DAMAGE);
boolean blocked = target != null && (body.horizontalCollision || !body.getNavigation().isDone() && body.position().distanceToSqr(lastPosition) < .0025);
blockedTicks = blocked ? blockedTicks + 1 : 0;
if (blockedTicks == 40) events.add(InfectedToolManifest.Trigger.ON_BLOCKED);
lastVisibleTarget = visible; lastPosition = body.position(); lastHealth = body.getHealth();
long time = server.overworld().getGameTime(); if (time < nextAutomaticAt || events.isEmpty()) return;
for (Learned row : entries.values()) if (row.certified && row.manifest.triggers().stream().anyMatch(events::contains)) {
nextAutomaticAt = time + AUTO_COOLDOWN;
try { start(body, row.manifest.id(), row.manifest.version(), Map.of()); log("AUTOMATIC", row.manifest, body.hostId().orElseThrow(), events.toString()); }
catch (RuntimeException missingInputs) { LogUtils.getLogger().debug("No se inició la herramienta corporal automática {}: {}", row.manifest.id(), missingInputs.getMessage()); }
return;
}
}
// Registro autenticado. Editar contadores o manifiestos no puede aprobar herramientas.
private void save() throws IOException {
checkDirectory(); JsonObject content = new JsonObject(); content.addProperty("schema", 1); JsonArray tools = new JsonArray();
for (Learned row : entries.values()) {
JsonObject tool = new JsonObject(); tool.addProperty("author", row.manifest.author().toString()); tool.addProperty("sha256", row.manifest.sha256());
tool.add("manifest", row.manifest.toJson()); tool.addProperty("createdAt", row.createdAt);
tool.addProperty("successes", row.successes); tool.addProperty("failures", row.failures); tool.addProperty("cancellations", row.cancellations);
tool.addProperty("reuseSuccesses", row.reuseSuccesses); tool.addProperty("reuseFailures", row.reuseFailures);
tool.addProperty("certified", row.certified); tool.addProperty("lastResult", row.lastResult);
tool.add("trials", JSON.toJsonTree(row.receipts)); tools.add(tool);
}
content.add("tools", tools); JsonObject envelope = new JsonObject(); envelope.add("content", content); envelope.addProperty("hmac", signature(content));
write(directory.resolve("learning.json"), JSON.toJson(envelope));
}
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();
if (!envelope.keySet().equals(Set.of("content", "hmac"))) throw invalid("Registro corporal incompatible");
JsonObject content = envelope.getAsJsonObject("content");
if (!MessageDigest.isEqual(signature(content).getBytes(StandardCharsets.US_ASCII), envelope.get("hmac").getAsString().getBytes(StandardCharsets.US_ASCII)))
throw invalid("El registro corporal fue adulterado");
if (!content.keySet().equals(Set.of("schema", "tools")) || content.get("schema").getAsInt() != 1 || content.getAsJsonArray("tools").size() > MAX_ENTRIES)
throw invalid("Registro corporal incompatible");
Set<UUID> receipts = new HashSet<>();
for (JsonElement item : content.getAsJsonArray("tools")) {
JsonObject raw = item.getAsJsonObject();
if (!raw.keySet().equals(Set.of("author", "sha256", "manifest", "createdAt", "successes", "failures", "cancellations", "reuseSuccesses", "reuseFailures", "certified", "lastResult", "trials")))
throw invalid("Campos del registro corporal incompatibles");
InfectedToolManifest manifest = InfectedToolManifest.parse(raw.get("manifest").toString(), UUID.fromString(raw.get("author").getAsString()));
if (!manifest.sha256().equals(raw.get("sha256").getAsString()) || entries.containsKey(key(manifest.id(), manifest.version()))) throw invalid("Versión o hash corporal alterados");
Learned row = new Learned(manifest); row.createdAt = raw.get("createdAt").getAsString();
row.successes = counter(raw, "successes"); row.failures = counter(raw, "failures"); row.cancellations = counter(raw, "cancellations");
row.reuseSuccesses = counter(raw, "reuseSuccesses"); row.reuseFailures = counter(raw, "reuseFailures"); row.certified = raw.get("certified").getAsBoolean(); row.lastResult = raw.get("lastResult").getAsString();
if (raw.getAsJsonArray("trials").size() > 32) throw invalid("Demasiadas evidencias corporales");
for (JsonElement record : raw.getAsJsonArray("trials")) {
Receipt receipt = JSON.fromJson(record, Receipt.class);
if (receipt.execution == null || !receipts.add(receipt.execution) || !manifest.author().equals(receipt.actor) || receipt.started < 0
|| receipt.steps < 0 || receipt.steps > 16 || receipt.useful < 0 || receipt.useful > 16 || !Set.of("SUCCEEDED", "FAILED", "CANCELLED").contains(receipt.result))
throw invalid("Evidencia corporal inválida");
row.receipts.add(receipt);
}
if (row.certified && (row.successes < 3 || (double)row.successes / (row.successes + row.failures) < .8
|| row.receipts.stream().filter(receipt -> receipt.result.equals("SUCCEEDED") && receipt.useful > 0).count() < 3)) throw invalid("Certificación corporal sin evidencia");
entries.put(key(manifest.id(), manifest.version()), row);
}
for (Learned row : entries.values()) validateComposition(row.manifest, new HashSet<>(), 0);
} catch (RuntimeException | StackOverflowError malformed) { entries.clear(); throw new IOException("Registro corporal inválido; no se otorgaron herramientas", malformed); }
}
private static long counter(JsonObject object, String key) {
long number = object.get(key).getAsBigDecimal().longValueExact();
if (number < 0 || number > 10_000_000) throw invalid("Contador corporal inválido"); return number;
}
private byte[] readOrCreateKey() throws IOException {
Path file = directory.resolve("certification.key");
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
checkFile(file, 32); byte[] key = Files.readAllBytes(file); if (key.length != 32) throw new IOException("Clave corporal inválida"); return key;
}
if (Files.exists(directory.resolve("learning.json"), LinkOption.NOFOLLOW_LINKS)) throw new IOException("Falta la clave del registro corporal");
byte[] key = new byte[32]; new SecureRandom().nextBytes(key); Files.write(file, key, StandardOpenOption.CREATE_NEW); return key;
}
private String signature(JsonObject content) {
try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(signingKey, "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(InfectedToolManifest.canonical(content).getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException failure) { throw new IllegalStateException(failure); }
}
private void checkDirectory() throws IOException {
for (Path path = directory; path != null; path = path.getParent()) if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (attrs.isSymbolicLink() || attrs.isOther() || !path.toRealPath().equals(path)) throw new IOException("Tools/infected no atraviesa enlaces o redirecciones");
}
}
private static void checkFile(Path file, long max) throws IOException {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) || Files.size(file) > max) throw new IOException("Archivo corporal inválido");
}
private void write(Path file, String source) throws IOException {
if (!file.getParent().equals(directory) || Files.isSymbolicLink(file) || source.getBytes(StandardCharsets.UTF_8).length > MAX_LEDGER_BYTES) throw new IOException("Escritura corporal fuera de límites");
Path temp = directory.resolve("writing_" + UUID.randomUUID() + ".tmp");
try { Files.writeString(temp, source, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW);
try { Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
catch (AtomicMoveNotSupportedException unavailable) { Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); }
} finally { Files.deleteIfExists(temp); }
}
private void log(String event, InfectedToolManifest manifest, UUID actor, String detail) {
LogUtils.getLogger().info("Tools/infected {} {} v{} actor={} {}", event, manifest.id(), manifest.version(), actor, detail);
}
private static String key(String id, int version) { return id + ":" + version; }
private static IllegalArgumentException invalid(String text) { return InfectedToolManifest.invalid(text); }
public record Statistics(long successes, long failures, long cancellations, long reuseSuccesses, long reuseFailures, boolean certified, String lastResult) { }
private record Counts(int steps, int ticks) { }
private record Compiled(String primitive, JsonObject args, int timeoutTicks) { }
private record Receipt(UUID execution, UUID actor, long started, String result, int steps, int useful) { }
private static final class Learned {
final InfectedToolManifest manifest; String createdAt = Instant.now().toString(), lastResult = "";
long successes, failures, cancellations, reuseSuccesses, reuseFailures; boolean certified;
final List<Receipt> receipts = new ArrayList<>();
Learned(InfectedToolManifest manifest) { this.manifest = manifest; }
}
private static final class Execution {
final UUID id = UUID.randomUUID(); final Learned row; final ParasiteEntity body; final UUID actor; final long started;
final List<Compiled> plan; final boolean trial; final CompletableFuture<Outcome> result = new CompletableFuture<>();
int completed, useful, duration; long stepStarted, attacksBefore, scaresBefore; Vec3 before, targetBefore; Compiled step; CompletableFuture<Boolean> voice;
Execution(Learned row, ParasiteEntity body, UUID actor, long started, List<Compiled> plan, boolean trial) {
this.row = row; this.body = body; this.actor = actor; this.started = started; this.plan = plan; this.trial = trial;
}
}
}