package ar.com.companeros.horror; import ar.com.companeros.decision.*; import ar.com.companeros.identity.AgentRegistry; import ar.com.companeros.runtime.AgentRuntime; import ar.com.companeros.social.AgentCommunication; import com.google.gson.JsonObject; import java.nio.charset.StandardCharsets; import java.util.*; import net.minecraft.nbt.*; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.ai.attributes.*; import net.minecraft.world.effect.*; import net.minecraft.world.item.*; import net.minecraft.world.level.saveddata.SavedData; import net.minecraftforge.registries.ForgeRegistries; /** Propuesta voluntaria → tasación neutral → pago físico → mejora acotada y persistente. */ public final class ParasiteUpgrades extends SavedData { public enum Stat { HEALTH, SPEED, DAMAGE, PERCEPTION } private final EnumMap levels = new EnumMap<>(Stat.class); private final Map adaptations = new TreeMap<>(); private int visualUpgrades; private int appearance; private final ArrayDeque history = new ArrayDeque<>(); private UUID proposer, quoteId; private String requested; private String goal = "", explanation = "", status = "Sin propuestas"; private Map costs = Map.of(); private long quotedAt, lastRequest = -24_000, sequence; private boolean pending; private long defeats, hitsTaken; private final Map damageKinds = new TreeMap<>(); private static final Map VALUE = Map.of("minecraft:iron_ingot", 1D, "minecraft:copper_ingot", .25D, "minecraft:gold_ingot", 2D, "minecraft:diamond", 8D, "minecraft:redstone", .5D); // Datos del mundo y límites de efectos. No se asigna una alianza por abrir este servicio. public static ParasiteUpgrades get(MinecraftServer server) { if (!server.isSameThread()) throw new IllegalStateException("Las mejoras usan el hilo del servidor"); return server.overworld().getDataStorage().computeIfAbsent(ParasiteUpgrades::load, ParasiteUpgrades::new, "raps_parasite_upgrades"); } public int level(Stat stat) { return levels.getOrDefault(stat, 0); } public int level(String target) { try { return level(Stat.valueOf(target)); } catch (IllegalArgumentException failure) { return adaptations.getOrDefault(target, 0); } } public static String capacity(String name) { if (name == null || name.length() > 128) throw new IllegalArgumentException("Capacidad inválida"); try { return Stat.valueOf(name.toUpperCase(Locale.ROOT)).name(); } catch (IllegalArgumentException ignored) { } if (Set.of("visual:iron", "visual:diamond", "visual:copper", "visual:obsidian").contains(name)) return name; if (name.startsWith("effect:")) { ResourceLocation id = ResourceLocation.tryParse(name.substring(7)); if (id != null && ForgeRegistries.MOB_EFFECTS.containsKey(id)) return "effect:" + id; } if (name.startsWith("attribute:")) { ResourceLocation id = ResourceLocation.tryParse(name.substring(10)); if (id != null && ForgeRegistries.ATTRIBUTES.containsKey(id)) return "attribute:" + id; } throw new IllegalArgumentException("Capacidad desconocida"); } public String describe() { return status + (costs.isEmpty() ? "" : "; cotización=" + quoteId + " materiales=" + costs) + "; mejoras=" + levels + "; adaptaciones=" + adaptations.entrySet().stream().limit(6).toList() + " (total=" + adaptations.size() + "); derrotas=" + defeats + "; impactos recibidos=" + hitsTaken; } public void combatResult(boolean defeated) { if (defeated) { defeats++; append("Derrota del huésped; niveles=" + levels); } else hitsTaken++; setDirty(); } public void damageObserved(String cause) { if (cause == null || cause.length() > 64) return; hitsTaken++; if (damageKinds.containsKey(cause) || damageKinds.size() < 16) damageKinds.merge(cause, 1L, Long::sum); setDirty(); } // Sólo el tasador puede entregar costs: este paso no existe en el catálogo de herramientas del bot. public String propose(ServerPlayer bot, String stat, String objective) { MinecraftServer server = bot.server; if (AgentRegistry.get(server).isHuman(bot) || !HorrorState.get(server).active()) return "No hay un huésped con quien colaborar"; String wanted; try { wanted = capacity(stat); } catch (RuntimeException invalid) { return "Capacidad desconocida"; } long time = server.overworld().getGameTime(); if (objective == null || objective.isBlank() || objective.length() > 220 || objective.contains("\n")) return "Objetivo inválido"; if (pending || time - lastRequest < 1200) return "Hay una tasación pendiente o en espera"; if (level(wanted) >= 3) return "La capacidad alcanzó su límite"; if (!adaptations.containsKey(wanted) && adaptations.size() >= 32) return "El cuerpo ya tiene32 adaptaciones"; proposer = bot.getUUID(); requested = wanted; goal = objective; quoteId = UUID.randomUUID(); costs = Map.of(); pending = true; lastRequest = time; sequence++; status = "Tasación solicitada"; setDirty(); UUID expected = quoteId; UUID actor = UUID.nameUUIDFromBytes(("raps-neutral-appraisal:" + AgentRegistry.get(server).worldId()).getBytes(StandardCharsets.UTF_8)); String effect = switch (wanted) { case "HEALTH" -> "+8 de salud máxima, sin curar"; case "SPEED" -> "+8% velocidad base, sin atravesar bloques"; case "DAMAGE" -> "+1.5 daño de ataque"; case "PERCEPTION" -> "+6 bloques de alcance de percepción"; default -> wanted.startsWith("visual:") ? "Piezas articuladas visibles, sin atributos gratis. El precio debe incluir al menos cuatro unidades del material indicado por nivel: iron_ingot, diamond, copper_ingot u obsidian." : wanted.startsWith("effect:") ? "Efecto real del registro, potencia=nivel-1. Si es instantáneo sólo se activa al montar; otros son adaptaciones persistentes." : "Atributo real del registro: +2 por nivel, sujeto a límites del atributo. Para velocidad se usa+8% por nivel."; }; List observations = new ArrayList<>(List.of("Capacidad=" + wanted + "; nivel solicitado=" + (level(wanted) + 1) + "; efecto=" + effect, "materiales base registrados: minecraft:iron_ingot,minecraft:copper_ingot,minecraft:gold_ingot,minecraft:diamond,minecraft:redstone", "impactos recibidos=" + hitsTaken + "; derrotas=" + defeats, "Daños observados por causa: " + damageKinds.toString().substring(0, Math.min(400, damageKinds.toString().length())))); history.stream().skip(Math.max(0, history.size() - 4)).forEach(observations::add); ObservationSnapshot observation = new ObservationSnapshot(AgentRegistry.get(server).worldId(), bot.level().dimension().location().toString(), bot.getBlockX(), bot.getBlockY(), bot.getBlockZ(), bot.getHealth(), bot.getFoodData().getFoodLevel(), Map.of(), "Cotizar mejora " + wanted, observations, List.of(), List.of("Objetivo solicitado: " + objective)); ToolCapability quote = new ToolCapability("quote_parasite_upgrade", 1, "Costos para la mejora limitada. Args costs:string JSON itemID:count, rationale:string.", Set.of("APPRAISE")); DecisionRequest request = new DecisionRequest(UUID.randomUUID(), actor, 1, sequence, "Tasación neutral de " + wanted, observation, Map.of(quote.id(), quote), Set.of("APPRAISE"), DecisionRequest.Kind.UPGRADE_APPRAISAL, null); try { AgentRuntime.appraiseUpgrade(server, request).whenComplete((answer, failure) -> server.execute(() -> { if (!expected.equals(quoteId)) return; pending = false; costs = Map.of(); try { if (failure != null || answer == null || !answer.accepted() || answer.decision().steps().size() != 1) throw new IllegalArgumentException("Sin tasación válida"); var step = answer.decision().steps().get(0); if (!step.toolId().equals("quote_parasite_upgrade") || !step.arguments().keySet().equals(Set.of("costs", "rationale"))) throw new IllegalArgumentException("Tasación inválida"); costs = validateCosts(step.arguments().get("costs"), level(requested) + 1); validateVisualMaterial(requested, costs, level(requested) + 1); explanation = step.arguments().get("rationale"); if (explanation.length() > 220) explanation = explanation.substring(0, 220); quotedAt = server.overworld().getGameTime(); status = "Cotización disponible: " + requested; } catch (RuntimeException rejected) { status = "La mejora no obtuvo una cotización válida"; } append(status + (costs.isEmpty() ? "" : "; materiales=" + costs)); setDirty(); AgentCommunication.sendInternal(server, proposer, proposer.toString(), describe()); })); } catch (RuntimeException unavailable) { pending = false; status = "La tasación no está disponible"; setDirty(); } return status; } // Validación local del precio: cantidades enteras, registro real, costo mínimo y sin contenedores. static Map validateCosts(String json, int nextLevel) { JsonObject data = DecisionCodec.strictObject(json, 2048); if (nextLevel < 1 || nextLevel > 3 || data.size() < 1 || data.size() > 8) throw new IllegalArgumentException("Costo fuera de límites"); Map result = new TreeMap<>(); int total = 0; double points = 0; for (var entry : data.entrySet()) { ResourceLocation id = ResourceLocation.tryParse(entry.getKey()); if (id == null || !ForgeRegistries.ITEMS.containsKey(id) || !entry.getValue().isJsonPrimitive() || !entry.getValue().getAsJsonPrimitive().isNumber()) throw new IllegalArgumentException("Material desconocido"); Item item = ForgeRegistries.ITEMS.getValue(id); if (item == Items.AIR || item.canFitInsideContainerItems() == false || item instanceof BucketItem || item instanceof net.minecraft.world.item.BlockItem block && block.getBlock() instanceof net.minecraft.world.level.block.EntityBlock) throw new IllegalArgumentException("Material no consumible"); int count = entry.getValue().getAsBigDecimal().intValueExact(); if (count < 1 || count > 512 || (total += count) > 1024) throw new IllegalArgumentException("Cantidad fuera de límite"); result.put(id.toString(), count); points += VALUE.getOrDefault(id.toString(), 0D) * count; } if (points < 16 * nextLevel) throw new IllegalArgumentException("Costo insuficiente"); return Map.copyOf(result); } static boolean consume(net.minecraft.world.entity.player.Inventory inventory, Map price) { for (var entry : price.entrySet()) { Item item = ForgeRegistries.ITEMS.getValue(new ResourceLocation(entry.getKey())); int found = 0; for (ItemStack stack : inventory.items) if (stack.is(item) && !stack.hasTag()) found += stack.getCount(); if (found < entry.getValue()) return false; } for (var entry : price.entrySet()) { Item item = ForgeRegistries.ITEMS.getValue(new ResourceLocation(entry.getKey())); int left = entry.getValue(); for (ItemStack stack : inventory.items) if (stack.is(item) && !stack.hasTag() && left > 0) { int count = Math.min(left, stack.getCount()); stack.shrink(count); left -= count; } } inventory.setChanged(); return true; } // Montaje físico: mismo mundo, cerca del cuerpo canónico, pago completo y una sola aplicación. public String apply(ServerPlayer bot, String quotedId) { MinecraftServer server = bot.server; if (AgentRegistry.get(server).isHuman(bot) || pending || costs.isEmpty() || quoteId == null || !quoteId.toString().equals(quotedId)) return "Cotización no disponible"; if (server.overworld().getGameTime() - quotedAt > 24_000 || level(requested) >= 3) return "Cotización vencida"; ParasiteEntity body = HorrorModule.findParasite(server).filter(ParasiteEntity::isAlive).orElse(null); if (body == null || body.level() != bot.level() || body.distanceToSqr(bot) > 36) return "Acercate al huésped para montar la mejora"; if (requested.startsWith("attribute:") && body.getAttribute(ForgeRegistries.ATTRIBUTES.getValue(new ResourceLocation(requested.substring(10)))) == null) return "El cuerpo no admite ese atributo"; validateVisualMaterial(requested, costs, level(requested) + 1); if (!consume(bot.getInventory(), costs)) return "Faltan materiales para completar la mejora"; Map paid = costs; visualUpgrades |= appearanceFor(requested, paid); int style = requested.equals("visual:diamond") || (appearanceFor(requested, paid) & 4) != 0 ? 3 : requested.equals("visual:copper") ? 4 : (appearanceFor(requested, paid) & 1) != 0 ? 1 : 2; if (appearanceFor(requested, paid) != 0) appearance = style | style << 3 | style << 6; try { levels.put(Stat.valueOf(requested), level(requested) + 1); } catch (IllegalArgumentException dynamic) { adaptations.put(requested, level(requested) + 1); } costs = Map.of(); applyAttributes(body); status = "Mejora montada: " + requested + " nivel " + level(requested); append(status + "; bot=" + bot.getUUID() + "; pagó=" + paid); setDirty(); if (requested.startsWith("effect:")) { MobEffect effect = ForgeRegistries.MOB_EFFECTS.getValue(new ResourceLocation(requested.substring(7))); if (effect != null && effect.isInstantenous()) effect.applyInstantenousEffect(bot, bot, body, level(requested) - 1, 1); } server.getPlayerList().saveAll(); server.overworld().getDataStorage().save(); return status; } public void applyAttributes(ParasiteEntity body) { body.setVisualUpgrades(appearance); for (Stat stat : Stat.values()) { Attribute attribute = switch (stat) { case HEALTH -> Attributes.MAX_HEALTH; case SPEED -> Attributes.MOVEMENT_SPEED; case DAMAGE -> Attributes.ATTACK_DAMAGE; case PERCEPTION -> Attributes.FOLLOW_RANGE; }; AttributeInstance instance = body.getAttribute(attribute); if (instance == null) continue; UUID id = UUID.nameUUIDFromBytes(("raps-upgrade-" + stat).getBytes(StandardCharsets.UTF_8)); double amount = level(stat) * switch (stat) { case HEALTH -> 8D; case SPEED -> .08D; case DAMAGE -> 1.5D; case PERCEPTION -> 6D; }; AttributeModifier old = instance.getModifier(id); if (old != null && old.getAmount() == amount) continue; instance.removeModifier(id); if (amount > 0) instance.addTransientModifier(new AttributeModifier(id, "raps_upgrade_" + stat, amount, stat == Stat.SPEED ? AttributeModifier.Operation.MULTIPLY_BASE : AttributeModifier.Operation.ADDITION)); } for (var adaptation : adaptations.entrySet()) { if (adaptation.getKey().startsWith("effect:")) { MobEffect effect = ForgeRegistries.MOB_EFFECTS.getValue(new ResourceLocation(adaptation.getKey().substring(7))); if (effect != null && !effect.isInstantenous()) { MobEffectInstance current = body.getEffect(effect); if (current == null || current.getDuration() < 60 || current.getAmplifier() < adaptation.getValue() - 1) body.addEffect(new MobEffectInstance(effect, 160, adaptation.getValue() - 1, true, false, false)); } } else if (adaptation.getKey().startsWith("attribute:")) { Attribute attribute = ForgeRegistries.ATTRIBUTES.getValue(new ResourceLocation(adaptation.getKey().substring(10))); AttributeInstance instance = attribute == null ? null : body.getAttribute(attribute); if (instance == null) continue; UUID id = UUID.nameUUIDFromBytes(("raps-adaptation-" + adaptation.getKey()).getBytes(StandardCharsets.UTF_8)); double amount = adaptation.getValue() * (attribute == Attributes.MOVEMENT_SPEED ? .08D : 2D); AttributeModifier old = instance.getModifier(id); if (old != null && old.getAmount() == amount) continue; instance.removeModifier(id); instance.addTransientModifier(new AttributeModifier(id, "raps_adaptation", amount, attribute == Attributes.MOVEMENT_SPEED ? AttributeModifier.Operation.MULTIPLY_BASE : AttributeModifier.Operation.ADDITION)); } } } public static List catalog(String query) { String match = query.toLowerCase(Locale.ROOT); List result = new ArrayList<>(); for (String visual : List.of("iron", "diamond", "copper", "obsidian")) if (("visual:" + visual).contains(match)) result.add("visual:" + visual); ForgeRegistries.MOB_EFFECTS.getKeys().stream().sorted().map(id -> "effect:" + id) .filter(id -> id.contains(match)).limit(24).forEach(result::add); ForgeRegistries.ATTRIBUTES.getKeys().stream().sorted().map(id -> "attribute:" + id) .filter(id -> id.contains(match)).limit(16).forEach(result::add); return result; } private void append(String message) { while (history.size() >= 12) history.removeFirst(); history.addLast(message.length() > 512 ? message.substring(0, 512) : message); } // Persistencia: una cotización del modelo se revalida al cargar; nunca guarda una llamada pendiente. public static ParasiteUpgrades load(CompoundTag tag) { ParasiteUpgrades state = new ParasiteUpgrades(); state.visualUpgrades = tag.getInt("VisualUpgrades") & 15; state.appearance = tag.getInt("Appearance") & 511; for (Stat stat : Stat.values()) state.levels.put(stat, Math.max(0, Math.min(3, tag.getInt(stat.name())))); CompoundTag adaptations = tag.getCompound("Adaptations"); for (String target : adaptations.getAllKeys().stream().sorted().limit(32).toList()) { try { String valid = capacity(target); int level = Math.max(0, Math.min(3, adaptations.getInt(target))); if (level > 0 && (valid.startsWith("effect:") || valid.startsWith("attribute:") || valid.startsWith("visual:"))) state.adaptations.put(valid, level); } catch (RuntimeException invalid) { } } state.defeats = Math.max(0, tag.getLong("Defeats")); state.hitsTaken = Math.max(0, tag.getLong("Hits")); CompoundTag damage = tag.getCompound("DamageKinds"); for (String cause : damage.getAllKeys().stream().sorted().limit(16).toList()) if (cause.length() <= 64) state.damageKinds.put(cause, Math.max(0, damage.getLong(cause))); state.lastRequest = tag.getLong("LastRequest"); state.sequence = Math.max(0, tag.getLong("Sequence")); ListTag history = tag.getList("History", Tag.TAG_STRING); for (int i = 0; i < Math.min(12, history.size()); i++) state.append(history.getString(i)); try { if (tag.hasUUID("Proposer") && tag.hasUUID("Quote")) { state.proposer = tag.getUUID("Proposer"); state.quoteId = tag.getUUID("Quote"); state.requested = capacity(tag.getString("Stat")); state.costs = validateCosts(tag.getString("Costs"), state.level(state.requested) + 1); state.quotedAt = tag.getLong("QuotedAt"); state.status = "Cotización disponible: " + state.requested; } } catch (RuntimeException rejected) { state.costs = Map.of(); } return state; } @Override public CompoundTag save(CompoundTag tag) { tag.putInt("VisualUpgrades", visualUpgrades); tag.putInt("Appearance", appearance); for (Stat stat : Stat.values()) tag.putInt(stat.name(), level(stat)); CompoundTag adaptationData = new CompoundTag(); adaptations.forEach(adaptationData::putInt); tag.put("Adaptations", adaptationData); tag.putLong("Defeats", defeats); tag.putLong("Hits", hitsTaken); tag.putLong("LastRequest", lastRequest); tag.putLong("Sequence", sequence); CompoundTag damage = new CompoundTag(); damageKinds.forEach(damage::putLong); tag.put("DamageKinds", damage); ListTag records = new ListTag(); history.forEach(message -> records.add(StringTag.valueOf(message))); tag.put("History", records); if (!costs.isEmpty() && proposer != null) { tag.putUUID("Proposer", proposer); tag.putUUID("Quote", quoteId); tag.putString("Stat", requested); JsonObject price = new JsonObject(); costs.forEach(price::addProperty); tag.putString("Costs", price.toString()); tag.putLong("QuotedAt", quotedAt); } return tag; } static void validateVisualMaterial(String requested, Map cost, int nextLevel) { if (!requested.startsWith("visual:")) return; String material = switch (requested) { case "visual:iron" -> "minecraft:iron_ingot"; case "visual:diamond" -> "minecraft:diamond"; case "visual:copper" -> "minecraft:copper_ingot"; case "visual:obsidian" -> "minecraft:obsidian"; default -> throw new IllegalArgumentException("Aspecto desconocido"); }; if (cost.getOrDefault(material, 0) < nextLevel * 4) throw new IllegalArgumentException("Falta material para fabricar las piezas visibles"); } public String appearance(ServerPlayer bot, String prefab, String slot) { ParasiteEntity body = HorrorModule.findParasite(bot.server).orElse(null); if (AgentRegistry.get(bot.server).isHuman(bot) || body == null || bot.level() != body.level() || bot.distanceToSqr(body) > 36) return "Acercate al huésped para montar la pieza"; int style = switch (prefab) { case "obsidian" -> 1; case "iron" -> 2; case "diamond" -> 3; case "copper" -> 4; case "none" -> 0; default -> -1; }; int index = switch (slot) { case "chest" -> 0; case "arms" -> 1; case "legs" -> 2; default -> -1; }; if (style < 0 || index < 0) return "Prefab o articulación desconocido"; int flag = style == 1 ? 1 : style == 2 ? 2 : style == 3 ? 4 : style == 4 ? 8 : 0; if (flag != 0 && (visualUpgrades & flag) == 0) return "Primero hay que fabricar y pagar ese prefab"; appearance = appearance & ~(7 << (index * 3)) | style << (index * 3); setDirty(); applyAttributes(body); bot.server.overworld().getDataStorage().save(); return "Aspecto montado: " + prefab + " en " + slot; } static int appearanceFor(String requested, Map paid) { if (requested.equals("effect:minecraft:fire_resistance") || requested.equals("visual:obsidian")) return 1; if (requested.equals("visual:diamond")) return 4; if (requested.equals("visual:copper")) return 8; if (requested.equals("visual:iron")) return 2; if (requested.equals("attribute:minecraft:generic.armor") || requested.equals("attribute:minecraft:generic.armor_toughness")) return paid.getOrDefault("minecraft:diamond", 0) > 0 ? 4 : 2; return 0; } }