Volver al índice
src/main/java/ar/com/companeros/horror/ParasiteUpgrades.java
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<Stat, Integer> levels = new EnumMap<>(Stat.class);
private final Map<String, Integer> adaptations = new TreeMap<>();
private int visualUpgrades;
private int appearance;
private final ArrayDeque<String> history = new ArrayDeque<>();
private UUID proposer, quoteId;
private String requested;
private String goal = "", explanation = "", status = "Sin propuestas";
private Map<String, Integer> costs = Map.of();
private long quotedAt, lastRequest = -24_000, sequence;
private boolean pending;
private long defeats, hitsTaken;
private final Map<String, Long> damageKinds = new TreeMap<>();
private static final Map<String, Double> 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<String> 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<String, Integer> 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<String, Integer> 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<String, Integer> 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<String, Integer> 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<String> catalog(String query) {
String match = query.toLowerCase(Locale.ROOT);
List<String> 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<String, Integer> 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<String, Integer> 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;
}
}