From 735dc8867dda197590f4aa4030f98e974dfb58db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 17 Oct 2026 22:57:42 +0200 Subject: [PATCH] =?UTF-8?q?Chat-Einstellungen,=20Tabliste=20f=C3=BCrs=20Ne?= =?UTF-8?q?tzwerk,=20Fixes=20aus=20dem=20Review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +- build.gradle | 2 +- .../eu/mhsl/craftattack/chatbridge/Chat.java | 38 ++++--- .../craftattack/chatbridge/ChatBridge.java | 25 ++++- .../craftattack/chatbridge/ChatListener.java | 78 ++++++++++--- .../craftattack/chatbridge/ChatSettings.java | 86 +++++++++++++++ .../mhsl/craftattack/chatbridge/Commands.java | 29 ++++- .../mhsl/craftattack/chatbridge/Config.java | 6 +- .../eu/mhsl/craftattack/chatbridge/Mutes.java | 11 +- .../chatbridge/PrivateMessages.java | 62 ++++++----- .../mhsl/craftattack/chatbridge/TabLists.java | 103 ++++++++++++++++++ src/main/resources/config.properties | 8 ++ .../craftattack/chatbridge/ConfigTest.java | 2 + 13 files changed, 390 insertions(+), 71 deletions(-) create mode 100644 src/main/java/eu/mhsl/craftattack/chatbridge/ChatSettings.java create mode 100644 src/main/java/eu/mhsl/craftattack/chatbridge/TabLists.java diff --git a/README.md b/README.md index 61bdde9..c032371 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # chat-bridge -Velocity-Plugin für CraftAttack: Der Chat gilt im ganzen Netzwerk (craftattack, arena, Limbo, Event). +Velocity-Plugin für CraftAttack: Chat und Tabliste gelten im ganzen Netzwerk (craftattack, arena, Limbo, Event). Velocity verteilt jede Zeile als Systemnachricht an alle Spieler; die Backends verwerfen den Chat selbst („Silencer“), sonst stünde er dort doppelt. @@ -17,6 +17,13 @@ verteilt sie selbst. LuckPerms-Permission `chatbridge.muted` in der gemeinsamen MariaDB, läuft von selbst ab. Gestummte dürfen weder öffentlich noch per /msg schreiben, außer an Admins. - **Join/Leave** nur beim Ein- und Ausloggen ins Netzwerk, nicht beim Serverwechsel. +- **Einstellungen** in `/settings` auf spawn (Join/Leave anzeigen, Erwähnungen hervorheben, Erwähnungston): + Velocity speichert sie in `plugins/chat-bridge/settings.properties` und gibt sie beim Join an spawn + zurück, damit die Arena dieselben Werte zeigt. Unbekannte Spieler übernimmt Velocity nur vom + `settings-server` (craftattack), damit die Defaults der Arena keine echten Werte verdrängen. +- **Tabliste:** Spieler anderer Server stehen unten mit Server dahinter (`tab-tags`, sonst Chat-Kürzel). + Die Einträge vom eigenen Server schickt weiter das Backend (Skins, Spielmodus, Prefixe). Abgleich + alle 2 s und kurz nach Join, Wechsel und Quit. - **Log:** Chat, /msg, Adminchat und Mutes stehen im Velocity-Log (`[chat]`, `[msg]`, `[adminchat]`, `[mute]`). Silencer auf den Backends: @@ -25,7 +32,7 @@ Silencer auf den Backends: |---|---| | craftattack, arena | spawn, Appliance `NetworkChat` (verwirft Chat und `/me`, `/teammsg`, `minecraft:msg`) | | limbo | EndlessJumper | -| event | Minigames, `velocity.chat-via-proxy: true` in `resources/config.yml` | +| event | Minigames (ab `develop` 8dc1659), `velocity.chat-via-proxy: true` in `resources/config.yml` | Server ohne Silencer stehen in `unsilenced-servers`: Deren Spieler bekommen den Chat vom eigenen Server nicht noch einmal, dort greift der Mute aber nur für die anderen Server. diff --git a/build.gradle b/build.gradle index 16555ea..ca0f762 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { } group = 'eu.mhsl.craftattack' -version = '1.0.0' +version = '1.1.0' repositories { mavenCentral() diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Chat.java b/src/main/java/eu/mhsl/craftattack/chatbridge/Chat.java index 1ecab86..c76178f 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Chat.java +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/Chat.java @@ -39,13 +39,15 @@ final class Chat { private final Config config; private final Names names; private final Mutes mutes; + private final ChatSettings settings; - Chat(ProxyServer proxy, Logger logger, Config config, Names names, Mutes mutes) { + Chat(ProxyServer proxy, Logger logger, Config config, Names names, Mutes mutes, ChatSettings settings) { this.proxy = proxy; this.logger = logger; this.config = config; this.names = names; this.mutes = mutes; + this.settings = settings; } void handle(Player sender, String message) { @@ -56,34 +58,40 @@ final class Chat { } Set mentioned = new LinkedHashSet<>(); - Component line = Component.text() - .append(this.tag(server)) - .append(this.names.clickable(sender)) - .append(Component.text(" > ", GRAY)) - .append(this.body(message, mentioned).colorIfAbsent(SILVER)) - .build(); + Component highlighted = this.line(server, sender, this.body(message, mentioned)); + Component plain = this.line(server, sender, Component.text(message)); for (Player player : this.proxy.getAllPlayers()) { if (server != null && this.config.unsilencedServers().contains(server) && server.equals(serverOf(player))) continue; - player.sendMessage(line); + ChatSettings.Values values = this.settings.of(player.getUniqueId()); + player.sendMessage(values.highlightMentions() ? highlighted : plain); + if (mentioned.contains(player) && !player.equals(sender) && values.mentionSound()) player.playSound(MENTION); } - mentioned.stream() - .filter(player -> !player.equals(sender)) - .forEach(player -> player.playSound(MENTION)); this.logger.info("[chat] [{}] {}: {}", server, sender.getUsername(), message); } void announceJoin(Player player) { - this.broadcast(Component.text(">>> ", NamedTextColor.GREEN).append(this.names.clickable(player))); + this.announce(Component.text(">>> ", NamedTextColor.GREEN).append(this.names.clickable(player))); } void announceLeave(Player player) { - this.broadcast(Component.text("<<< ", NamedTextColor.RED).append(this.names.clickable(player))); + this.announce(Component.text("<<< ", NamedTextColor.RED).append(this.names.clickable(player))); } - void broadcast(Component line) { - this.proxy.getAllPlayers().forEach(player -> player.sendMessage(line)); + private void announce(Component line) { + this.proxy.getAllPlayers().stream() + .filter(player -> this.settings.of(player.getUniqueId()).joinAndLeave()) + .forEach(player -> player.sendMessage(line)); + } + + private Component line(@Nullable String server, Player sender, Component body) { + return Component.text() + .append(this.tag(server)) + .append(this.names.clickable(sender)) + .append(Component.text(" > ", GRAY)) + .append(body.colorIfAbsent(SILVER)) + .build(); } /** Meldet einem gestummten Spieler die Restzeit; true, wenn er gestummt ist. */ diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatBridge.java b/src/main/java/eu/mhsl/craftattack/chatbridge/ChatBridge.java index 005cda8..4596338 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatBridge.java +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/ChatBridge.java @@ -13,19 +13,23 @@ import net.luckperms.api.LuckPermsProvider; import org.slf4j.Logger; import java.nio.file.Path; +import java.util.concurrent.TimeUnit; @Plugin( id = "chat-bridge", name = "chat-bridge", - version = "1.0.0", - description = "Serverübergreifender Chat: öffentlicher Chat, /msg, Adminchat, Mute und Join/Leave", + version = "1.1.0", + description = "Serverübergreifender Chat und Tabliste: öffentlicher Chat, /msg, Adminchat, Mute, Join/Leave", authors = {"MHSL"}, dependencies = {@Dependency(id = "luckperms")} ) public final class ChatBridge { - /** Backend → Proxy: Rang-Prefixe aus spawn ({@code "prefix"}, danach Adventure-JSON oder leer). */ + /** Kanal zu spawn (Appliance NetworkChat): Rang-Prefixe und Chat-Einstellungen, Format siehe dort. */ static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.create("craftattack", "chat"); + /** Abgleich der Tablisten; nach Join, Wechsel und Quit zusätzlich kurz danach. */ + static final long TAB_REFRESH_SECONDS = 2; + private final ProxyServer proxy; private final Logger logger; private final Path dataDirectory; @@ -47,13 +51,24 @@ public final class ChatBridge { return; } + ChatSettings settings; + try { + settings = new ChatSettings(this.dataDirectory, this.logger); + } catch (Exception e) { + this.logger.error("settings.properties nicht lesbar, chat-bridge ist aus", e); + return; + } + Names names = new Names(config); Mutes mutes = new Mutes(LuckPermsProvider.get()); - Chat chat = new Chat(this.proxy, this.logger, config, names, mutes); + Chat chat = new Chat(this.proxy, this.logger, config, names, mutes, settings); PrivateMessages privateMessages = new PrivateMessages(this.proxy, this.logger, config, names, chat); + TabLists tabLists = new TabLists(this.proxy, config, names); + this.proxy.getChannelRegistrar().register(CHANNEL); - this.proxy.getEventManager().register(this, new ChatListener(this, this.proxy, this.logger, names, chat, privateMessages)); + this.proxy.getEventManager().register(this, new ChatListener(this, this.proxy, this.logger, config, names, chat, privateMessages, settings, tabLists)); + this.proxy.getScheduler().buildTask(this, tabLists::refresh).repeat(TAB_REFRESH_SECONDS, TimeUnit.SECONDS).schedule(); Commands.register(this, this.proxy, this.logger, config, names, chat, privateMessages, mutes); this.logger.info("chat-bridge aktiv, Kürzel {}, ohne Silencer {}", config.serverTags(), config.unsilencedServers()); diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatListener.java b/src/main/java/eu/mhsl/craftattack/chatbridge/ChatListener.java index 13ed184..0252d08 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatListener.java +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/ChatListener.java @@ -1,6 +1,8 @@ package eu.mhsl.craftattack.chatbridge; import com.google.common.io.ByteArrayDataInput; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.connection.PluginMessageEvent; @@ -12,9 +14,9 @@ import com.velocitypowered.api.proxy.ServerConnection; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import org.slf4j.Logger; +import java.util.HashSet; import java.util.Set; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; final class ChatListener { @@ -27,16 +29,23 @@ final class ChatListener { private final Names names; private final Chat chat; private final PrivateMessages privateMessages; + private final ChatSettings settings; + private final Config config; + private final TabLists tabLists; /** Wer eine Join-Meldung bekommen hat, bekommt beim Verlassen auch eine. */ - private final Set announced = ConcurrentHashMap.newKeySet(); + private final Set announced = new HashSet<>(); - ChatListener(Object plugin, ProxyServer proxy, Logger logger, Names names, Chat chat, PrivateMessages privateMessages) { + ChatListener(Object plugin, ProxyServer proxy, Logger logger, Config config, Names names, Chat chat, + PrivateMessages privateMessages, ChatSettings settings, TabLists tabLists) { this.plugin = plugin; this.proxy = proxy; this.logger = logger; this.names = names; this.chat = chat; this.privateMessages = privateMessages; + this.settings = settings; + this.config = config; + this.tabLists = tabLists; } /** @@ -57,13 +66,30 @@ final class ChatListener { try { ByteArrayDataInput in = event.dataAsDataStream(); - String type = in.readUTF(); - if (type.equals("prefix")) { - String json = in.readUTF(); - this.names.setPrefix( - connection.getPlayer().getUniqueId(), - connection.getServerInfo().getName(), - json.isEmpty() ? null : GsonComponentSerializer.gson().deserialize(json)); + UUID player = connection.getPlayer().getUniqueId(); + String server = connection.getServerInfo().getName(); + switch (in.readUTF()) { + case "prefix" -> { + String json = in.readUTF(); + this.names.setPrefix(player, server, json.isEmpty() ? null : GsonComponentSerializer.gson().deserialize(json)); + } + case "settings-set" -> { + // Andere spawn-Server haben eigene Spielerdaten mit Defaults; die sollen echte Werte nicht verdrängen + ChatSettings.Values sent = readSettings(in); + if (this.settings.has(player) || server.equals(this.config.settingsServer())) this.settings.set(player, sent); + } + case "settings-hello" -> { + ChatSettings.Values sent = readSettings(in); + if (this.settings.has(player)) { + // Velocity kennt die Werte schon, das Backend übernimmt sie + ChatSettings.Values known = this.settings.of(player); + if (!known.equals(sent)) sendSettings(connection, known); + } else if (server.equals(this.config.settingsServer())) { + this.settings.set(player, sent); + } + } + default -> { + } } } catch (Exception e) { this.logger.warn("Ungültige Nachricht von {} für {}", connection.getServerInfo().getName(), @@ -71,12 +97,29 @@ final class ChatListener { } } + private static ChatSettings.Values readSettings(ByteArrayDataInput in) { + return new ChatSettings.Values(in.readBoolean(), in.readBoolean(), in.readBoolean()); + } + + private static void sendSettings(ServerConnection connection, ChatSettings.Values values) { + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF("settings"); + out.writeBoolean(values.joinAndLeave()); + out.writeBoolean(values.highlightMentions()); + out.writeBoolean(values.mentionSound()); + connection.sendPluginMessage(ChatBridge.CHANNEL, out.toByteArray()); + } + @Subscribe public void onServerPostConnect(ServerPostConnectEvent event) { - if (event.getPreviousServer() != null) return; Player player = event.getPlayer(); + this.tabLists.reset(player.getUniqueId()); + this.refreshTabListsSoon(); + if (event.getPreviousServer() != null) return; this.proxy.getScheduler().buildTask(this.plugin, () -> { - if (!player.isActive() || !this.announced.add(player.getUniqueId())) return; + synchronized (this.announced) { + if (!player.isActive() || !this.announced.add(player.getUniqueId())) return; + } this.chat.announceJoin(player); }).delay(JOIN_DELAY_MS, TimeUnit.MILLISECONDS).schedule(); } @@ -84,8 +127,17 @@ final class ChatListener { @Subscribe public void onDisconnect(DisconnectEvent event) { Player player = event.getPlayer(); - if (this.announced.remove(player.getUniqueId())) this.chat.announceLeave(player); + boolean wasAnnounced; + synchronized (this.announced) { + wasAnnounced = this.announced.remove(player.getUniqueId()); + } + if (wasAnnounced) this.chat.announceLeave(player); this.names.forget(player.getUniqueId()); this.privateMessages.forget(player.getUniqueId()); + this.refreshTabListsSoon(); + } + + private void refreshTabListsSoon() { + this.proxy.getScheduler().buildTask(this.plugin, this.tabLists::refresh).delay(500, TimeUnit.MILLISECONDS).schedule(); } } diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatSettings.java b/src/main/java/eu/mhsl/craftattack/chatbridge/ChatSettings.java new file mode 100644 index 0000000..ac8dfd6 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/ChatSettings.java @@ -0,0 +1,86 @@ +package eu.mhsl.craftattack.chatbridge; + +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Chat-Einstellungen der Spieler, Quelle der Wahrheit für das ganze Netzwerk. Geändert werden sie in + * {@code /settings} auf einem spawn-Server, der sie herschickt; beim Join bekommt spawn die + * gespeicherten Werte zurück. Gespeichert in {@code plugins/chat-bridge/settings.properties}. + */ +final class ChatSettings { + record Values(boolean joinAndLeave, boolean highlightMentions, boolean mentionSound) { + static final Values DEFAULT = new Values(true, true, true); + + String serialize() { + return this.joinAndLeave + "," + this.highlightMentions + "," + this.mentionSound; + } + + static Values parse(String value) { + String[] parts = value.split(","); + if (parts.length != 3) throw new IllegalArgumentException("drei Werte erwartet: " + value); + return new Values(Boolean.parseBoolean(parts[0]), Boolean.parseBoolean(parts[1]), Boolean.parseBoolean(parts[2])); + } + } + + private final Path file; + private final Logger logger; + private final Map values = new ConcurrentHashMap<>(); + + ChatSettings(Path dataDirectory, Logger logger) throws IOException { + this.file = dataDirectory.resolve("settings.properties"); + this.logger = logger; + if (Files.notExists(this.file)) return; + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(this.file, StandardCharsets.UTF_8)) { + properties.load(reader); + } + properties.forEach((key, value) -> { + try { + this.values.put(UUID.fromString((String) key), Values.parse((String) value)); + } catch (IllegalArgumentException e) { + logger.warn("settings.properties: Eintrag {} ungültig, ignoriert", key); + } + }); + } + + Values of(UUID player) { + return this.values.getOrDefault(player, Values.DEFAULT); + } + + boolean has(UUID player) { + return this.values.containsKey(player); + } + + void set(UUID player, Values values) { + if (values.equals(this.values.get(player))) return; + this.values.put(player, values); + this.save(); + } + + private synchronized void save() { + Properties properties = new Properties(); + this.values.forEach((uuid, values) -> properties.setProperty(uuid.toString(), values.serialize())); + Path temporary = this.file.resolveSibling("settings.properties.new"); + try { + Files.createDirectories(this.file.getParent()); + try (Writer writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8)) { + properties.store(writer, "chat-bridge: UUID=Join/Leave,Erwähnungen hervorheben,Erwähnungston"); + } + Files.move(temporary, this.file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + this.logger.error("Chat-Einstellungen konnten nicht gespeichert werden", e); + } + } +} diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Commands.java b/src/main/java/eu/mhsl/craftattack/chatbridge/Commands.java index 1e91084..2b15711 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Commands.java +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/Commands.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Locale; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; /** Befehle auf dem Proxy; sie greifen vor dem Backend und funktionieren damit auf jedem Server. */ final class Commands { @@ -93,7 +94,7 @@ final class Commands { .thenCompose(uuid -> mutes.mute(uuid, Duration.ofHours(hours))) .whenComplete((ignored, error) -> { if (error != null) { - invocation.source().sendMessage(Component.text("Der Spieler " + args[0] + " ist unbekannt.", NamedTextColor.RED)); + reportError(invocation, logger, args[0], error); return; } invocation.source().sendMessage(Component.text(args[0] + " wurde für " + hours + " Stunden gestummt!", NamedTextColor.GREEN)); @@ -125,9 +126,13 @@ final class Commands { } resolve(proxy, mutes, args[0]) .thenCompose(mutes::unmute) - .whenComplete((ignored, error) -> { + .whenComplete((wasMuted, error) -> { if (error != null) { - invocation.source().sendMessage(Component.text("Der Spieler " + args[0] + " ist unbekannt.", NamedTextColor.RED)); + reportError(invocation, logger, args[0], error); + return; + } + if (!wasMuted) { + invocation.source().sendMessage(Component.text(args[0] + " war nicht gestummt.", NamedTextColor.GRAY)); return; } invocation.source().sendMessage(Component.text(args[0] + " ist nicht mehr gestummt.", NamedTextColor.GREEN)); @@ -147,12 +152,28 @@ final class Commands { }); } + private static void reportError(SimpleCommand.Invocation invocation, Logger logger, String name, Throwable error) { + Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error; + if (cause instanceof UnknownPlayerException) { + invocation.source().sendMessage(Component.text("Der Spieler " + name + " ist unbekannt.", NamedTextColor.RED)); + return; + } + invocation.source().sendMessage(Component.text("Konnte nicht gespeichert werden, siehe Velocity-Log.", NamedTextColor.RED)); + logger.error("[mute] Fehler für {}", name, cause); + } + + private static final class UnknownPlayerException extends RuntimeException { + UnknownPlayerException(String name) { + super("unbekannt: " + name); + } + } + /** Online zuerst über den Proxy, sonst über LuckPerms (Spieler, die schon mal da waren). */ private static CompletableFuture resolve(ProxyServer proxy, Mutes mutes, String name) { return proxy.getPlayer(name) .map(player -> CompletableFuture.completedFuture(player.getUniqueId())) .orElseGet(() -> mutes.lookup(name).thenApply(uuid -> { - if (uuid == null) throw new IllegalArgumentException("unbekannt: " + name); + if (uuid == null) throw new UnknownPlayerException(name); return uuid; })); } diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Config.java b/src/main/java/eu/mhsl/craftattack/chatbridge/Config.java index 78edf2d..7dfe3cd 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Config.java +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/Config.java @@ -20,7 +20,9 @@ import java.util.stream.Collectors; */ record Config( Map serverTags, + Map tabTags, Set unsilencedServers, + String settingsServer, String adminPermission, String markerPermission ) { @@ -44,7 +46,9 @@ record Config( static Config fromProperties(Properties p) { return new Config( pairs(p.getProperty("server-tags", "")), + pairs(p.getProperty("tab-tags", "")), Set.copyOf(split(p.getProperty("unsilenced-servers", ""))), + p.getProperty("settings-server", "craftattack").trim(), p.getProperty("admin-permission", "admin").trim(), p.getProperty("marker-permission", "adminmarker").trim() ); @@ -54,7 +58,7 @@ record Config( Map pairs = new HashMap<>(); for (String entry : split(value)) { int separator = entry.indexOf('='); - if (separator <= 0) throw new IllegalArgumentException("server-tags: '" + entry + "' braucht Server=Kürzel"); + if (separator <= 0) throw new IllegalArgumentException("'" + entry + "' braucht Server=Kürzel"); pairs.put(entry.substring(0, separator).trim(), entry.substring(separator + 1).trim()); } return Map.copyOf(pairs); diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Mutes.java b/src/main/java/eu/mhsl/craftattack/chatbridge/Mutes.java index e387610..a8436eb 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Mutes.java +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/Mutes.java @@ -12,6 +12,7 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; /** @@ -46,8 +47,14 @@ final class Mutes { }); } - CompletableFuture unmute(UUID player) { - return this.modify(player, user -> user.data().clear(node -> node.getKey().equals(NODE))); + /** true, wenn ein (noch laufender) Mute entfernt wurde. */ + CompletableFuture unmute(UUID player) { + AtomicBoolean wasMuted = new AtomicBoolean(); + return this.modify(player, user -> { + wasMuted.set(user.getNodes(NodeType.PERMISSION).stream() + .anyMatch(node -> node.getPermission().equals(NODE) && !node.hasExpired())); + user.data().clear(node -> node.getKey().equals(NODE)); + }).thenApply(ignored -> wasMuted.get()); } CompletableFuture lookup(String name) { diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/PrivateMessages.java b/src/main/java/eu/mhsl/craftattack/chatbridge/PrivateMessages.java index 673a242..3f2ca20 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/PrivateMessages.java +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/PrivateMessages.java @@ -50,6 +50,8 @@ final class PrivateMessages { void reply(Player sender, String message) { List replyList = this.replyMapping.computeIfAbsent(sender.getUniqueId(), uuid -> new ArrayList<>()); long now = System.currentTimeMillis(); + // whisper() sperrt die Liste des Empfängers; erst nach diesem Block, sonst Deadlock bei gleichzeitigem /r + Player whisperTo = null; synchronized (replyList) { replyList.removeIf(conversation -> conversation.lastSet() < now - CONVERSATION_TIMEOUT_MINUTES * 60_000L); @@ -81,36 +83,40 @@ final class PrivateMessages { return; } replyList.clear(); - this.whisper(sender, target, message); - return; + whisperTo = target; + } else { + this.offerChoice(sender, message, replyList, old); } - - TextComponent.Builder choice = Component.text() - .append(Component.newline()) - .append(PREFIX) - .append(Component.text("Das Ziel für /r hat sich bei dir in den letzten " + TARGET_CHANGE_TIMEOUT_SECONDS - + " Sekunden geändert. Wer soll deine Nachricht erhalten? ", NamedTextColor.RED)) - .append(Component.newline()) - .append(Component.newline()); - - if (!old.isEmpty()) { - Conversation youngestOld = old.stream() - .max(Comparator.comparingLong(Conversation::lastSet)) - .orElse(old.getLast()); - replyList.removeAll(old); - replyList.add(youngestOld); - } - - replyList.stream().map(Conversation::targetName).distinct().forEach(name -> choice - .append(Component.text("[") - .append(Component.text(name, NamedTextColor.GOLD)) - .append(Component.text("]")) - .clickEvent(ClickEvent.runCommand("/msg " + name + " " + message)) - .hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesem Spieler zu schreiben.", NamedTextColor.GOLD)))) - .append(Component.text(" "))); - choice.append(Component.newline()); - sender.sendMessage(choice.build()); } + if (whisperTo != null) this.whisper(sender, whisperTo, message); + } + + private void offerChoice(Player sender, String message, List replyList, List old) { + TextComponent.Builder choice = Component.text() + .append(Component.newline()) + .append(PREFIX) + .append(Component.text("Das Ziel für /r hat sich bei dir in den letzten " + TARGET_CHANGE_TIMEOUT_SECONDS + + " Sekunden geändert. Wer soll deine Nachricht erhalten? ", NamedTextColor.RED)) + .append(Component.newline()) + .append(Component.newline()); + + if (!old.isEmpty()) { + Conversation youngestOld = old.stream() + .max(Comparator.comparingLong(Conversation::lastSet)) + .orElse(old.getLast()); + replyList.removeAll(old); + replyList.add(youngestOld); + } + + replyList.stream().map(Conversation::targetName).distinct().forEach(name -> choice + .append(Component.text("[") + .append(Component.text(name, NamedTextColor.GOLD)) + .append(Component.text("]")) + .clickEvent(ClickEvent.runCommand("/msg " + name + " " + message)) + .hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesem Spieler zu schreiben.", NamedTextColor.GOLD)))) + .append(Component.text(" "))); + choice.append(Component.newline()); + sender.sendMessage(choice.build()); } void message(Player sender, String targetName, String message) { diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/TabLists.java b/src/main/java/eu/mhsl/craftattack/chatbridge/TabLists.java new file mode 100644 index 0000000..6fcb9cc --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/chatbridge/TabLists.java @@ -0,0 +1,103 @@ +package eu.mhsl.craftattack.chatbridge; + +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.player.TabList; +import com.velocitypowered.api.proxy.player.TabListEntry; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Ergänzt die Tabliste jedes Spielers um die Spieler auf anderen Servern (grau, unten, mit Server). + * Die Einträge der Spieler auf dem eigenen Server schickt weiter das Backend (Skins, Spielmodus, + * Prefixe); die werden hier nie angefasst. + *

