Chat-Einstellungen, Tabliste fürs Netzwerk, Fixes aus dem Review

This commit is contained in:
MineTec committed 2026-10-17 22:57:42 +02:00
1 parent 0a07f0bf7c
commit 735dc8867d
13 files changed
+390 -71

No files matched your search

+9 -2
View File
@@ -1,6 +1,6 @@
# chat-bridge # 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 Velocity verteilt jede Zeile als Systemnachricht an alle Spieler; die Backends verwerfen den Chat
selbst („Silencer“), sonst stünde er dort doppelt. 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 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. dürfen weder öffentlich noch per /msg schreiben, außer an Admins.
- **Join/Leave** nur beim Ein- und Ausloggen ins Netzwerk, nicht beim Serverwechsel. - **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]`). - **Log:** Chat, /msg, Adminchat und Mutes stehen im Velocity-Log (`[chat]`, `[msg]`, `[adminchat]`, `[mute]`).
Silencer auf den Backends: Silencer auf den Backends:
@@ -25,7 +32,7 @@ Silencer auf den Backends:
|---|---| |---|---|
| craftattack, arena | spawn, Appliance `NetworkChat` (verwirft Chat und `/me`, `/teammsg`, `minecraft:msg`) | | craftattack, arena | spawn, Appliance `NetworkChat` (verwirft Chat und `/me`, `/teammsg`, `minecraft:msg`) |
| limbo | EndlessJumper | | 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 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. Server nicht noch einmal, dort greift der Mute aber nur für die anderen Server.
+1 -1
View File
@@ -3,7 +3,7 @@ plugins {
} }
group = 'eu.mhsl.craftattack' group = 'eu.mhsl.craftattack'
version = '1.0.0' version = '1.1.0'
repositories { repositories {
mavenCentral() mavenCentral()
@@ -39,13 +39,15 @@ final class Chat {
private final Config config; private final Config config;
private final Names names; private final Names names;
private final Mutes mutes; 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.proxy = proxy;
this.logger = logger; this.logger = logger;
this.config = config; this.config = config;
this.names = names; this.names = names;
this.mutes = mutes; this.mutes = mutes;
this.settings = settings;
} }
void handle(Player sender, String message) { void handle(Player sender, String message) {
@@ -56,34 +58,40 @@ final class Chat {
} }
Set<Player> mentioned = new LinkedHashSet<>(); Set<Player> mentioned = new LinkedHashSet<>();
Component line = Component.text() Component highlighted = this.line(server, sender, this.body(message, mentioned));
.append(this.tag(server)) Component plain = this.line(server, sender, Component.text(message));
.append(this.names.clickable(sender))
.append(Component.text(" > ", GRAY))
.append(this.body(message, mentioned).colorIfAbsent(SILVER))
.build();
for (Player player : this.proxy.getAllPlayers()) { for (Player player : this.proxy.getAllPlayers()) {
if (server != null && this.config.unsilencedServers().contains(server) && server.equals(serverOf(player))) continue; 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); this.logger.info("[chat] [{}] {}: {}", server, sender.getUsername(), message);
} }
void announceJoin(Player player) { 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) { 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) { private void announce(Component line) {
this.proxy.getAllPlayers().forEach(player -> player.sendMessage(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. */ /** Meldet einem gestummten Spieler die Restzeit; true, wenn er gestummt ist. */
@@ -13,19 +13,23 @@ import net.luckperms.api.LuckPermsProvider;
import org.slf4j.Logger; import org.slf4j.Logger;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
@Plugin( @Plugin(
id = "chat-bridge", id = "chat-bridge",
name = "chat-bridge", name = "chat-bridge",
version = "1.0.0", version = "1.1.0",
description = "Serverübergreifender Chat: öffentlicher Chat, /msg, Adminchat, Mute und Join/Leave", description = "Serverübergreifender Chat und Tabliste: öffentlicher Chat, /msg, Adminchat, Mute, Join/Leave",
authors = {"MHSL"}, authors = {"MHSL"},
dependencies = {@Dependency(id = "luckperms")} dependencies = {@Dependency(id = "luckperms")}
) )
public final class ChatBridge { 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"); 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 ProxyServer proxy;
private final Logger logger; private final Logger logger;
private final Path dataDirectory; private final Path dataDirectory;
@@ -47,13 +51,24 @@ public final class ChatBridge {
return; 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); Names names = new Names(config);
Mutes mutes = new Mutes(LuckPermsProvider.get()); 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); 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.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); 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()); this.logger.info("chat-bridge aktiv, Kürzel {}, ohne Silencer {}", config.serverTags(), config.unsilencedServers());
@@ -1,6 +1,8 @@
package eu.mhsl.craftattack.chatbridge; package eu.mhsl.craftattack.chatbridge;
import com.google.common.io.ByteArrayDataInput; 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.Subscribe;
import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.connection.PluginMessageEvent; 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 net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import org.slf4j.Logger; import org.slf4j.Logger;
import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
final class ChatListener { final class ChatListener {
@@ -27,16 +29,23 @@ final class ChatListener {
private final Names names; private final Names names;
private final Chat chat; private final Chat chat;
private final PrivateMessages privateMessages; 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. */ /** Wer eine Join-Meldung bekommen hat, bekommt beim Verlassen auch eine. */
private final Set<UUID> announced = ConcurrentHashMap.newKeySet(); private final Set<UUID> 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.plugin = plugin;
this.proxy = proxy; this.proxy = proxy;
this.logger = logger; this.logger = logger;
this.names = names; this.names = names;
this.chat = chat; this.chat = chat;
this.privateMessages = privateMessages; this.privateMessages = privateMessages;
this.settings = settings;
this.config = config;
this.tabLists = tabLists;
} }
/** /**
@@ -57,13 +66,30 @@ final class ChatListener {
try { try {
ByteArrayDataInput in = event.dataAsDataStream(); ByteArrayDataInput in = event.dataAsDataStream();
String type = in.readUTF(); UUID player = connection.getPlayer().getUniqueId();
if (type.equals("prefix")) { String server = connection.getServerInfo().getName();
String json = in.readUTF(); switch (in.readUTF()) {
this.names.setPrefix( case "prefix" -> {
connection.getPlayer().getUniqueId(), String json = in.readUTF();
connection.getServerInfo().getName(), this.names.setPrefix(player, server, json.isEmpty() ? null : GsonComponentSerializer.gson().deserialize(json));
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) { } catch (Exception e) {
this.logger.warn("Ungültige Nachricht von {} für {}", connection.getServerInfo().getName(), 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 @Subscribe
public void onServerPostConnect(ServerPostConnectEvent event) { public void onServerPostConnect(ServerPostConnectEvent event) {
if (event.getPreviousServer() != null) return;
Player player = event.getPlayer(); Player player = event.getPlayer();
this.tabLists.reset(player.getUniqueId());
this.refreshTabListsSoon();
if (event.getPreviousServer() != null) return;
this.proxy.getScheduler().buildTask(this.plugin, () -> { 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); this.chat.announceJoin(player);
}).delay(JOIN_DELAY_MS, TimeUnit.MILLISECONDS).schedule(); }).delay(JOIN_DELAY_MS, TimeUnit.MILLISECONDS).schedule();
} }
@@ -84,8 +127,17 @@ final class ChatListener {
@Subscribe @Subscribe
public void onDisconnect(DisconnectEvent event) { public void onDisconnect(DisconnectEvent event) {
Player player = event.getPlayer(); 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.names.forget(player.getUniqueId());
this.privateMessages.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();
} }
} }
@@ -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<UUID, Values> 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);
}
}
}
@@ -15,6 +15,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; 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. */ /** Befehle auf dem Proxy; sie greifen vor dem Backend und funktionieren damit auf jedem Server. */
final class Commands { final class Commands {
@@ -93,7 +94,7 @@ final class Commands {
.thenCompose(uuid -> mutes.mute(uuid, Duration.ofHours(hours))) .thenCompose(uuid -> mutes.mute(uuid, Duration.ofHours(hours)))
.whenComplete((ignored, error) -> { .whenComplete((ignored, error) -> {
if (error != null) { if (error != null) {
invocation.source().sendMessage(Component.text("Der Spieler " + args[0] + " ist unbekannt.", NamedTextColor.RED)); reportError(invocation, logger, args[0], error);
return; return;
} }
invocation.source().sendMessage(Component.text(args[0] + " wurde für " + hours + " Stunden gestummt!", NamedTextColor.GREEN)); 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]) resolve(proxy, mutes, args[0])
.thenCompose(mutes::unmute) .thenCompose(mutes::unmute)
.whenComplete((ignored, error) -> { .whenComplete((wasMuted, error) -> {
if (error != null) { 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; return;
} }
invocation.source().sendMessage(Component.text(args[0] + " ist nicht mehr gestummt.", NamedTextColor.GREEN)); 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). */ /** Online zuerst über den Proxy, sonst über LuckPerms (Spieler, die schon mal da waren). */
private static CompletableFuture<UUID> resolve(ProxyServer proxy, Mutes mutes, String name) { private static CompletableFuture<UUID> resolve(ProxyServer proxy, Mutes mutes, String name) {
return proxy.getPlayer(name) return proxy.getPlayer(name)
.map(player -> CompletableFuture.completedFuture(player.getUniqueId())) .map(player -> CompletableFuture.completedFuture(player.getUniqueId()))
.orElseGet(() -> mutes.lookup(name).thenApply(uuid -> { .orElseGet(() -> mutes.lookup(name).thenApply(uuid -> {
if (uuid == null) throw new IllegalArgumentException("unbekannt: " + name); if (uuid == null) throw new UnknownPlayerException(name);
return uuid; return uuid;
})); }));
} }
@@ -20,7 +20,9 @@ import java.util.stream.Collectors;
*/ */
record Config( record Config(
Map<String, String> serverTags, Map<String, String> serverTags,
Map<String, String> tabTags,
Set<String> unsilencedServers, Set<String> unsilencedServers,
String settingsServer,
String adminPermission, String adminPermission,
String markerPermission String markerPermission
) { ) {
@@ -44,7 +46,9 @@ record Config(
static Config fromProperties(Properties p) { static Config fromProperties(Properties p) {
return new Config( return new Config(
pairs(p.getProperty("server-tags", "")), pairs(p.getProperty("server-tags", "")),
pairs(p.getProperty("tab-tags", "")),
Set.copyOf(split(p.getProperty("unsilenced-servers", ""))), Set.copyOf(split(p.getProperty("unsilenced-servers", ""))),
p.getProperty("settings-server", "craftattack").trim(),
p.getProperty("admin-permission", "admin").trim(), p.getProperty("admin-permission", "admin").trim(),
p.getProperty("marker-permission", "adminmarker").trim() p.getProperty("marker-permission", "adminmarker").trim()
); );
@@ -54,7 +58,7 @@ record Config(
Map<String, String> pairs = new HashMap<>(); Map<String, String> pairs = new HashMap<>();
for (String entry : split(value)) { for (String entry : split(value)) {
int separator = entry.indexOf('='); 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()); pairs.put(entry.substring(0, separator).trim(), entry.substring(separator + 1).trim());
} }
return Map.copyOf(pairs); return Map.copyOf(pairs);
@@ -12,6 +12,7 @@ import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
@@ -46,8 +47,14 @@ final class Mutes {
}); });
} }
CompletableFuture<Void> unmute(UUID player) { /** true, wenn ein (noch laufender) Mute entfernt wurde. */
return this.modify(player, user -> user.data().clear(node -> node.getKey().equals(NODE))); CompletableFuture<Boolean> 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<UUID> lookup(String name) { CompletableFuture<UUID> lookup(String name) {
@@ -50,6 +50,8 @@ final class PrivateMessages {
void reply(Player sender, String message) { void reply(Player sender, String message) {
List<Conversation> replyList = this.replyMapping.computeIfAbsent(sender.getUniqueId(), uuid -> new ArrayList<>()); List<Conversation> replyList = this.replyMapping.computeIfAbsent(sender.getUniqueId(), uuid -> new ArrayList<>());
long now = System.currentTimeMillis(); 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) { synchronized (replyList) {
replyList.removeIf(conversation -> conversation.lastSet() < now - CONVERSATION_TIMEOUT_MINUTES * 60_000L); replyList.removeIf(conversation -> conversation.lastSet() < now - CONVERSATION_TIMEOUT_MINUTES * 60_000L);
@@ -81,36 +83,40 @@ final class PrivateMessages {
return; return;
} }
replyList.clear(); replyList.clear();
this.whisper(sender, target, message); whisperTo = target;
return; } 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<Conversation> replyList, List<Conversation> 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) { void message(Player sender, String targetName, String message) {
@@ -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.
* <p>
* 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<UUID, Set<UUID>> 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<UUID> ours = this.added.computeIfAbsent(viewer.getUniqueId(), uuid -> new HashSet<>());
Set<UUID> 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();
}
}
+8
View File
@@ -5,11 +5,19 @@
# Server ohne Eintrag (der Hauptserver) bekommen keins. # Server ohne Eintrag (der Hauptserver) bekommen keins.
server-tags=arena=Arena|limbo=Limbo|event=Event|grand-event=Event|worldmuseum=Museum 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). # 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 # 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. # noch einmal. Achtung: Dort greift der Mute nur für die anderen Server.
unsilenced-servers=event|grand-event|worldmuseum 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) # Adminchat, /mute und /unmute (LuckPerms auf Velocity)
admin-permission=admin admin-permission=admin
# Wer diese Permission hat, erscheint blau (invis_admin und technical haben sie auf false) # Wer diese Permission hat, erscheint blau (invis_admin und technical haben sie auf false)
@@ -22,6 +22,8 @@ class ConfigTest {
assertEquals("Arena", config.serverTags().get("arena")); assertEquals("Arena", config.serverTags().get("arena"));
assertEquals(Set.of("event", "grand-event", "worldmuseum"), config.unsilencedServers()); assertEquals(Set.of("event", "grand-event", "worldmuseum"), config.unsilencedServers());
assertEquals("adminmarker", config.markerPermission()); assertEquals("adminmarker", config.markerPermission());
assertEquals("CraftAttack", config.tabTags().get("craftattack"));
assertEquals("craftattack", config.settingsServer());
} }
@Test @Test