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

@@ -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<Player> 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. */
@@ -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());
@@ -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<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.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();
}
}
@@ -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.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<UUID> 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;
}));
}
@@ -20,7 +20,9 @@ import java.util.stream.Collectors;
*/
record Config(
Map<String, String> serverTags,
Map<String, String> tabTags,
Set<String> 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<String, String> 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);
@@ -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<Void> 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<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) {
@@ -50,6 +50,8 @@ final class PrivateMessages {
void reply(Player sender, String message) {
List<Conversation> 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<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) {
@@ -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();
}
}