diff --git a/README.md b/README.md index 461d7ab..3185e11 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,46 @@ -# chat-bridge +# proxy -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-Plugin des CraftAttack-Netzwerks (craftattack, arena, Limbo, Event, Weltmuseum). Aufgebaut wie +spawn auf den Backends: Ein kleiner Kern lädt **Appliances**, jede ist ein Teil-Plugin mit eigenen +Listenern, Befehlen und eigenem Config-Abschnitt. Braucht LuckPerms auf Velocity. + +| Appliance | Was | Braucht | +|---|---|---| +| `mute` | `/mute`, `/unmute`, LuckPerms-Permission `chatbridge.muted` | – | +| `chat` | öffentlicher Chat, `/msg`, `/r`, `/adminchat`, Join/Leave, Chat-Einstellungen | `mute` | +| `tablist` | Spieler anderer Server in der Tabliste | `chat` | + +## Aufbau + +``` +src/main/java/eu/mhsl/craftattack/proxy/ + core/ Main (Velocity-Plugin), Config, Servers + core/appliance/ Appliance, ApplianceCommand, PlayerCommand + appliances// eine Appliance je Package +``` + +**Neue Appliance** (z. B. der Anmeldecheck, der zwischen Login-Server und echtem Server entscheidet): + +1. Package `appliances//` mit einer Klasse `extends Appliance`. +2. `onEnable()` für den Aufbau, `listeners()` für Objekte mit `@Subscribe`, `commands()` für Befehle + (`ApplianceCommand.of("name", command, "alias")`), `onDisable()` beim Herunterfahren. +3. In `Main.APPLIANCES` eintragen. Die Reihenfolge ist die Startreihenfolge: `queryAppliance(X.class)` + geht nur für Appliances, die davor stehen. +4. Einstellungen als `.` in `src/main/resources/config.properties`, lesen mit + `config().string(…)`, `integer`, `bool`, `list`, `pairs`. Daten in `dataDirectory()` + (`plugins/proxy//`). + +Der Name einer Appliance ist der Klassenname mit kleinem Anfangsbuchstaben (`Mute` → `mute`), änderbar +über `name()`. Schlägt `onEnable()` fehl, fällt nur diese Appliance aus und der Fehler steht im Log; +Velocity und die anderen Appliances laufen weiter. Eine Appliance, die Spieler blockieren soll, muss +deshalb selbst dafür sorgen, dass sie bei einem Fehler nicht stillschweigend alle durchlässt. + +Globale Schlüssel: `disabled-appliances`, `admin-permission`, `server-symbols`, `server-where` (letztere +über `servers()` in jeder Appliance). + +## Chat (`chat`, `mute`, `tablist`) + +Chat und Tabliste gelten im ganzen Netzwerk. Velocity verteilt jede Zeile als Systemnachricht an alle Spieler; die Backends verwerfen den Chat selbst („Silencer“), sonst stünde er dort doppelt. Warum so herum: Velocity kann eine signierte Chat-Nachricht (ab 1.19.1) weder ändern noch abbrechen, @@ -21,9 +60,9 @@ verteilt sie selbst. 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 + Velocity speichert sie in `plugins/proxy/chat/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. + `chat.settings-server` (craftattack), damit die Defaults der Arena keine echten Werte verdrängen. - **Tabliste:** Spieler anderer Server stehen unten, mit Symbol, grau und als Spectator (kursiv). Die Einträge vom eigenen Server schickt weiter das Backend (Skins, Spielmodus, Prefixe). Abgleich alle 2 s und kurz nach Join, Wechsel und Quit. @@ -37,11 +76,10 @@ Silencer auf den Backends: | limbo | EndlessJumper | | 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 `chat.unsilenced-servers`: Deren Spieler bekommen den Chat vom eigenen Server nicht noch einmal, dort greift der Mute aber nur für die anderen Server. -Einstellungen: `plugins/chat-bridge/config.properties` (wird beim ersten Start angelegt, kommentiert). -Braucht LuckPerms auf Velocity. +Einstellungen: `plugins/proxy/config.properties` (wird beim ersten Start angelegt, kommentiert). ## Bauen und Deploy @@ -49,5 +87,23 @@ Java 25, Gradle-Wrapper. ```bash ./gradlew build # inkl. Tests -./gradlew uploadPlugin # nach ~/velocity/plugins/chat-bridge.jar, danach: sudo systemctl restart velocity +./gradlew uploadPlugin # nach ~/velocity/plugins/proxy.jar, danach: sudo systemctl restart velocity ``` + +### Umstellung von chat-bridge (einmalig) + +Plugin-ID und Datenordner haben sich geändert (`chat-bridge` → `proxy`). Auf dem Proxy (10.20.9.3), bei +gestopptem Velocity: + +```bash +cd ~/velocity/plugins +mkdir -p proxy/chat +mv chat-bridge/settings.properties proxy/chat/settings.properties +mv chat-bridge.jar ~/backup/ # sonst laufen beide Plugins und jede Zeile kommt doppelt +mv chat-bridge ~/backup/chat-bridge-config +``` + +`proxy/config.properties` legt das Plugin beim ersten Start aus der Vorlage an. Eigene Änderungen aus der +alten `config.properties` übernehmen: `unsilenced-servers`, `settings-server` und `marker-permission` +heißen jetzt `chat.…`, die übrigen Schlüssel bleiben gleich. Kanal `craftattack:chat` und die Permission +`chatbridge.muted` bleiben gleich, an spawn und an laufenden Mutes ändert sich nichts. diff --git a/build.gradle b/build.gradle index e1a7565..e23f1d9 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { } group = 'eu.mhsl.craftattack' -version = '1.2.0' +version = '2.0.0' repositories { mavenCentral() @@ -47,7 +47,7 @@ tasks.register('uploadPlugin') { doLast { def file = jar.archiveFile.get().asFile def checksum = file.bytes.digest('SHA-256') - def target = '/home/minecraft/velocity/plugins/chat-bridge.jar' + def target = '/home/minecraft/velocity/plugins/proxy.jar' providers.exec { commandLine 'scp', file.absolutePath, "minecraft@10.20.9.3:${target}.new" }.result.get() diff --git a/settings.gradle b/settings.gradle index 16813ef..fde0391 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'chat-bridge' +rootProject.name = 'proxy' diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatBridge.java b/src/main/java/eu/mhsl/craftattack/chatbridge/ChatBridge.java deleted file mode 100644 index 5ab4ce9..0000000 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatBridge.java +++ /dev/null @@ -1,76 +0,0 @@ -package eu.mhsl.craftattack.chatbridge; - -import com.google.inject.Inject; -import com.velocitypowered.api.event.Subscribe; -import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; -import com.velocitypowered.api.plugin.Dependency; -import com.velocitypowered.api.plugin.Plugin; -import com.velocitypowered.api.plugin.annotation.DataDirectory; -import com.velocitypowered.api.proxy.ProxyServer; -import com.velocitypowered.api.proxy.messages.ChannelIdentifier; -import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; -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.2.0", - description = "Serverübergreifender Chat und Tabliste: öffentlicher Chat, /msg, Adminchat, Mute, Join/Leave", - authors = {"MHSL"}, - dependencies = {@Dependency(id = "luckperms")} -) -public final class ChatBridge { - /** 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; - - @Inject - public ChatBridge(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) { - this.proxy = proxy; - this.logger = logger; - this.dataDirectory = dataDirectory; - } - - @Subscribe - public void onProxyInitialize(ProxyInitializeEvent event) { - Config config; - try { - config = Config.load(this.dataDirectory); - } catch (Exception e) { - this.logger.error("Konfiguration ungültig, chat-bridge ist aus", e); - 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, 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, 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, Symbole {}, ohne Silencer {}", config.serverSymbols(), config.unsilencedServers()); - } -} diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Commands.java b/src/main/java/eu/mhsl/craftattack/chatbridge/Commands.java deleted file mode 100644 index 2b15711..0000000 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Commands.java +++ /dev/null @@ -1,204 +0,0 @@ -package eu.mhsl.craftattack.chatbridge; - -import com.velocitypowered.api.command.CommandManager; -import com.velocitypowered.api.command.SimpleCommand; -import com.velocitypowered.api.proxy.Player; -import com.velocitypowered.api.proxy.ProxyServer; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import org.slf4j.Logger; - -import java.time.Duration; -import java.util.Arrays; -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 { - private Commands() { - } - - static void register(Object plugin, ProxyServer proxy, Logger logger, Config config, Names names, Chat chat, - PrivateMessages privateMessages, Mutes mutes) { - CommandManager manager = proxy.getCommandManager(); - - // tell/w/whisper überdecken die Vanilla-Befehle, die am Mute vorbei flüstern würden - manager.register(manager.metaBuilder("msg").aliases("tell", "w", "whisper").plugin(plugin).build(), - new PlayerCommand() { - @Override - void run(Player sender, String[] args) { - if (args.length < 2) { - PrivateMessages.error(sender, "Es muss ein Spieler sowie eine Nachricht angegeben werden."); - return; - } - privateMessages.message(sender, args[0], String.join(" ", Arrays.copyOfRange(args, 1, args.length))); - } - - @Override - public List suggest(Invocation invocation) { - return invocation.arguments().length <= 1 ? playerNames(proxy, invocation.arguments()) : List.of(); - } - }); - - manager.register(manager.metaBuilder("r").plugin(plugin).build(), new PlayerCommand() { - @Override - void run(Player sender, String[] args) { - privateMessages.reply(sender, String.join(" ", args)); - } - }); - - manager.register(manager.metaBuilder("adminchat").plugin(plugin).build(), new PlayerCommand() { - @Override - void run(Player sender, String[] args) { - if (args.length == 0) { - PrivateMessages.error(sender, "Syntax: /adminchat "); - return; - } - String message = String.join(" ", args); - Component line = Component.text() - .append(Component.text("[Admin] ", NamedTextColor.LIGHT_PURPLE).clickEvent(ClickEvent.suggestCommand("/adminchat "))) - .append(names.plain(sender)) - .append(Component.text(" > ", NamedTextColor.DARK_GRAY)) - .append(Component.text(message)) - .build(); - proxy.getAllPlayers().stream() - .filter(player -> player.hasPermission(config.adminPermission())) - .forEach(player -> player.sendMessage(line)); - logger.info("[adminchat] {}: {}", sender.getUsername(), message); - } - - @Override - public boolean hasPermission(Invocation invocation) { - return invocation.source().hasPermission(config.adminPermission()); - } - }); - - manager.register(manager.metaBuilder("mute").plugin(plugin).build(), new SimpleCommand() { - @Override - public void execute(Invocation invocation) { - String[] args = invocation.arguments(); - int hours; - try { - if (args.length < 2) throw new NumberFormatException(); - hours = Integer.parseInt(args[1]); - if (hours < 1) throw new NumberFormatException(); - } catch (NumberFormatException e) { - invocation.source().sendMessage(Component.text("Syntax: /mute ", NamedTextColor.RED)); - return; - } - resolve(proxy, mutes, args[0]) - .thenCompose(uuid -> mutes.mute(uuid, Duration.ofHours(hours))) - .whenComplete((ignored, error) -> { - if (error != null) { - reportError(invocation, logger, args[0], error); - return; - } - invocation.source().sendMessage(Component.text(args[0] + " wurde für " + hours + " Stunden gestummt!", NamedTextColor.GREEN)); - logger.info("[mute] {} stummt {} für {} h", sourceName(invocation), args[0], hours); - }); - } - - @Override - public List suggest(Invocation invocation) { - String[] args = invocation.arguments(); - if (args.length <= 1) return playerNames(proxy, args); - if (args.length == 2) return List.of("1", "2", "4", "8", "24", "48"); - return List.of(); - } - - @Override - public boolean hasPermission(Invocation invocation) { - return invocation.source().hasPermission(config.adminPermission()); - } - }); - - manager.register(manager.metaBuilder("unmute").plugin(plugin).build(), new SimpleCommand() { - @Override - public void execute(Invocation invocation) { - String[] args = invocation.arguments(); - if (args.length != 1) { - invocation.source().sendMessage(Component.text("Syntax: /unmute ", NamedTextColor.RED)); - return; - } - resolve(proxy, mutes, args[0]) - .thenCompose(mutes::unmute) - .whenComplete((wasMuted, error) -> { - if (error != null) { - 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)); - logger.info("[mute] {} hebt Mute von {} auf", sourceName(invocation), args[0]); - }); - } - - @Override - public List suggest(Invocation invocation) { - return invocation.arguments().length <= 1 ? playerNames(proxy, invocation.arguments()) : List.of(); - } - - @Override - public boolean hasPermission(Invocation invocation) { - return invocation.source().hasPermission(config.adminPermission()); - } - }); - } - - 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 UnknownPlayerException(name); - return uuid; - })); - } - - private static List playerNames(ProxyServer proxy, String[] args) { - String start = args.length == 0 ? "" : args[0].toLowerCase(Locale.ROOT); - return proxy.getAllPlayers().stream() - .map(Player::getUsername) - .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(start)) - .sorted(String.CASE_INSENSITIVE_ORDER) - .toList(); - } - - private static String sourceName(SimpleCommand.Invocation invocation) { - return invocation.source() instanceof Player player ? player.getUsername() : "Konsole"; - } - - /** Befehl nur für Spieler, nicht für die Konsole. */ - private abstract static class PlayerCommand implements SimpleCommand { - abstract void run(Player sender, String[] args); - - @Override - public void execute(Invocation invocation) { - if (invocation.source() instanceof Player player) run(player, invocation.arguments()); - else invocation.source().sendMessage(Component.text("Nur für Spieler.")); - } - } -} diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Config.java b/src/main/java/eu/mhsl/craftattack/chatbridge/Config.java deleted file mode 100644 index a7ef19d..0000000 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Config.java +++ /dev/null @@ -1,73 +0,0 @@ -package eu.mhsl.craftattack.chatbridge; - -import java.io.IOException; -import java.io.InputStream; -import java.io.Reader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Properties; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Einstellungen aus {@code plugins/chat-bridge/config.properties}. Fehlt die Datei, wird die - * kommentierte Vorlage aus dem JAR geschrieben. - */ -record Config( - Map serverSymbols, - Map serverWhere, - Set unsilencedServers, - String settingsServer, - String adminPermission, - String markerPermission -) { - static Config load(Path dataDirectory) throws IOException { - Path file = dataDirectory.resolve("config.properties"); - if (Files.notExists(file)) { - Files.createDirectories(dataDirectory); - try (InputStream template = Config.class.getResourceAsStream("/config.properties")) { - if (template == null) throw new IOException("config.properties fehlt im JAR"); - Files.copy(template, file); - } - } - - Properties properties = new Properties(); - try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { - properties.load(reader); - } - return fromProperties(properties); - } - - static Config fromProperties(Properties p) { - return new Config( - pairs(p.getProperty("server-symbols", "")), - pairs(p.getProperty("server-where", "")), - 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() - ); - } - - private static Map pairs(String value) { - Map pairs = new HashMap<>(); - for (String entry : split(value)) { - int separator = entry.indexOf('='); - 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); - } - - private static List split(String value) { - return Arrays.stream(value.split("\\|")) - .map(String::trim) - .filter(entry -> !entry.isEmpty()) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Mutes.java b/src/main/java/eu/mhsl/craftattack/chatbridge/Mutes.java deleted file mode 100644 index a8436eb..0000000 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Mutes.java +++ /dev/null @@ -1,84 +0,0 @@ -package eu.mhsl.craftattack.chatbridge; - -import net.luckperms.api.LuckPerms; -import net.luckperms.api.model.user.User; -import net.luckperms.api.node.Node; -import net.luckperms.api.node.NodeType; -import net.luckperms.api.node.types.PermissionNode; - -import java.time.Duration; -import java.time.Instant; -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; - -/** - * Mute als zeitlich begrenzte LuckPerms-Permission {@value #NODE}. Liegt damit in der gemeinsamen - * MariaDB, gilt auf allen Servern und läuft von selbst ab. - */ -final class Mutes { - static final String NODE = "chatbridge.muted"; - - private final LuckPerms luckPerms; - - Mutes(LuckPerms luckPerms) { - this.luckPerms = luckPerms; - } - - /** Ende des Mutes eines online Spielers, leer wenn nicht gestummt. */ - Optional mutedUntil(UUID player) { - User user = this.luckPerms.getUserManager().getUser(player); - if (user == null) return Optional.empty(); - return user.getNodes(NodeType.PERMISSION).stream() - .filter(node -> node.getPermission().equals(NODE) && node.getValue()) - .filter(node -> node.hasExpiry() && !node.hasExpired()) - .map(Node::getExpiry) - .filter(Objects::nonNull) - .max(Instant::compareTo); - } - - CompletableFuture mute(UUID player, Duration duration) { - return this.modify(player, user -> { - user.data().clear(node -> node.getKey().equals(NODE)); - user.data().add(PermissionNode.builder(NODE).expiry(duration).build()); - }); - } - - /** 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) { - return this.luckPerms.getUserManager().lookupUniqueId(name); - } - - private CompletableFuture modify(UUID player, Consumer action) { - User online = this.luckPerms.getUserManager().getUser(player); - if (online == null) return this.luckPerms.getUserManager().modifyUser(player, action); - action.accept(online); - return this.luckPerms.getUserManager().saveUser(online); - } - - /** „2 Stunden 5 Minuten“, unter einer Minute „weniger als eine Minute“. */ - static String format(Duration duration) { - long hours = duration.toHours(); - long minutes = duration.toMinutesPart(); - if (hours == 0 && minutes == 0) return "weniger als eine Minute"; - StringBuilder text = new StringBuilder(); - if (hours > 0) text.append(hours).append(hours == 1 ? " Stunde" : " Stunden"); - if (minutes > 0) { - if (!text.isEmpty()) text.append(' '); - text.append(minutes).append(minutes == 1 ? " Minute" : " Minuten"); - } - return text.toString(); - } -} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/Chat.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/Chat.java new file mode 100644 index 0000000..5c3039a --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/Chat.java @@ -0,0 +1,62 @@ +package eu.mhsl.craftattack.proxy.appliances.chat; + +import com.velocitypowered.api.proxy.messages.ChannelIdentifier; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import eu.mhsl.craftattack.proxy.appliances.mute.Mute; +import eu.mhsl.craftattack.proxy.core.appliance.Appliance; +import eu.mhsl.craftattack.proxy.core.appliance.ApplianceCommand; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Chat im ganzen Netzwerk: öffentlicher Chat, /msg, /r, /adminchat, Join/Leave und die + * Chat-Einstellungen. Velocity verteilt jede Zeile als Systemnachricht; die Backends verwerfen den + * Chat selbst („Silencer“), sonst stünde er dort doppelt. Braucht die Appliance {@link Mute}. + */ +public final class Chat extends Appliance { + /** Kanal zu spawn (Appliance NetworkChat): Rang-Prefixe und Chat-Einstellungen, Format siehe dort. */ + static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.create("craftattack", "chat"); + + private ChatConfig config; + private ChatSettings settings; + private Names names; + private PublicChat publicChat; + private PrivateMessages privateMessages; + + @Override + protected void onEnable() throws Exception { + this.config = ChatConfig.from(this.config()); + this.settings = new ChatSettings(this.dataDirectory(), this.logger()); + Mute mute = this.queryAppliance(Mute.class); + this.names = new Names(this.servers(), this.config); + this.publicChat = new PublicChat(this.proxy(), this.logger(), this.config, this.names, mute, this.settings); + this.privateMessages = new PrivateMessages(this.proxy(), this.logger(), this.plugin().adminPermission(), this.names, mute); + this.proxy().getChannelRegistrar().register(CHANNEL); + this.logger().info("chat: ohne Silencer {}", this.config.unsilencedServers()); + } + + @Override + protected void onDisable() { + this.proxy().getChannelRegistrar().unregister(CHANNEL); + } + + @Override + protected List listeners() { + return List.of(new ChatListener(this, this.logger(), this.config, this.names, this.publicChat, this.privateMessages, this.settings)); + } + + @Override + protected List commands() { + return ChatCommands.create(this.proxy(), this.logger(), this.plugin().adminPermission(), this.names, this.privateMessages); + } + + /** Namen samt Rang-Prefix und Server-Symbol, auch für andere Appliances (Tabliste). */ + public Names names() { + return this.names; + } + + void later(Runnable runnable, long delayMillis) { + this.task(runnable).delay(delayMillis, TimeUnit.MILLISECONDS).schedule(); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatCommands.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatCommands.java new file mode 100644 index 0000000..7680102 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatCommands.java @@ -0,0 +1,74 @@ +package eu.mhsl.craftattack.proxy.appliances.chat; + +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import eu.mhsl.craftattack.proxy.core.appliance.ApplianceCommand; +import eu.mhsl.craftattack.proxy.core.appliance.PlayerCommand; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import org.slf4j.Logger; + +import java.util.Arrays; +import java.util.List; + +/** /msg, /r und /adminchat; sie greifen auf dem Proxy vor dem Backend und funktionieren damit auf jedem Server. */ +final class ChatCommands { + private ChatCommands() { + } + + static List create(ProxyServer proxy, Logger logger, String adminPermission, Names names, + PrivateMessages privateMessages) { + // tell/w/whisper überdecken die Vanilla-Befehle, die am Mute vorbei flüstern würden + ApplianceCommand msg = ApplianceCommand.of("msg", new PlayerCommand() { + @Override + protected void run(Player sender, String[] args) { + if (args.length < 2) { + PrivateMessages.error(sender, "Es muss ein Spieler sowie eine Nachricht angegeben werden."); + return; + } + privateMessages.message(sender, args[0], String.join(" ", Arrays.copyOfRange(args, 1, args.length))); + } + + @Override + public List suggest(Invocation invocation) { + return invocation.arguments().length <= 1 ? playerNames(proxy, invocation.arguments()) : List.of(); + } + }, "tell", "w", "whisper"); + + ApplianceCommand reply = ApplianceCommand.of("r", new PlayerCommand() { + @Override + protected void run(Player sender, String[] args) { + privateMessages.reply(sender, String.join(" ", args)); + } + }); + + ApplianceCommand adminChat = ApplianceCommand.of("adminchat", new PlayerCommand() { + @Override + protected void run(Player sender, String[] args) { + if (args.length == 0) { + PrivateMessages.error(sender, "Syntax: /adminchat "); + return; + } + String message = String.join(" ", args); + Component line = Component.text() + .append(Component.text("[Admin] ", NamedTextColor.LIGHT_PURPLE).clickEvent(ClickEvent.suggestCommand("/adminchat "))) + .append(names.plain(sender)) + .append(Component.text(" > ", NamedTextColor.DARK_GRAY)) + .append(Component.text(message)) + .build(); + proxy.getAllPlayers().stream() + .filter(player -> player.hasPermission(adminPermission)) + .forEach(player -> player.sendMessage(line)); + logger.info("[adminchat] {}: {}", sender.getUsername(), message); + } + + @Override + public boolean hasPermission(Invocation invocation) { + return invocation.source().hasPermission(adminPermission); + } + }); + + return List.of(msg, reply, adminChat); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatConfig.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatConfig.java new file mode 100644 index 0000000..2a2723d --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatConfig.java @@ -0,0 +1,16 @@ +package eu.mhsl.craftattack.proxy.appliances.chat; + +import eu.mhsl.craftattack.proxy.core.Config; + +import java.util.Set; + +/** Abschnitt {@code chat.*} der config.properties. */ +record ChatConfig(Set unsilencedServers, String settingsServer, String markerPermission) { + static ChatConfig from(Config section) { + return new ChatConfig( + Set.copyOf(section.list("unsilenced-servers")), + section.string("settings-server", "craftattack"), + section.string("marker-permission", "adminmarker") + ); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatListener.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatListener.java similarity index 79% rename from src/main/java/eu/mhsl/craftattack/chatbridge/ChatListener.java rename to src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatListener.java index 0252d08..c36c7dc 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatListener.java +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatListener.java @@ -1,4 +1,4 @@ -package eu.mhsl.craftattack.chatbridge; +package eu.mhsl.craftattack.proxy.appliances.chat; import com.google.common.io.ByteArrayDataInput; import com.google.common.io.ByteArrayDataOutput; @@ -9,7 +9,6 @@ import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.api.event.player.PlayerChatEvent; import com.velocitypowered.api.event.player.ServerPostConnectEvent; import com.velocitypowered.api.proxy.Player; -import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.ServerConnection; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import org.slf4j.Logger; @@ -17,35 +16,30 @@ import org.slf4j.Logger; import java.util.HashSet; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeUnit; final class ChatListener { /** Join-Meldung erst nach dieser Zeit, damit das Backend die Rang-Prefixe schon geschickt hat. */ private static final long JOIN_DELAY_MS = 1000; - private final Object plugin; - private final ProxyServer proxy; - private final Logger logger; - private final Names names; private final Chat chat; + private final Logger logger; + private final ChatConfig config; + private final Names names; + private final PublicChat publicChat; 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 = new HashSet<>(); - 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; + ChatListener(Chat chat, Logger logger, ChatConfig config, Names names, PublicChat publicChat, PrivateMessages privateMessages, + ChatSettings settings) { this.chat = chat; + this.logger = logger; + this.config = config; + this.names = names; + this.publicChat = publicChat; this.privateMessages = privateMessages; this.settings = settings; - this.config = config; - this.tabLists = tabLists; } /** @@ -54,12 +48,12 @@ final class ChatListener { */ @Subscribe public void onChat(PlayerChatEvent event) { - this.chat.handle(event.getPlayer(), event.getMessage()); + this.publicChat.handle(event.getPlayer(), event.getMessage()); } @Subscribe public void onPluginMessage(PluginMessageEvent event) { - if (!event.getIdentifier().equals(ChatBridge.CHANNEL)) return; + if (!event.getIdentifier().equals(Chat.CHANNEL)) return; event.setResult(PluginMessageEvent.ForwardResult.handled()); // Nur Backends dürfen Prefixe setzen, ein Client könnte sich sonst einen eigenen geben if (!(event.getSource() instanceof ServerConnection connection)) return; @@ -107,21 +101,19 @@ final class ChatListener { out.writeBoolean(values.joinAndLeave()); out.writeBoolean(values.highlightMentions()); out.writeBoolean(values.mentionSound()); - connection.sendPluginMessage(ChatBridge.CHANNEL, out.toByteArray()); + connection.sendPluginMessage(Chat.CHANNEL, out.toByteArray()); } @Subscribe public void onServerPostConnect(ServerPostConnectEvent event) { - Player player = event.getPlayer(); - this.tabLists.reset(player.getUniqueId()); - this.refreshTabListsSoon(); if (event.getPreviousServer() != null) return; - this.proxy.getScheduler().buildTask(this.plugin, () -> { + Player player = event.getPlayer(); + this.chat.later(() -> { synchronized (this.announced) { if (!player.isActive() || !this.announced.add(player.getUniqueId())) return; } - this.chat.announceJoin(player); - }).delay(JOIN_DELAY_MS, TimeUnit.MILLISECONDS).schedule(); + this.publicChat.announceJoin(player); + }, JOIN_DELAY_MS); } @Subscribe @@ -131,13 +123,8 @@ final class ChatListener { synchronized (this.announced) { wasAnnounced = this.announced.remove(player.getUniqueId()); } - if (wasAnnounced) this.chat.announceLeave(player); + if (wasAnnounced) this.publicChat.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/proxy/appliances/chat/ChatSettings.java similarity index 92% rename from src/main/java/eu/mhsl/craftattack/chatbridge/ChatSettings.java rename to src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatSettings.java index ac8dfd6..0f8e4cf 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/ChatSettings.java +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/ChatSettings.java @@ -1,4 +1,4 @@ -package eu.mhsl.craftattack.chatbridge; +package eu.mhsl.craftattack.proxy.appliances.chat; import org.slf4j.Logger; @@ -17,7 +17,7 @@ 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}. + * gespeicherten Werte zurück. Gespeichert in {@code plugins/proxy/chat/settings.properties}. */ final class ChatSettings { record Values(boolean joinAndLeave, boolean highlightMentions, boolean mentionSound) { @@ -76,7 +76,7 @@ final class ChatSettings { 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"); + properties.store(writer, "chat: UUID=Join/Leave,Erwähnungen hervorheben,Erwähnungston"); } Files.move(temporary, this.file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (IOException e) { diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Names.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/Names.java similarity index 80% rename from src/main/java/eu/mhsl/craftattack/chatbridge/Names.java rename to src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/Names.java index 19baeb9..0998128 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Names.java +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/Names.java @@ -1,7 +1,7 @@ -package eu.mhsl.craftattack.chatbridge; +package eu.mhsl.craftattack.proxy.appliances.chat; import com.velocitypowered.api.proxy.Player; -import com.velocitypowered.api.proxy.ServerConnection; +import eu.mhsl.craftattack.proxy.core.Servers; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.event.ClickEvent; @@ -17,19 +17,21 @@ import java.util.concurrent.ConcurrentHashMap; /** * Baut den Namen eines Spielers für den Chat: Rang-Prefixe vom Backend, 🎮 für Bedrock und den - * Namen, blau für Admins. Die Prefixe schickt das Backend (spawn) über {@link ChatBridge#CHANNEL}; + * Namen, blau für Admins. Die Prefixe schickt das Backend (spawn) über {@link Chat#CHANNEL}; * sie gelten nur, solange der Spieler auf genau diesem Server ist. */ -final class Names { +public final class Names { private static final TextColor ADMIN = TextColor.color(0x00FFFF); private record Prefix(String server, Component component) { } - private final Config config; + private final Servers servers; + private final ChatConfig config; private final Map prefixes = new ConcurrentHashMap<>(); - Names(Config config) { + Names(Servers servers, ChatConfig config) { + this.servers = servers; this.config = config; } @@ -43,16 +45,16 @@ final class Names { } /** Name ohne Klick, z. B. für die eigene Seite einer Privatnachricht. */ - Component plain(Player player) { + public Component plain(Player player) { return this.plain(player, NamedTextColor.WHITE); } /** Name ohne Klick; {@code color} gilt für Nicht-Admins. */ - Component plain(Player player, TextColor color) { + public Component plain(Player player, TextColor color) { TextComponent.Builder name = Component.text(); Prefix prefix = this.prefixes.get(player.getUniqueId()); - String server = player.getCurrentServer().map(ServerConnection::getServerInfo).map(info -> info.getName()).orElse(null); + String server = Servers.of(player); if (prefix != null && Objects.equals(prefix.server(), server)) { name.append(prefix.component()).append(Component.space()); } @@ -70,7 +72,7 @@ final class Names { } /** Name mit Hover und Klick, der {@code /msg } vorschlägt. */ - Component clickable(Player player) { + public Component clickable(Player player) { return this.plain(player) .hoverEvent(HoverEvent.showText(Component.text("Klicke, um " + player.getUsername() + " zu schreiben", NamedTextColor.GOLD))) .clickEvent(ClickEvent.suggestCommand("/msg " + player.getUsername() + " ")); @@ -80,12 +82,12 @@ final class Names { * Symbol des Servers, auf dem {@code player} ist, samt Leerzeichen; leer, wenn der Betrachter auf * demselben Server ist oder der Server kein Symbol hat. */ - Component marker(Player player, @Nullable String viewerServer) { - String server = player.getCurrentServer().map(ServerConnection::getServerInfo).map(info -> info.getName()).orElse(null); + public Component marker(Player player, @Nullable String viewerServer) { + String server = Servers.of(player); if (server == null || server.equals(viewerServer)) return Component.empty(); - String symbol = this.config.serverSymbols().get(server); + String symbol = this.servers.symbol(server); if (symbol == null) return Component.empty(); - String where = this.config.serverWhere().getOrDefault(server, "auf " + server); + String where = this.servers.where(server); return Component.text() .append(Component.text(symbol, NamedTextColor.DARK_GRAY) .hoverEvent(HoverEvent.showText(Component.text(player.getUsername() + " ist gerade " + where, NamedTextColor.GRAY)))) diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/PrivateMessages.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/PrivateMessages.java similarity index 92% rename from src/main/java/eu/mhsl/craftattack/chatbridge/PrivateMessages.java rename to src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/PrivateMessages.java index c27b7cf..807517a 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/PrivateMessages.java +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/PrivateMessages.java @@ -1,7 +1,9 @@ -package eu.mhsl.craftattack.chatbridge; +package eu.mhsl.craftattack.proxy.appliances.chat; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; +import eu.mhsl.craftattack.proxy.appliances.mute.Mute; +import eu.mhsl.craftattack.proxy.core.Servers; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.event.ClickEvent; @@ -30,17 +32,17 @@ final class PrivateMessages { private final ProxyServer proxy; private final Logger logger; - private final Config config; + private final String adminPermission; private final Names names; - private final Chat chat; + private final Mute mute; private final Map> replyMapping = new ConcurrentHashMap<>(); - PrivateMessages(ProxyServer proxy, Logger logger, Config config, Names names, Chat chat) { + PrivateMessages(ProxyServer proxy, Logger logger, String adminPermission, Names names, Mute mute) { this.proxy = proxy; this.logger = logger; - this.config = config; + this.adminPermission = adminPermission; this.names = names; - this.chat = chat; + this.mute = mute; } void forget(UUID player) { @@ -130,7 +132,7 @@ final class PrivateMessages { private void whisper(Player sender, Player receiver, String message) { // Gestummte dürfen nur noch Admins schreiben - if (!receiver.hasPermission(this.config.adminPermission()) && this.chat.denyMuted(sender)) { + if (!receiver.hasPermission(this.adminPermission) && this.mute.denyMuted(sender)) { this.logger.info("[msg] {} -> {} (gestummt): {}", sender.getUsername(), receiver.getUsername(), message); return; } @@ -149,13 +151,13 @@ final class PrivateMessages { .append(PREFIX.clickEvent(ClickEvent.suggestCommand("/msg " + receiver.getUsername() + " "))) .append(this.names.plain(sender)) .append(Component.text(" zu ", NamedTextColor.GRAY)) - .append(this.names.marker(receiver, Chat.serverOf(sender))) + .append(this.names.marker(receiver, Servers.of(sender))) .append(this.names.clickable(receiver)) .append(Component.text(" > ", NamedTextColor.GRAY)) .append(Component.text(message))); receiver.sendMessage(Component.text() .append(PREFIX.clickEvent(ClickEvent.suggestCommand("/msg " + sender.getUsername() + " "))) - .append(this.names.marker(sender, Chat.serverOf(receiver))) + .append(this.names.marker(sender, Servers.of(receiver))) .append(this.names.clickable(sender)) .append(Component.text(" zu ", NamedTextColor.GRAY)) .append(this.names.plain(receiver)) diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/Chat.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/PublicChat.java similarity index 78% rename from src/main/java/eu/mhsl/craftattack/chatbridge/Chat.java rename to src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/PublicChat.java index 06e518b..c6be61d 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/Chat.java +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/chat/PublicChat.java @@ -1,24 +1,21 @@ -package eu.mhsl.craftattack.chatbridge; +package eu.mhsl.craftattack.proxy.appliances.chat; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; -import com.velocitypowered.api.proxy.ServerConnection; +import eu.mhsl.craftattack.proxy.appliances.mute.Mute; +import eu.mhsl.craftattack.proxy.core.Servers; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextColor; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; -import java.time.Duration; -import java.time.Instant; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.Locale; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -27,7 +24,7 @@ import java.util.regex.Pattern; * Öffentlicher Chat: Velocity schickt jede Zeile als Systemnachricht an alle Spieler im Netzwerk. * Die Backends verwerfen den Chat selbst (spawn, Limbo), sonst stünde er dort doppelt. */ -final class Chat { +final class PublicChat { private static final TextColor GRAY = TextColor.color(0x808080); private static final TextColor SILVER = TextColor.color(0xC0C0C0); /** Wort mit optionalem @ davor und Satzzeichen dahinter; Bedrock-Namen beginnen mit einem Punkt. */ @@ -36,23 +33,23 @@ final class Chat { private final ProxyServer proxy; private final Logger logger; - private final Config config; + private final ChatConfig config; private final Names names; - private final Mutes mutes; + private final Mute mute; private final ChatSettings settings; - Chat(ProxyServer proxy, Logger logger, Config config, Names names, Mutes mutes, ChatSettings settings) { + PublicChat(ProxyServer proxy, Logger logger, ChatConfig config, Names names, Mute mute, ChatSettings settings) { this.proxy = proxy; this.logger = logger; this.config = config; this.names = names; - this.mutes = mutes; + this.mute = mute; this.settings = settings; } void handle(Player sender, String message) { - String server = serverOf(sender); - if (this.denyMuted(sender)) { + String server = Servers.of(sender); + if (this.mute.denyMuted(sender)) { this.logger.info("[chat] [{}] {} (gestummt): {}", server, sender.getUsername(), message); return; } @@ -63,7 +60,7 @@ final class Chat { Component name = this.names.clickable(sender); for (Player player : this.proxy.getAllPlayers()) { - String viewerServer = serverOf(player); + String viewerServer = Servers.of(player); if (server != null && this.config.unsilencedServers().contains(server) && server.equals(viewerServer)) continue; ChatSettings.Values values = this.settings.of(player.getUniqueId()); player.sendMessage(Component.text() @@ -89,17 +86,7 @@ final class Chat { Component name = this.names.clickable(subject); this.proxy.getAllPlayers().stream() .filter(player -> this.settings.of(player.getUniqueId()).joinAndLeave()) - .forEach(player -> player.sendMessage(arrow.append(this.names.marker(subject, serverOf(player))).append(name))); - } - - /** Meldet einem gestummten Spieler die Restzeit; true, wenn er gestummt ist. */ - boolean denyMuted(Player player) { - Optional until = this.mutes.mutedUntil(player.getUniqueId()); - if (until.isEmpty()) return false; - player.sendMessage(Component.text( - "Du bist für " + Mutes.format(Duration.between(Instant.now(), until.get())) + " gestummt!", - NamedTextColor.RED)); - return true; + .forEach(player -> player.sendMessage(arrow.append(this.names.marker(subject, Servers.of(player))).append(name))); } /** Text der Nachricht; Namen von Spielern im Netzwerk werden golden als @Name hervorgehoben. */ @@ -128,8 +115,4 @@ final class Chat { boolean endOk = matcher.end() == message.length() || Character.isWhitespace(message.charAt(matcher.end())); return startOk && endOk; } - - static @Nullable String serverOf(Player player) { - return player.getCurrentServer().map(ServerConnection::getServerInfo).map(info -> info.getName()).orElse(null); - } } diff --git a/src/main/java/eu/mhsl/craftattack/proxy/appliances/mute/Mute.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/mute/Mute.java new file mode 100644 index 0000000..ed3b71c --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/mute/Mute.java @@ -0,0 +1,206 @@ +package eu.mhsl.craftattack.proxy.appliances.mute; + +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import eu.mhsl.craftattack.proxy.core.appliance.Appliance; +import eu.mhsl.craftattack.proxy.core.appliance.ApplianceCommand; +import eu.mhsl.craftattack.proxy.core.appliance.PlayerCommand; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.model.user.User; +import net.luckperms.api.node.Node; +import net.luckperms.api.node.NodeType; +import net.luckperms.api.node.types.PermissionNode; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Mute als zeitlich begrenzte LuckPerms-Permission {@value #NODE}. Liegt damit in der gemeinsamen + * MariaDB, gilt auf allen Servern und läuft von selbst ab. Befehle {@code /mute} und {@code /unmute}. + */ +public final class Mute extends Appliance { + /** Name aus der Zeit als chat-bridge; nicht ändern, sonst sind laufende Mutes weg. */ + static final String NODE = "chatbridge.muted"; + + private LuckPerms luckPerms; + + @Override + protected void onEnable() { + this.luckPerms = this.plugin().luckPerms(); + } + + /** Ende des Mutes eines online Spielers, leer wenn nicht gestummt. */ + public Optional mutedUntil(UUID player) { + User user = this.luckPerms.getUserManager().getUser(player); + if (user == null) return Optional.empty(); + return user.getNodes(NodeType.PERMISSION).stream() + .filter(node -> node.getPermission().equals(NODE) && node.getValue()) + .filter(node -> node.hasExpiry() && !node.hasExpired()) + .map(Node::getExpiry) + .filter(Objects::nonNull) + .max(Instant::compareTo); + } + + /** Meldet einem gestummten Spieler die Restzeit; true, wenn er gestummt ist. */ + public boolean denyMuted(Player player) { + Optional until = this.mutedUntil(player.getUniqueId()); + if (until.isEmpty()) return false; + player.sendMessage(Component.text( + "Du bist für " + format(Duration.between(Instant.now(), until.get())) + " gestummt!", + NamedTextColor.RED)); + return true; + } + + CompletableFuture mute(UUID player, Duration duration) { + return this.modify(player, user -> { + user.data().clear(node -> node.getKey().equals(NODE)); + user.data().add(PermissionNode.builder(NODE).expiry(duration).build()); + }); + } + + /** 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()); + } + + private CompletableFuture modify(UUID player, Consumer action) { + User online = this.luckPerms.getUserManager().getUser(player); + if (online == null) return this.luckPerms.getUserManager().modifyUser(player, action); + action.accept(online); + return this.luckPerms.getUserManager().saveUser(online); + } + + /** Online zuerst über den Proxy, sonst über LuckPerms (Spieler, die schon mal da waren). */ + private CompletableFuture resolve(String name) { + return this.proxy().getPlayer(name) + .map(player -> CompletableFuture.completedFuture(player.getUniqueId())) + .orElseGet(() -> this.luckPerms.getUserManager().lookupUniqueId(name).thenApply(uuid -> { + if (uuid == null) throw new UnknownPlayerException(name); + return uuid; + })); + } + + /** „2 Stunden 5 Minuten“, unter einer Minute „weniger als eine Minute“. */ + public static String format(Duration duration) { + long hours = duration.toHours(); + long minutes = duration.toMinutesPart(); + if (hours == 0 && minutes == 0) return "weniger als eine Minute"; + StringBuilder text = new StringBuilder(); + if (hours > 0) text.append(hours).append(hours == 1 ? " Stunde" : " Stunden"); + if (minutes > 0) { + if (!text.isEmpty()) text.append(' '); + text.append(minutes).append(minutes == 1 ? " Minute" : " Minuten"); + } + return text.toString(); + } + + @Override + protected List commands() { + String admin = this.plugin().adminPermission(); + return List.of( + ApplianceCommand.of("mute", new SimpleCommand() { + @Override + public void execute(Invocation invocation) { + String[] args = invocation.arguments(); + int hours; + try { + if (args.length < 2) throw new NumberFormatException(); + hours = Integer.parseInt(args[1]); + if (hours < 1) throw new NumberFormatException(); + } catch (NumberFormatException e) { + invocation.source().sendMessage(Component.text("Syntax: /mute ", NamedTextColor.RED)); + return; + } + Mute.this.resolve(args[0]) + .thenCompose(uuid -> Mute.this.mute(uuid, Duration.ofHours(hours))) + .whenComplete((ignored, error) -> { + if (error != null) { + Mute.this.reportError(invocation, args[0], error); + return; + } + invocation.source().sendMessage(Component.text(args[0] + " wurde für " + hours + " Stunden gestummt!", NamedTextColor.GREEN)); + Mute.this.logger().info("[mute] {} stummt {} für {} h", PlayerCommand.sourceName(invocation), args[0], hours); + }); + } + + @Override + public List suggest(Invocation invocation) { + String[] args = invocation.arguments(); + if (args.length <= 1) return PlayerCommand.playerNames(Mute.this.proxy(), args); + if (args.length == 2) return List.of("1", "2", "4", "8", "24", "48"); + return List.of(); + } + + @Override + public boolean hasPermission(Invocation invocation) { + return invocation.source().hasPermission(admin); + } + }), + ApplianceCommand.of("unmute", new SimpleCommand() { + @Override + public void execute(Invocation invocation) { + String[] args = invocation.arguments(); + if (args.length != 1) { + invocation.source().sendMessage(Component.text("Syntax: /unmute ", NamedTextColor.RED)); + return; + } + Mute.this.resolve(args[0]) + .thenCompose(Mute.this::unmute) + .whenComplete((wasMuted, error) -> { + if (error != null) { + Mute.this.reportError(invocation, 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)); + Mute.this.logger().info("[mute] {} hebt Mute von {} auf", PlayerCommand.sourceName(invocation), args[0]); + }); + } + + @Override + public List suggest(Invocation invocation) { + return invocation.arguments().length <= 1 ? PlayerCommand.playerNames(Mute.this.proxy(), invocation.arguments()) : List.of(); + } + + @Override + public boolean hasPermission(Invocation invocation) { + return invocation.source().hasPermission(admin); + } + }) + ); + } + + private void reportError(SimpleCommand.Invocation invocation, 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)); + this.logger().error("[mute] Fehler für {}", name, cause); + } + + private static final class UnknownPlayerException extends RuntimeException { + UnknownPlayerException(String name) { + super("unbekannt: " + name); + } + } +} diff --git a/src/main/java/eu/mhsl/craftattack/chatbridge/TabLists.java b/src/main/java/eu/mhsl/craftattack/proxy/appliances/tablist/NetworkTabList.java similarity index 63% rename from src/main/java/eu/mhsl/craftattack/chatbridge/TabLists.java rename to src/main/java/eu/mhsl/craftattack/proxy/appliances/tablist/NetworkTabList.java index 5e69247..d557cb0 100644 --- a/src/main/java/eu/mhsl/craftattack/chatbridge/TabLists.java +++ b/src/main/java/eu/mhsl/craftattack/proxy/appliances/tablist/NetworkTabList.java @@ -1,22 +1,31 @@ -package eu.mhsl.craftattack.chatbridge; +package eu.mhsl.craftattack.proxy.appliances.tablist; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; +import com.velocitypowered.api.event.player.ServerPostConnectEvent; 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 com.velocitypowered.api.scheduler.ScheduledTask; +import eu.mhsl.craftattack.proxy.appliances.chat.Chat; +import eu.mhsl.craftattack.proxy.appliances.chat.Names; +import eu.mhsl.craftattack.proxy.core.Servers; +import eu.mhsl.craftattack.proxy.core.appliance.Appliance; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; /** * Ergänzt die Tabliste jedes Spielers um die Spieler auf anderen Servern (grau, kursiv, unten, mit - * Server-Symbol). + * Server-Symbol). Braucht die Appliance {@link Chat} für Namen und Rang-Prefixe. * Die Einträge der Spieler auf dem eigenen Server schickt weiter das Backend (Skins, Spielmodus, * Prefixe); die werden hier nie angefasst. *

