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 INTENTS = Set.of("stalk", "retreat", "ambush", "scare", "climb"); private static final Set 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 permissions; private final Set triggers; private final String sha256; private InfectedToolManifest(JsonObject source, UUID author) { original = source.deepCopy(); JsonObject mapped = source.deepCopy(); JsonArray translated = new JsonArray(); EnumSet 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 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 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 permissions() { return permissions; } public Set triggers() { return triggers; } public List steps() { return structural.steps(); } public List 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 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 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); } }