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); }
}