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) { }
}