package ar.com.companeros.social; import ar.com.companeros.body.PlayerSessions; import ar.com.companeros.identity.AgentIdentity; import ar.com.companeros.identity.AgentRegistry; import ar.com.companeros.identity.AgentState; import com.mojang.logging.LogUtils; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.WeakHashMap; import net.minecraft.network.chat.Component; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraftforge.common.ForgeHooks; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.common.util.FakePlayer; import net.minecraftforge.event.ServerChatEvent; import net.minecraftforge.eventbus.api.EventPriority; /** Comunicación con procedencia asignada por el servidor, sin interpretar comandos. */ public final class AgentCommunication { private static final int COOLDOWN_TICKS = 200; private static final int SERVER_WINDOW_TICKS = 1200; private static final int SERVER_MESSAGE_LIMIT = 80; private static final Map budgets = new WeakHashMap<>(); private static boolean installed; private AgentCommunication() { } // El chat humano puede llegar desde el decorador async; SavedData se toca en el servidor. public static synchronized void install() { if (installed) return; installed = true; MinecraftForge.EVENT_BUS.addListener(EventPriority.LOWEST, AgentCommunication::humanChat); MinecraftForge.EVENT_BUS.addListener(AgentCommunication::stopping); } private static void humanChat(ServerChatEvent event) { if (event.isCanceled()) return; ServerPlayer sender = event.getPlayer(); String text = clean(event.getRawText(), 512); if (text == null || sender instanceof FakePlayer) return; UUID id = sender.getUUID(); String name = sender.getGameProfile().getName(); sender.server.execute(() -> { if (event.isCanceled()) return; AgentRegistry registry = AgentRegistry.get(sender.server); if (registry.isAgent(id) || PlayerSessions.isAgent(sender.server, id)) return; if (!allowHumanObservation(sender.server, id)) return; AgentInbox inbox = AgentInbox.get(sender.server); for (AgentIdentity agent : registry.entries()) { if (!inbox.canAcceptOrdinary()) break; if (agent.state() != AgentState.RETIRED) inbox.deliver(agent.id(), id, name, AgentInbox.Kind.HUMAN, text); } }); } // Mensajes públicos: pasan por Forge y se publican como texto de sistema sin firmas falsas. public static boolean sendGlobal(ServerPlayer player, String message) { if (player == null) return false; MinecraftServer server = player.server; requireServerThread(server); String text = clean(message, 256); if (text == null || player instanceof FakePlayer || player.hasDisconnected() || server.getPlayerList().getPlayer(player.getUUID()) != player) return false; AgentIdentity sender = AgentRegistry.get(server).find(player.getUUID()).orElse(null); if (sender == null || sender.state() == AgentState.RETIRED || !allow(server, sender.id(), Channel.GLOBAL)) return false; Component approved = ForgeHooks.onServerChatSubmittedEvent(player, text, Component.literal(text)); if (approved == null) return false; server.getPlayerList().broadcastSystemMessage(Component.literal("<" + sender.name() + "> ").append(approved), false); usage(server, sender.id()).globalSent++; usage(server, sender.id()).publishedCharacters += text.length(); LogUtils.getLogger().info("[Agentes:GLOBAL] sender={} text={}", sender.id(), text); return true; } // La etiqueta siempre es AGENT, aunque el cuerpo del mensaje diga ADMIN o /botmalo. public static int sendInternal(MinecraftServer server, UUID senderId, String recipient, String message) { requireServerThread(server); if (senderId == null || recipient == null || recipient.isBlank() || recipient.length() > 48) return 0; String text = clean(message, 512); AgentRegistry registry = AgentRegistry.get(server); AgentIdentity sender = registry.find(senderId).orElse(null); if (text == null || sender == null || sender.state() == AgentState.RETIRED) return 0; List recipients = registry.entries().stream().filter(agent -> agent.state() != AgentState.RETIRED && !agent.id().equals(senderId) && (recipient.equalsIgnoreCase("all") || recipient.equalsIgnoreCase(agent.name()) || recipient.equalsIgnoreCase(agent.id().toString()))).toList(); if (recipients.isEmpty() || !allow(server, senderId, Channel.INTERNAL)) return 0; int delivered = 0; for (AgentIdentity agent : recipients) if (AgentInbox.get(server).deliver(agent.id(), senderId, sender.name(), AgentInbox.Kind.AGENT, text)) delivered++; Usage usage = usage(server, senderId); usage.internalDeliveries += delivered; usage.publishedCharacters += (long) text.length() * delivered; return delivered; } public static CommunicationUsage usageSnapshot(MinecraftServer server, UUID senderId) { requireServerThread(server); Usage usage = usage(server, senderId); return new CommunicationUsage(usage.globalSent, usage.internalDeliveries, usage.publishedCharacters); } // Presupuestos de tráfico compartidos: reintentos vetados también gastan su turno de envío. private static boolean allow(MinecraftServer server, UUID sender, Channel channel) { long now = server.getTickCount(); ServerBudget budget = budgets.computeIfAbsent(server, ignored -> new ServerBudget(now)); if (now - budget.windowStart >= SERVER_WINDOW_TICKS || now < budget.windowStart) { budget.windowStart = now; budget.windowMessages = 0; } Map recent = channel == Channel.GLOBAL ? budget.globalLast : budget.internalLast; Long previous = recent.get(sender); if (previous != null && now - previous < COOLDOWN_TICKS) return false; if (budget.windowMessages >= SERVER_MESSAGE_LIMIT) return false; recent.put(sender, now); budget.windowMessages++; return true; } private static Usage usage(MinecraftServer server, UUID sender) { return budgets.computeIfAbsent(server, ignored -> new ServerBudget(server.getTickCount())).usage .computeIfAbsent(sender, ignored -> new Usage()); } private static boolean allowHumanObservation(MinecraftServer server, UUID sender) { long now = server.getTickCount(); ServerBudget budget = budgets.computeIfAbsent(server, ignored -> new ServerBudget(now)); if (now - budget.humanWindowStart >= SERVER_WINDOW_TICKS || now < budget.humanWindowStart) { budget.humanWindowStart = now; budget.humanWindowMessages = 0; } Long previous = budget.humanLast.get(sender); if (previous != null && now - previous < 20 || budget.humanWindowMessages >= 40) return false; budget.humanLast.put(sender, now); budget.humanWindowMessages++; return true; } private static String clean(String message, int maximum) { if (message == null) return null; String text = message.strip(); return text.isEmpty() || text.length() > maximum || text.chars().anyMatch(Character::isISOControl) ? null : text; } private static void requireServerThread(MinecraftServer server) { if (server == null || !server.isSameThread()) throw new IllegalStateException("La comunicación se usa en el hilo del servidor"); } // El límite por identidad permanece durante reconexiones y se limpia al cerrar el servidor. private static void stopping(net.minecraftforge.event.server.ServerStoppingEvent event) { budgets.remove(event.getServer()); } private enum Channel { GLOBAL, INTERNAL } private static final class Usage { long globalSent; long internalDeliveries; long publishedCharacters; } private static final class ServerBudget { long windowStart; int windowMessages; long humanWindowStart; int humanWindowMessages; final Map globalLast = new HashMap<>(), internalLast = new HashMap<>(); final Map humanLast = new HashMap<>(); final Map usage = new HashMap<>(); ServerBudget(long tick) { windowStart = tick; humanWindowStart = tick; } } public record CommunicationUsage(long globalSent, long internalDeliveries, long publishedCharacters) { } }