@@ -24,34 +33,68 @@ import java.util.concurrent.ConcurrentHashMap; * in unbestimmter Reihenfolge auf, {@link #refresh()} läuft deshalb regelmäßig und stellt den * Sollzustand her. */ -final class TabLists { +public final class NetworkTabList extends Appliance { /** Vanilla sortiert höhere Werte nach oben; Backends lassen 0 stehen. */ private static final int REMOTE_ORDER = -1000; /** Der Client zeigt Spectator kursiv und blass; ohne Welt hat der Modus sonst keine Wirkung. */ private static final int SPECTATOR = 3; + /** Nach Join, Wechsel und Quit zusätzlich so kurz danach. */ + private static final long SOON_MS = 500; - private final ProxyServer proxy; - private final Config config; - private final Names names; + private Names names; + private ScheduledTask refreshTask; /** 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; + @Override + public String name() { + return "tablist"; + } + + @Override + protected void onEnable() { + this.names = this.queryAppliance(Chat.class).names(); + int seconds = this.config().integer("refresh-seconds", 2); + this.refreshTask = this.task(this::refresh).repeat(seconds, TimeUnit.SECONDS).schedule(); + } + + @Override + protected void onDisable() { + if (this.refreshTask != null) this.refreshTask.cancel(); + } + + @Override + protected List listeners() { + return List.of(new Listener()); + } + + final class Listener { + @Subscribe + public void onServerPostConnect(ServerPostConnectEvent event) { + NetworkTabList.this.reset(event.getPlayer().getUniqueId()); + NetworkTabList.this.refreshSoon(); + } + + @Subscribe + public void onDisconnect(DisconnectEvent event) { + NetworkTabList.this.refreshSoon(); + } + } + + private void refreshSoon() { + this.task(this::refresh).delay(SOON_MS, TimeUnit.MILLISECONDS).schedule(); } synchronized void refresh() { - for (Player viewer : this.proxy.getAllPlayers()) { - String viewerServer = Chat.serverOf(viewer); + for (Player viewer : this.proxy().getAllPlayers()) { + String viewerServer = Servers.of(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); + for (Player other : this.proxy().getAllPlayers()) { + String otherServer = Servers.of(other); if (other.equals(viewer) || otherServer == null || otherServer.equals(viewerServer)) continue; wanted.add(other.getUniqueId()); @@ -81,15 +124,15 @@ final class TabLists { 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) + boolean sameServer = this.proxy().getPlayer(uuid) + .map(Servers::of) .map(viewerServer::equals) .orElse(false); if (!sameServer) tabList.removeEntry(uuid); return true; }); } - this.added.keySet().removeIf(uuid -> this.proxy.getPlayer(uuid).isEmpty()); + this.added.keySet().removeIf(uuid -> this.proxy().getPlayer(uuid).isEmpty()); } /** Nach einem Serverwechsel hat der Client eine frische Liste, eigene Einträge neu anlegen. */ diff --git a/src/main/java/eu/mhsl/craftattack/proxy/core/Config.java b/src/main/java/eu/mhsl/craftattack/proxy/core/Config.java new file mode 100644 index 0000000..e89febf --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/core/Config.java @@ -0,0 +1,92 @@ +package eu.mhsl.craftattack.proxy.core; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/** + * Einstellungen aus {@code plugins/proxy/config.properties}. Fehlt die Datei, wird die kommentierte + * Vorlage aus dem JAR geschrieben. Jede Appliance liest ihren Abschnitt über {@link #section}: Die + * Schlüssel von {@code chat} heißen in der Datei {@code chat.}. + */ +public final class Config { + private final Properties properties; + private final String prefix; + + private Config(Properties properties, String prefix) { + this.properties = properties; + this.prefix = prefix; + } + + public static Config load(Path dataDirectory) throws IOException { + Path file = dataDirectory.resolve("config.properties"); + if (Files.notExists(file)) { + Files.createDirectories(dataDirectory); + try (InputStream template = Config.class.getResourceAsStream("/config.properties")) { + if (template == null) throw new IOException("config.properties fehlt im JAR"); + Files.copy(template, file); + } + } + + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + properties.load(reader); + } + return of(properties); + } + + public static Config of(Properties properties) { + return new Config(properties, ""); + } + + /** Abschnitt einer Appliance: Schlüssel bekommen {@code .} vorangestellt. */ + public Config section(String name) { + return new Config(this.properties, this.prefix + name + "."); + } + + public String string(String key, String fallback) { + return this.properties.getProperty(this.prefix + key, fallback).trim(); + } + + public int integer(String key, int fallback) { + String value = this.properties.getProperty(this.prefix + key); + if (value == null || value.isBlank()) return fallback; + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(this.prefix + key + ": Zahl erwartet, nicht '" + value + "'"); + } + } + + public boolean bool(String key, boolean fallback) { + String value = this.properties.getProperty(this.prefix + key); + return value == null || value.isBlank() ? fallback : Boolean.parseBoolean(value.trim()); + } + + /** Mit {@code |} getrennte Liste, leere Einträge fallen weg. */ + public List list(String key) { + return Arrays.stream(this.properties.getProperty(this.prefix + key, "").split("\\|")) + .map(String::trim) + .filter(entry -> !entry.isEmpty()) + .toList(); + } + + /** Liste aus {@code Schlüssel=Wert}-Paaren, z. B. {@code arena=◎|limbo=⌛}. */ + public Map pairs(String key) { + Map pairs = new HashMap<>(); + for (String entry : this.list(key)) { + int separator = entry.indexOf('='); + if (separator <= 0) throw new IllegalArgumentException(this.prefix + key + ": '" + entry + "' braucht Schlüssel=Wert"); + 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/proxy/core/Main.java b/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java new file mode 100644 index 0000000..741f117 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/core/Main.java @@ -0,0 +1,138 @@ +package eu.mhsl.craftattack.proxy.core; + +import com.google.inject.Inject; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.Dependency; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import eu.mhsl.craftattack.proxy.appliances.chat.Chat; +import eu.mhsl.craftattack.proxy.appliances.mute.Mute; +import eu.mhsl.craftattack.proxy.appliances.tablist.NetworkTabList; +import eu.mhsl.craftattack.proxy.core.appliance.Appliance; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; +import org.slf4j.Logger; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Velocity-Plugin des CraftAttack-Netzwerks. Wie spawn auf den Backends besteht es aus Appliances: + * jede ist ein kleines Teil-Plugin mit eigenen Listenern, Befehlen und eigenem Config-Abschnitt. + */ +@Plugin( + id = "proxy", + name = "proxy", + version = "2.0.0", + description = "CraftAttack-Netzwerk auf Velocity: Chat, Tabliste, Mute und weitere Appliances", + authors = {"MHSL"}, + dependencies = {@Dependency(id = "luckperms")} +) +public final class Main { + /** + * Alle Appliances, in Startreihenfolge: Eine Appliance kann in {@code onEnable} nur die davor + * abfragen. Neue Appliances hier eintragen. + */ + private static final List> APPLIANCES = List.of( + Mute::new, + Chat::new, + NetworkTabList::new + ); + + private final ProxyServer proxy; + private final Logger logger; + private final Path dataDirectory; + + private Config config; + private Servers servers; + private LuckPerms luckPerms; + private final List appliances = new ArrayList<>(); + + @Inject + public Main(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) { + this.proxy = proxy; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + @Subscribe + public void onProxyInitialize(ProxyInitializeEvent event) { + try { + this.config = Config.load(this.dataDirectory); + this.servers = Servers.from(this.config); + } catch (Exception e) { + this.logger.error("Konfiguration ungültig, proxy ist aus", e); + return; + } + this.luckPerms = LuckPermsProvider.get(); + + Set disabled = Set.copyOf(this.config.list("disabled-appliances")); + for (Supplier supplier : APPLIANCES) { + Appliance appliance = supplier.get(); + if (disabled.contains(appliance.name())) { + this.logger.info("Appliance {} ist abgeschaltet", appliance.name()); + continue; + } + try { + appliance.enable(this); + this.appliances.add(appliance); + } catch (Exception e) { + // Nur diese Appliance fällt aus, der Proxy und die anderen laufen weiter + this.logger.error("Appliance {} konnte nicht starten", appliance.name(), e); + } + } + this.logger.info("proxy aktiv mit {}", this.appliances.stream().map(Appliance::name).toList()); + } + + @Subscribe + public void onProxyShutdown(ProxyShutdownEvent event) { + for (Appliance appliance : this.appliances.reversed()) { + try { + appliance.disable(); + } catch (Exception e) { + this.logger.error("Appliance {} konnte nicht sauber beendet werden", appliance.name(), e); + } + } + this.appliances.clear(); + } + + /** Geladene Appliance des Typs, leer, wenn abgeschaltet oder nicht gestartet. */ + public java.util.Optional appliance(Class type) { + return this.appliances.stream().filter(type::isInstance).map(type::cast).findFirst(); + } + + public ProxyServer proxy() { + return this.proxy; + } + + public Logger logger() { + return this.logger; + } + + public Path dataDirectory() { + return this.dataDirectory; + } + + public Config config() { + return this.config; + } + + public Servers servers() { + return this.servers; + } + + public LuckPerms luckPerms() { + return this.luckPerms; + } + + /** Permission für Admin-Befehle und -Kanäle (Adminchat, /mute). */ + public String adminPermission() { + return this.config.string("admin-permission", "admin"); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/core/Servers.java b/src/main/java/eu/mhsl/craftattack/proxy/core/Servers.java new file mode 100644 index 0000000..23857f5 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/core/Servers.java @@ -0,0 +1,33 @@ +package eu.mhsl.craftattack.proxy.core; + +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.server.ServerInfo; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * Was das Netzwerk über seine Server weiß: Symbol und Ortsangabe je Server (globale Schlüssel + * {@code server-symbols} und {@code server-where}). + */ +public record Servers(Map symbols, Map where) { + static Servers from(Config config) { + return new Servers(config.pairs("server-symbols"), config.pairs("server-where")); + } + + /** Symbol des Servers, null, wenn er keins hat. */ + public @Nullable String symbol(String server) { + return this.symbols.get(server); + } + + /** „in der Arena“, ohne Eintrag „auf <server>“. */ + public String where(String server) { + return this.where.getOrDefault(server, "auf " + server); + } + + /** Name des Servers, auf dem der Spieler gerade ist, null während des Verbindens. */ + public static @Nullable String of(Player player) { + return player.getCurrentServer().map(ServerConnection::getServerInfo).map(ServerInfo::getName).orElse(null); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/Appliance.java b/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/Appliance.java new file mode 100644 index 0000000..190a8cf --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/Appliance.java @@ -0,0 +1,112 @@ +package eu.mhsl.craftattack.proxy.core.appliance; + +import com.velocitypowered.api.command.CommandManager; +import com.velocitypowered.api.command.CommandMeta; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.scheduler.Scheduler; +import eu.mhsl.craftattack.proxy.core.Config; +import eu.mhsl.craftattack.proxy.core.Main; +import eu.mhsl.craftattack.proxy.core.Servers; +import org.slf4j.Logger; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Teil-Plugin mit eigenen Listenern, Befehlen und eigenem Config-Abschnitt, wie die Appliances in + * spawn. Eintragen in {@code Main.APPLIANCES}; abschalten über {@code disabled-appliances}. + *

+ * Ablauf: {@link #onEnable()}, danach werden {@link #listeners()} und {@link #commands()} + * registriert. Beim Herunterfahren von Velocity {@link #onDisable()}. + */ +public abstract class Appliance { + private Main main; + private final List listeners = new ArrayList<>(); + private final List commands = new ArrayList<>(); + + /** Name für Config-Abschnitt, Datenordner und {@code disabled-appliances}: Klassenname klein. */ + public String name() { + String name = this.getClass().getSimpleName(); + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + + protected void onEnable() throws Exception { + } + + protected void onDisable() { + } + + /** Objekte mit {@code @Subscribe}-Methoden; werden nach {@link #onEnable()} registriert. */ + protected List listeners() { + return List.of(); + } + + /** Befehle auf dem Proxy; werden nach {@link #onEnable()} registriert. */ + protected List commands() { + return List.of(); + } + + public final void enable(Main main) throws Exception { + this.main = main; + this.onEnable(); + + for (Object listener : this.listeners()) { + this.proxy().getEventManager().register(main, listener); + this.listeners.add(listener); + } + CommandManager manager = this.proxy().getCommandManager(); + for (ApplianceCommand command : this.commands()) { + CommandMeta meta = manager.metaBuilder(command.name()) + .aliases(command.aliases().toArray(String[]::new)) + .plugin(main) + .build(); + manager.register(meta, command.command()); + this.commands.add(meta); + } + } + + public final void disable() { + this.onDisable(); + this.listeners.forEach(listener -> this.proxy().getEventManager().unregisterListener(this.main, listener)); + this.commands.forEach(this.proxy().getCommandManager()::unregister); + this.listeners.clear(); + this.commands.clear(); + } + + /** Eine andere, schon gestartete Appliance; Fehler, wenn sie fehlt (abgeschaltet oder später in der Liste). */ + protected T queryAppliance(Class type) { + return this.main.appliance(type).orElseThrow(() -> new IllegalStateException( + this.name() + " braucht die Appliance " + type.getSimpleName() + ", die nicht läuft")); + } + + protected Main plugin() { + return this.main; + } + + protected ProxyServer proxy() { + return this.main.proxy(); + } + + protected Logger logger() { + return this.main.logger(); + } + + protected Servers servers() { + return this.main.servers(); + } + + /** Eigener Abschnitt der Config ({@code .}). */ + protected Config config() { + return this.main.config().section(this.name()); + } + + /** Eigener Ordner für Daten: {@code plugins/proxy//}, wird nicht automatisch angelegt. */ + protected Path dataDirectory() { + return this.main.dataDirectory().resolve(this.name()); + } + + protected Scheduler.TaskBuilder task(Runnable runnable) { + return this.proxy().getScheduler().buildTask(this.main, runnable); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/ApplianceCommand.java b/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/ApplianceCommand.java new file mode 100644 index 0000000..35a0606 --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/ApplianceCommand.java @@ -0,0 +1,12 @@ +package eu.mhsl.craftattack.proxy.core.appliance; + +import com.velocitypowered.api.command.Command; + +import java.util.List; + +/** Befehl einer Appliance samt Aliassen, siehe {@link Appliance#commands()}. */ +public record ApplianceCommand(String name, List aliases, Command command) { + public static ApplianceCommand of(String name, Command command, String... aliases) { + return new ApplianceCommand(name, List.of(aliases), command); + } +} diff --git a/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/PlayerCommand.java b/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/PlayerCommand.java new file mode 100644 index 0000000..bb89d9b --- /dev/null +++ b/src/main/java/eu/mhsl/craftattack/proxy/core/appliance/PlayerCommand.java @@ -0,0 +1,34 @@ +package eu.mhsl.craftattack.proxy.core.appliance; + +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import net.kyori.adventure.text.Component; + +import java.util.List; +import java.util.Locale; + +/** Befehl nur für Spieler, nicht für die Konsole. */ +public abstract class PlayerCommand implements SimpleCommand { + protected abstract void run(Player sender, String[] args); + + @Override + public void execute(Invocation invocation) { + if (invocation.source() instanceof Player player) this.run(player, invocation.arguments()); + else invocation.source().sendMessage(Component.text("Nur für Spieler.")); + } + + /** Namen der Spieler im Netzwerk, die mit dem ersten Argument beginnen. */ + public static List playerNames(ProxyServer proxy, String[] args) { + String start = args.length == 0 ? "" : args[0].toLowerCase(Locale.ROOT); + return proxy.getAllPlayers().stream() + .map(Player::getUsername) + .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(start)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + + public static String sourceName(Invocation invocation) { + return invocation.source() instanceof Player player ? player.getUsername() : "Konsole"; + } +} diff --git a/src/main/resources/config.properties b/src/main/resources/config.properties index 360a3f8..912075d 100644 --- a/src/main/resources/config.properties +++ b/src/main/resources/config.properties @@ -1,5 +1,12 @@ -# chat-bridge: serverübergreifender Chat +# proxy: CraftAttack-Netzwerk auf Velocity # Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity. +# Schlüssel einer Appliance beginnen mit ihrem Namen (chat., tablist. …). + +# Appliances, die nicht geladen werden (mute, chat, tablist). chat braucht mute, tablist braucht chat. +disabled-appliances= + +# Adminchat, /mute und /unmute (LuckPerms auf Velocity) +admin-permission=admin # Symbol vor Spielern, die auf einem anderen Server sind als man selbst (Chat, /msg, Join/Leave, # Tabliste), dunkelgrau, im Hover „ ist gerade “. Bewusst einfache Zeichen ohne @@ -7,16 +14,23 @@ server-symbols=craftattack=⛏|arena=◎|limbo=⌛|event=⚄|grand-event=⚄|worldmuseum=♜ server-where=craftattack=auf CraftAttack|arena=in der Arena|limbo=im Warteraum|event=beim Event|grand-event=beim Event|worldmuseum=im Weltmuseum + +# --- chat ------------------------------------------------------------------------------------- + # 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=worldmuseum +chat.unsilenced-servers=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 +chat.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) -marker-permission=adminmarker +chat.marker-permission=adminmarker + + +# --- tablist ---------------------------------------------------------------------------------- + +# Abgleich der Tablisten in Sekunden; nach Join, Wechsel und Quit zusätzlich kurz danach +tablist.refresh-seconds=2 diff --git a/src/test/java/eu/mhsl/craftattack/chatbridge/ConfigTest.java b/src/test/java/eu/mhsl/craftattack/chatbridge/ConfigTest.java deleted file mode 100644 index 750c7d6..0000000 --- a/src/test/java/eu/mhsl/craftattack/chatbridge/ConfigTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package eu.mhsl.craftattack.chatbridge; - -import org.junit.jupiter.api.Test; - -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Properties; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ConfigTest { - @Test - void templateLoads() throws Exception { - Properties properties = new Properties(); - try (InputStream in = ConfigTest.class.getResourceAsStream("/config.properties")) { - properties.load(new InputStreamReader(in, StandardCharsets.UTF_8)); - } - Config config = Config.fromProperties(properties); - assertEquals("◎", config.serverSymbols().get("arena")); - assertEquals("im Warteraum", config.serverWhere().get("limbo")); - assertEquals(Set.of("worldmuseum"), config.unsilencedServers()); - assertEquals("adminmarker", config.markerPermission()); - assertEquals("craftattack", config.settingsServer()); - } - - @Test - void formatsMuteDuration() { - assertEquals("weniger als eine Minute", Mutes.format(Duration.ofSeconds(30))); - assertEquals("1 Stunde 5 Minuten", Mutes.format(Duration.ofMinutes(65))); - assertEquals("48 Stunden", Mutes.format(Duration.ofHours(48))); - } -} diff --git a/src/test/java/eu/mhsl/craftattack/proxy/ConfigTest.java b/src/test/java/eu/mhsl/craftattack/proxy/ConfigTest.java new file mode 100644 index 0000000..99caae0 --- /dev/null +++ b/src/test/java/eu/mhsl/craftattack/proxy/ConfigTest.java @@ -0,0 +1,63 @@ +package eu.mhsl.craftattack.proxy; + +import eu.mhsl.craftattack.proxy.appliances.mute.Mute; +import eu.mhsl.craftattack.proxy.core.Config; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ConfigTest { + private static Config template() throws Exception { + Properties properties = new Properties(); + try (InputStream in = ConfigTest.class.getResourceAsStream("/config.properties")) { + properties.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + return Config.of(properties); + } + + @Test + void templateLoads() throws Exception { + Config config = template(); + assertEquals(List.of(), config.list("disabled-appliances")); + assertEquals("admin", config.string("admin-permission", "")); + assertEquals("◎", config.pairs("server-symbols").get("arena")); + assertEquals("im Warteraum", config.pairs("server-where").get("limbo")); + + Config chat = config.section("chat"); + assertEquals(List.of("worldmuseum"), chat.list("unsilenced-servers")); + assertEquals("craftattack", chat.string("settings-server", "")); + assertEquals("adminmarker", chat.string("marker-permission", "")); + + assertEquals(2, config.section("tablist").integer("refresh-seconds", 0)); + } + + @Test + void sectionFallsBack() throws Exception { + Config missing = template().section("gibtsnicht"); + assertEquals("x", missing.string("a", "x")); + assertEquals(7, missing.integer("a", 7)); + assertEquals(List.of(), missing.list("a")); + } + + @Test + void rejectsBrokenPairs() { + Properties properties = new Properties(); + properties.setProperty("server-symbols", "arena"); + assertThrows(IllegalArgumentException.class, () -> Config.of(properties).pairs("server-symbols")); + } + + @Test + void formatsMuteDuration() { + assertEquals("weniger als eine Minute", Mute.format(Duration.ofSeconds(30))); + assertEquals("1 Stunde 5 Minuten", Mute.format(Duration.ofMinutes(65))); + assertEquals("48 Stunden", Mute.format(Duration.ofHours(48))); + } +}