+ * Abgleich statt Ereignisketten: Beim Serverwechsel räumen Velocity und das alte Backend die Liste + * in unbestimmter Reihenfolge auf, {@link #refresh()} läuft deshalb regelmäßig und stellt den + * Sollzustand her. + */ +final class TabLists { + /** Vanilla sortiert höhere Werte nach oben; Backends lassen 0 stehen. */ + private static final int REMOTE_ORDER = -1000; + + private final ProxyServer proxy; + private final Config config; + private final Names names; + /** Je Betrachter die Einträge, die von hier stammen. */ + private final Map> added = new ConcurrentHashMap<>(); + + TabLists(ProxyServer proxy, Config config, Names names) { + this.proxy = proxy; + this.config = config; + this.names = names; + } + + synchronized void refresh() { + for (Player viewer : this.proxy.getAllPlayers()) { + String viewerServer = Chat.serverOf(viewer); + if (viewerServer == null) continue; + TabList tabList = viewer.getTabList(); + Set ours = this.added.computeIfAbsent(viewer.getUniqueId(), uuid -> new HashSet<>()); + + Set wanted = new HashSet<>(); + for (Player other : this.proxy.getAllPlayers()) { + String otherServer = Chat.serverOf(other); + if (other.equals(viewer) || otherServer == null || otherServer.equals(viewerServer)) continue; + wanted.add(other.getUniqueId()); + + Component displayName = this.displayName(other, otherServer); + TabListEntry entry = tabList.getEntry(other.getUniqueId()).orElse(null); + if (entry == null || !ours.contains(other.getUniqueId())) { + // Ein Rest-Eintrag vom Backend (gerade gewechselt) wird ersetzt + if (entry != null) tabList.removeEntry(other.getUniqueId()); + tabList.addEntry(TabListEntry.builder() + .tabList(tabList) + .profile(other.getGameProfile()) + .displayName(displayName) + .latency((int) Math.min(other.getPing(), Integer.MAX_VALUE)) + .gameMode(0) + .listed(true) + .listOrder(REMOTE_ORDER) + .build()); + ours.add(other.getUniqueId()); + } else { + if (!Objects.equals(entry.getDisplayNameComponent().orElse(null), displayName)) entry.setDisplayName(displayName); + int latency = (int) Math.min(other.getPing(), Integer.MAX_VALUE); + if (entry.getLatency() != latency) entry.setLatency(latency); + } + } + + ours.removeIf(uuid -> { + if (wanted.contains(uuid)) return false; + // Jetzt auf demselben Server: Der Eintrag gehört dem Backend, nicht entfernen + boolean sameServer = this.proxy.getPlayer(uuid) + .map(Chat::serverOf) + .map(viewerServer::equals) + .orElse(false); + if (!sameServer) tabList.removeEntry(uuid); + return true; + }); + } + this.added.keySet().removeIf(uuid -> this.proxy.getPlayer(uuid).isEmpty()); + } + + /** Nach einem Serverwechsel hat der Client eine frische Liste, eigene Einträge neu anlegen. */ + synchronized void reset(UUID viewer) { + this.added.remove(viewer); + } + + private Component displayName(Player player, String server) { + String tag = this.config.tabTags().getOrDefault(server, this.config.serverTags().getOrDefault(server, server)); + return Component.text() + .append(this.names.plain(player)) + .append(Component.text(" · " + tag, NamedTextColor.DARK_GRAY)) + .build(); + } +} diff --git a/src/main/resources/config.properties b/src/main/resources/config.properties index 3a3df84..94b1622 100644 --- a/src/main/resources/config.properties +++ b/src/main/resources/config.properties @@ -5,11 +5,19 @@ # Server ohne Eintrag (der Hauptserver) bekommen keins. server-tags=arena=Arena|limbo=Limbo|event=Event|grand-event=Event|worldmuseum=Museum +# Tabliste: Spieler auf anderen Servern stehen unten mit diesem Namen dahinter. Ohne Eintrag gilt das +# Chat-Kürzel, sonst der Servername. +tab-tags=craftattack=CraftAttack + # Server, die ihren Chat nicht selbst unterdrücken (kein spawn-Plugin bzw. kein Silencer). # Deren Spieler sehen den Chat vom eigenen Server schon lokal und bekommen ihn von Velocity nicht # noch einmal. Achtung: Dort greift der Mute nur für die anderen Server. unsilenced-servers=event|grand-event|worldmuseum +# Chat-Einstellungen (/settings in spawn) kennt Velocity zuerst von diesem Server; andere spawn-Server +# (Arena) haben eigene Spielerdaten und übernehmen dann die Werte von Velocity. +settings-server=craftattack + # Adminchat, /mute und /unmute (LuckPerms auf Velocity) admin-permission=admin # Wer diese Permission hat, erscheint blau (invis_admin und technical haben sie auf false) diff --git a/src/test/java/eu/mhsl/craftattack/chatbridge/ConfigTest.java b/src/test/java/eu/mhsl/craftattack/chatbridge/ConfigTest.java index c6ad149..05452eb 100644 --- a/src/test/java/eu/mhsl/craftattack/chatbridge/ConfigTest.java +++ b/src/test/java/eu/mhsl/craftattack/chatbridge/ConfigTest.java @@ -22,6 +22,8 @@ class ConfigTest { assertEquals("Arena", config.serverTags().get("arena")); assertEquals(Set.of("event", "grand-event", "worldmuseum"), config.unsilencedServers()); assertEquals("adminmarker", config.markerPermission()); + assertEquals("CraftAttack", config.tabTags().get("craftattack")); + assertEquals("craftattack", config.settingsServer()); } @Test