Volver al índice

src/main/java/ar/com/companeros/social/AgentCommunication.java

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<MinecraftServer, ServerBudget> 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<AgentIdentity> 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<UUID, Long> 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<UUID, Long> globalLast = new HashMap<>(), internalLast = new HashMap<>();
        final Map<UUID, Long> humanLast = new HashMap<>();
        final Map<UUID, Usage> usage = new HashMap<>();
        ServerBudget(long tick) { windowStart = tick; humanWindowStart = tick; }
    }
    public record CommunicationUsage(long globalSent, long internalDeliveries, long publishedCharacters) { }
}