package ar.com.companeros.runtime; import ar.com.companeros.decision.ObservationSnapshot; import ar.com.companeros.identity.AgentRegistry; import ar.com.companeros.memory.MemoryQuery; import ar.com.companeros.memory.MemoryRecord; import ar.com.companeros.memory.MemoryStore; import ar.com.companeros.social.AgentInbox; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import net.minecraft.core.BlockPos; import net.minecraft.core.GlobalPos; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.tags.BlockTags; import net.minecraft.tags.FluidTags; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.monster.Enemy; import net.minecraft.world.level.ClipContext; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.CropBlock; import net.minecraft.world.level.block.SweetBerryBushBlock; import net.minecraft.world.phys.HitResult; import net.minecraft.world.phys.Vec3; import net.minecraftforge.common.Tags; import net.minecraftforge.registries.ForgeRegistries; /** Captura local y con presupuesto; la consulta al modelo decide cuándo solicitar otra observación. */ public final class Perception { private static final int RADIUS = 8, BLOCK_SAMPLES = 96; private Perception() { } // Entrada única desde el hilo lógico; ninguna entidad viva pasa al proveedor asíncrono. public static ObservationSnapshot snapshot(MinecraftServer server, ServerPlayer player, String goal, List recent) { return snapshot(server, player, goal, recent, null); } public static ObservationSnapshot snapshot(MinecraftServer server, ServerPlayer player, String goal, List recent, ar.com.companeros.memory.PersistentMemoryArchive archive) { if (!server.isSameThread()) throw new IllegalStateException("La percepción se captura en el hilo servidor"); ServerLevel level = player.serverLevel(); AgentRegistry registry = AgentRegistry.get(server); GlobalPos location = GlobalPos.of(level.dimension(), player.blockPosition()); List facts = new ArrayList<>(); facts.add("role=" + ar.com.companeros.identity.AgentRoles.get(server).role(player.getUUID())); facts.add("skin_profile=" + ar.com.companeros.identity.SkinProfiles.get(server).source(player.getUUID())); // Versiones relevantes al objetivo; el catálogo de recetas es la autoridad del modpack. String lowerGoal = goal == null ? "" : goal.toLowerCase(java.util.Locale.ROOT); net.minecraftforge.fml.ModList.get().getMods().stream() .filter(mod -> lowerGoal.contains(mod.getModId()) || mod.getModId().equals("minecraft") || mod.getModId().equals("forge") || mod.getModId().equals("create")) .limit(8).forEach(mod -> facts.add("mod_installed=" + mod.getModId() + " version=" + mod.getVersion())); floorFacts(player, facts); nearbyEntities(player, registry, facts); visibleResources(player, facts); if (recent != null) recent.stream().filter(java.util.Objects::nonNull).limit(6) .forEach(value -> facts.add("recent=" + limit(value, 480))); List memories = new ArrayList<>(MemoryStore.get(server) .query(player.getUUID(), MemoryQuery.relevant(limit(goal, 512), location, level.getGameTime())) .stream().map(Perception::compactMemory).toList()); if (archive != null) try { for (MemoryRecord previous : archive.historical(player.getUUID(), registry.worldId(), limit(goal, 512))) memories.add(limit("HISTORICAL_OTHER_WORLD world=" + previous.worldId() + " type=" + previous.type() + " date=" + Instant.ofEpochMilli(previous.occurredAtUtc()) + " confidence=" + previous.confidence() + " origin=" + previous.origin() + " source=" + previous.sourceId() + " no_current_location_or_resources=true content=" + previous.content(), 2048)); } catch (java.io.IOException invalid) { com.mojang.logging.LogUtils.getLogger().error("No se pudo recuperar memoria archivada de {}", player.getGameProfile().getName(), invalid); } List messages = AgentInbox.get(server).pending(player.getUUID(), 8).stream() .map(Perception::inboxObservation).toList(); Map inventory = new HashMap<>(); for (int slot = 0; slot < 36; slot++) { var stack = player.getInventory().getItem(slot); if (!stack.isEmpty()) inventory.merge(ForgeRegistries.ITEMS.getKey(stack.getItem()).toString(), stack.getCount(), Integer::sum); } BlockPos pos = player.blockPosition(); return new ObservationSnapshot(registry.worldId(), level.dimension().location().toString(), pos.getX(), pos.getY(), pos.getZ(), player.getHealth(), player.getFoodData().getFoodLevel(), inventory, limit(goal, 512), facts.stream().limit(32).map(value -> limit(value, 512)).toList(), memories, messages); } // El buzón valida 2000 caracteres de cuerpo; el DTO reserva además su cabecera sin truncar instrucciones. public static String inboxObservation(AgentInbox.Message message) { java.util.Objects.requireNonNull(message); return "id=" + message.id() + " sender=" + message.senderId() + " kind=" + message.kind() + " createdAtUtc=" + message.createdAtUtc() + " name=" + message.senderName() + " body=" + message.body(); } // Estado propio y soporte inmediato: evita afirmar que un vacío equivale a una caída letal. private static void floorFacts(ServerPlayer player, List facts) { ServerLevel level = player.serverLevel(); BlockPos feet = player.blockPosition(); if (!level.hasChunkAt(feet) || !level.hasChunkAt(feet.below())) return; var below = level.getBlockState(feet.below()); var standing = level.getBlockState(feet); if (standing.getFluidState().is(FluidTags.LAVA) || below.getFluidState().is(FluidTags.LAVA)) facts.add("hazard=lava_at_feet"); else if (below.getCollisionShape(level, feet.below()).isEmpty()) facts.add("floor_support=false"); else facts.add("floor_support=true"); facts.add("day_time=" + level.getDayTime() % 24000 + " rain=" + level.isRaining()); } // Visión cercana: entidades cargadas, alcance esférico, campo de vista y línea sin chunks desconocidos. private static void nearbyEntities(ServerPlayer player, AgentRegistry registry, List facts) { player.serverLevel().getEntitiesOfClass(Entity.class, player.getBoundingBox().inflate(RADIUS), entity -> entity != player && entity.isAlive() && entity.distanceToSqr(player) <= RADIUS * RADIUS) .stream().sorted(Comparator.comparingDouble(player::distanceToSqr)) .filter(entity -> inView(player, entity.getEyePosition()) && loadedLine(player.serverLevel(), player.getEyePosition(), entity.getEyePosition()) && player.hasLineOfSight(entity)).limit(12).forEach(entity -> { String kind = entity instanceof ServerPlayer other ? registry.isAgent(other.getUUID()) ? "agent" : "human" : entity instanceof ar.com.companeros.horror.ParasiteEntity ? "parasite_ignores_agents" : entity instanceof Enemy ? "hostile" : "entity"; BlockPos pos = entity.blockPosition(); facts.add(kind + "=" + ForgeRegistries.ENTITY_TYPES.getKey(entity.getType()) + " id=" + entity.getUUID() + " name=" + limit(entity.getName().getString(), 64) + " pos=" + pos.getX() + "," + pos.getY() + "," + pos.getZ()); }); } // Solo recursos con evidencia de visión; máximo 96 posiciones consultadas y 12 resultados. private static void visibleResources(ServerPlayer player, List facts) { ServerLevel level = player.serverLevel(); BlockPos center = player.blockPosition(); Set examined = new HashSet<>(); int found = 0; for (int sample = 0; sample < BLOCK_SAMPLES && found < 12; sample++) { double angle = sample * 2.399963229728653; int radius = 1 + sample % RADIUS; BlockPos pos = center.offset((int) Math.round(Math.cos(angle) * radius), (sample / RADIUS) % 5 - 2, (int) Math.round(Math.sin(angle) * radius)); Vec3 point = Vec3.atCenterOf(pos); if (!examined.add(pos) || !level.hasChunkAt(pos) || point.distanceToSqr(player.position()) > RADIUS * RADIUS || !inView(player, point)) continue; var state = level.getBlockState(pos); if (!state.is(BlockTags.LOGS) && !state.is(Tags.Blocks.ORES) && !state.is(Blocks.STONE) && !(state.getBlock() instanceof CropBlock) && !(state.getBlock() instanceof SweetBerryBushBlock)) continue; if (!loadedLine(level, player.getEyePosition(), point)) continue; var hit = level.clip(new ClipContext(player.getEyePosition(), point, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, player)); if (hit.getType() != HitResult.Type.MISS && !hit.getBlockPos().equals(pos)) continue; String maturity = state.getBlock() instanceof CropBlock crop ? " mature=" + crop.isMaxAge(state) : state.getBlock() instanceof SweetBerryBushBlock ? " mature=" + (state.getValue(SweetBerryBushBlock.AGE) >= 2) : ""; facts.add("visible_block=" + ForgeRegistries.BLOCKS.getKey(state.getBlock()) + " pos=" + pos.getX() + "," + pos.getY() + "," + pos.getZ() + maturity); found++; } } private static boolean inView(ServerPlayer player, Vec3 point) { Vec3 delta = point.subtract(player.getEyePosition()); return delta.lengthSqr() < .25 || player.getLookAngle().dot(delta.normalize()) >= Math.cos(Math.toRadians(70)); } private static boolean loadedLine(ServerLevel level, Vec3 from, Vec3 to) { // Revisar el rectángulo de chunks completo evita omitir cruces muy cortos cerca de una esquina. BlockPos start = BlockPos.containing(from), end = BlockPos.containing(to); for (int x = Math.min(start.getX() >> 4, end.getX() >> 4); x <= Math.max(start.getX() >> 4, end.getX() >> 4); x++) for (int z = Math.min(start.getZ() >> 4, end.getZ() >> 4); z <= Math.max(start.getZ() >> 4, end.getZ() >> 4); z++) if (!level.hasChunkAt(new BlockPos(x << 4, 0, z << 4))) return false; return true; } private static String compactMemory(MemoryRecord memory) { String location = memory.location() == null ? "unknown" : memory.location().dimension().location() + ":" + memory.location().pos().toShortString(); return limit("id=" + memory.id() + " type=" + memory.type() + " origin=" + memory.origin() + " importance=" + memory.importance() + " confidence=" + memory.confidence() + " date=" + Instant.ofEpochMilli(memory.occurredAtUtc()) + " place=" + location + " last_verified=" + memory.lastVerifiedAtUtc() + " source=" + memory.sourceId() + " content=" + limit(memory.content(), 1200), 2048); } private static String limit(String value, int max) { if (value == null) return ""; return value.length() > max ? value.substring(0, max) : value; } }