Chat-Einstellungen, Tabliste fürs Netzwerk, Fixes aus dem Review
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user