Volver al índice

src/main/java/ar/com/companeros/horror/tools/InfectedToolManifest.java

package ar.com.companeros.horror.tools;

import ar.com.companeros.tools.ToolManifest;
import com.google.gson.*;
import com.google.gson.stream.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.*;

/** Composición corporal declarativa. Comparte el formato estructural de Tools, nunca Java o scripts. */
public final class InfectedToolManifest {
    public enum Permission { BODY_MOVE, BODY_VOICE }
    public enum Trigger { ON_TARGET_VISIBLE, ON_DAMAGE, ON_BLOCKED }
    public static final int MAX_TICKS = 1200, MAX_STEPS = 16, MAX_SOURCE_BYTES = 4096;
    private static final Set<String> INTENTS = Set.of("stalk", "retreat", "ambush", "scare", "climb");
    private static final Set<String> RESERVED = Set.of("propose_infected_tool", "parasite_speak", "parasite_stalk",
        "parasite_retreat", "parasite_ambush", "parasite_scare", "parasite_climb");
    private final ToolManifest structural;
    private final JsonObject original;
    private final Set<Permission> permissions;
    private final Set<Trigger> triggers;
    private final String sha256;

    private InfectedToolManifest(JsonObject source, UUID author) {
        original = source.deepCopy();
        JsonObject mapped = source.deepCopy(); JsonArray translated = new JsonArray();
        EnumSet<Permission> requested = EnumSet.noneOf(Permission.class);
        if (!source.has("permissions") || !source.get("permissions").isJsonArray()) throw invalid("Faltan permisos corporales");
        for (JsonElement value : source.getAsJsonArray("permissions")) {
            if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw invalid("Permiso corporal inválido");
            Permission permission;
            try { permission = Permission.valueOf(value.getAsString()); }
            catch (IllegalArgumentException unknown) { throw invalid("Sólo se permiten BODY_MOVE y BODY_VOICE"); }
            if (!requested.add(permission)) throw invalid("Permiso repetido");
            translated.add(permission == Permission.BODY_MOVE ? "MOVE" : "GLOBAL_CHAT");
        }
        mapped.add("permissions", translated);
        EnumSet<Trigger> declaredTriggers = EnumSet.noneOf(Trigger.class);
        if (source.has("triggers")) {
            if (!source.get("triggers").isJsonArray() || source.getAsJsonArray("triggers").size() > 3) throw invalid("Disparadores inválidos");
            for (JsonElement value : source.getAsJsonArray("triggers")) {
                if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw invalid("Disparador inválido");
                try { if (!declaredTriggers.add(Trigger.valueOf(value.getAsString()))) throw invalid("Disparador repetido"); }
                catch (IllegalArgumentException unknown) { throw invalid("Disparador corporal no permitido"); }
            }
        }
        mapped.remove("triggers"); triggers = Set.copyOf(declaredTriggers);
        structural = ToolManifest.parse(mapped.toString(), author);
        permissions = Set.copyOf(requested);
        if (RESERVED.contains(id()) || id().startsWith("parasite_")) throw invalid("No se sustituye una capacidad instalada");
        if (maxTicks() > MAX_TICKS || maxSteps() > MAX_STEPS || structural.dependencies().size() > 8)
            throw invalid("La composición corporal admite hasta 16 pasos y 1200 ticks");
        for (ToolManifest.Step step : steps()) if (step.primitive() != null) validatePrimitive(step, true);
        JsonObject authenticated = source.deepCopy(); authenticated.addProperty("trustedAuthor", author.toString());
        try { sha256 = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical(authenticated).getBytes(StandardCharsets.UTF_8))); }
        catch (NoSuchAlgorithmException failure) { throw new IllegalStateException(failure); }
    }

    // Lectura estricta: campos duplicados y JSON profundo no pueden esconder capacidades adicionales.
    public static InfectedToolManifest parse(String json, UUID trustedAuthor) {
        if (json == null || json.getBytes(StandardCharsets.UTF_8).length > MAX_SOURCE_BYTES) throw invalid("Manifiesto corporal demasiado grande");
        try (JsonReader reader = new JsonReader(new StringReader(json))) {
            reader.setLenient(false); readValue(reader, 0, new int[]{0});
            if (reader.peek() != JsonToken.END_DOCUMENT) throw invalid("Contenido adicional en el manifiesto");
        } catch (IOException | StackOverflowError failure) { throw invalid("JSON corporal inválido"); }
        JsonElement source = JsonParser.parseString(json);
        if (!source.isJsonObject()) throw invalid("El manifiesto debe ser un objeto");
        return new InfectedToolManifest(source.getAsJsonObject(), Objects.requireNonNull(trustedAuthor));
    }
    private static void readValue(JsonReader reader, int depth, int[] count) throws IOException {
        if (depth > 12 || ++count[0] > 2048) throw invalid("JSON corporal demasiado complejo");
        switch (reader.peek()) {
            case BEGIN_OBJECT -> {
                reader.beginObject(); Set<String> keys = new HashSet<>();
                while (reader.hasNext()) { if (!keys.add(reader.nextName())) throw invalid("Campo duplicado"); readValue(reader, depth + 1, count); }
                reader.endObject();
            }
            case BEGIN_ARRAY -> { reader.beginArray(); while (reader.hasNext()) readValue(reader, depth + 1, count); reader.endArray(); }
            case STRING, NUMBER -> reader.nextString();
            case BOOLEAN -> reader.nextBoolean();
            case NULL -> reader.nextNull();
            default -> throw invalid("Token JSON inválido");
        }
    }

    // Lista cerrada de acciones: no hay UUID, etapas, atributos, rutas, comandos ni seguridad modificables.
    static void validatePrimitive(ToolManifest.Step step, boolean references) {
        validatePrimitive(step.primitive(), step.arguments(), step.timeoutTicks(), references);
    }
    static void validatePrimitive(String name, JsonObject args, int timeoutTicks, boolean references) {
        if (INTENTS.contains(name) || name.equals("wait")) {
            if (!args.keySet().equals(Set.of("durationTicks"))) throw invalid("La acción corporal sólo acepta durationTicks");
            JsonElement value = args.get("durationTicks");
            if (references && value.isJsonObject()) return;
            int duration = integer(value, name.equals("wait") ? 1 : 20, MAX_TICKS);
            if (duration > timeoutTicks) throw invalid("La duración excede el límite del paso");
        } else if (name.equals("posture")) {
            if (!args.keySet().equals(Set.of("mode")) && !args.keySet().equals(Set.of("mode", "durationTicks")))
                throw invalid("posture sólo acepta mode y durationTicks opcional");
            JsonElement value = args.get("mode");
            if (!(references && value.isJsonObject())) {
                if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw invalid("Postura corporal inválida");
                ar.com.companeros.horror.ParasiteEntity.parseBodyPosture(value.getAsString());
            }
            if (args.has("durationTicks") && !(references && args.get("durationTicks").isJsonObject()))
                integer(args.get("durationTicks"), 20, ar.com.companeros.horror.ParasiteEntity.MAX_POSTURE_TICKS);
        } else if (name.equals("speak")) {
            if (!args.keySet().equals(Set.of("text"))) throw invalid("speak sólo acepta text");
            JsonElement value = args.get("text");
            if (references && value.isJsonObject()) return;
            text(value);
        } else throw invalid("Primitiva corporal no permitida: " + name);
    }
    static int integer(JsonElement value, int min, int max) {
        if (value == null || !value.isJsonPrimitive()) throw invalid("Duración inválida");
        int number;
        try { number = value.getAsBigDecimal().intValueExact(); }
        catch (ArithmeticException | NumberFormatException failure) { throw invalid("Duración no entera"); }
        if (number < min || number > max) throw invalid("Duración fuera de límite");
        return number;
    }
    static String text(JsonElement value) {
        if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw invalid("Falta el texto corporal");
        String text = value.getAsString().strip();
        if (text.isEmpty() || text.length() > 320 || text.codePoints().anyMatch(character -> Character.isISOControl(character)))
            throw invalid("La voz admite entre 1 y 320 caracteres sin controles");
        return text;
    }
    static JsonObject resolve(JsonObject template, JsonObject inputs) {
        JsonObject result = new JsonObject();
        for (var entry : template.entrySet()) {
            JsonElement value = entry.getValue();
            if (value.isJsonObject()) {
                String key = value.getAsJsonObject().get("input").getAsString();
                value = inputs.get(key);
                if (value == null) throw invalid("Falta la entrada corporal " + key);
            }
            if (!value.isJsonPrimitive()) throw invalid("Entrada corporal no escalar");
            result.add(entry.getKey(), value.deepCopy());
        }
        return result;
    }
    public String id() { return structural.id(); }
    public int version() { return structural.version(); }
    public UUID author() { return structural.author(); }
    public String description() { return structural.description(); }
    public String sha256() { return sha256; }
    public int maxTicks() { return structural.maxTicks(); }
    public int maxSteps() { return structural.maxSteps(); }
    public Set<Permission> permissions() { return permissions; }
    public Set<Trigger> triggers() { return triggers; }
    public List<ToolManifest.Step> steps() { return structural.steps(); }
    public List<ToolManifest.Dependency> dependencies() { return structural.dependencies(); }
    public JsonObject toJson() { return original.deepCopy(); }
    static boolean isIntent(String primitive) { return INTENTS.contains(primitive); }
    static String canonical(JsonElement element) {
        if (element.isJsonObject()) {
            List<String> parts = new ArrayList<>();
            for (String key : new TreeSet<>(element.getAsJsonObject().keySet())) parts.add(new JsonPrimitive(key) + ":" + canonical(element.getAsJsonObject().get(key)));
            return "{" + String.join(",", parts) + "}";
        }
        if (element.isJsonArray()) {
            List<String> parts = new ArrayList<>(); for (JsonElement value : element.getAsJsonArray()) parts.add(canonical(value));
            return "[" + String.join(",", parts) + "]";
        }
        return element.toString();
    }
    static IllegalArgumentException invalid(String reason) { return new IllegalArgumentException(reason); }
}