Compare commits

...
8 Commits
Author SHA1 Message Date
jannis 7878a463d3 added commandWhitelist to proxy plugin 2026-11-12 15:48:44 +01:00
jannis e006396ed3 fixed issues with whitelist api 2026-11-07 18:49:32 +01:00
jannis 869efecee6 added move to login or craftattack 2026-11-07 15:46:40 +01:00
MineTecandClaude Opus 5.5 ad532c8b99 Umbau zu proxy: Appliances wie in spawn
Kern (Main, Config, Servers, Appliance) lädt die Appliances mute, chat und
tablist; weitere kommen in Main.APPLIANCES dazu. Plugin-ID und Datenordner
heißen jetzt proxy, Kanal craftattack:chat und chatbridge.muted bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 08:56:15 +01:00
MineTec 6d383eaca1 grand-event hat jetzt einen Silencer 2026-10-17 23:28:35 +02:00
MineTec 5e7b57689c Server-Symbole nur für fremde Server, Tabliste als Spectator, Warteraum statt Limbo 2026-10-17 23:09:02 +02:00
MineTec 40edf64352 event hat jetzt einen Silencer 2026-10-17 22:58:03 +02:00
MineTec 735dc8867d Chat-Einstellungen, Tabliste fürs Netzwerk, Fixes aus dem Review 2026-10-17 22:57:42 +02:00
34 changed files with 2033 additions and 628 deletions

No files matched your search

+78 -10
View File
@@ -1,15 +1,59 @@
# chat-bridge # proxy
Velocity-Plugin für CraftAttack: Der Chat gilt im ganzen Netzwerk (craftattack, arena, Limbo, Event). Velocity-Plugin des CraftAttack-Netzwerks (craftattack, arena, Limbo, Event, Weltmuseum). Aufgebaut wie
Velocity verteilt jede Zeile als Systemnachricht an alle Spieler; die Backends verwerfen den Chat 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` |
| `signup` | erster Server beim Join: Login-Server oder CraftAttack, je nach Anmeldung auf der Website | – |
| `commandwhitelist` | nur erlaubte Befehle sichtbar und ausführbar, Liste in `plugins/proxy/commandwhitelist/commandWhitelist.yml`, `/commandwhitelist reload` | – |
## Aufbau
```
src/main/java/eu/mhsl/craftattack/proxy/
core/ Main (Velocity-Plugin), Config, Servers
core/appliance/ Appliance, ApplianceCommand, PlayerCommand
appliances/<name>/ eine Appliance je Package
```
**Neue Appliance** (z. B. der Anmeldecheck, der zwischen Login-Server und echtem Server entscheidet):
1. Package `appliances/<name>/` 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 `<name>.<schlüssel>` in `src/main/resources/config.properties`, lesen mit
`config().string(…)`, `integer`, `bool`, `list`, `pairs`. Daten in `dataDirectory()`
(`plugins/proxy/<name>/`).
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. 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, Warum so herum: Velocity kann eine signierte Chat-Nachricht (ab 1.19.1) weder ändern noch abbrechen,
ohne den Spieler zu trennen. Der Proxy lässt sie deshalb zum Backend durch, schaut nur zu und ohne den Spieler zu trennen. Der Proxy lässt sie deshalb zum Backend durch, schaut nur zu und
verteilt sie selbst. verteilt sie selbst.
- **Öffentlicher Chat** mit Server-Kürzel (`[Arena]`, `[Limbo]` …, der Hauptserver ohne), Namen - **Öffentlicher Chat:** Spieler auf einem anderen Server als man selbst bekommen ein dunkelgraues
von Spielern im Netzwerk werden golden als `@Name` hervorgehoben und hören einen Ton. Symbol vor den Namen (⛏ CraftAttack, ◎ Arena, ⌛ Warteraum, ⚄ Event, ♜ Weltmuseum; Hover: „… ist
gerade in der Arena“), Spieler vom eigenen Server keins. Gilt auch für /msg, Join/Leave und die
Tabliste. Einfache Zeichen ohne Klammern, damit sie nicht mit Rängen ([🌟], 👑, 🗡 …) verwechselt
werden. Namen von Spielern im Netzwerk werden golden als `@Name` hervorgehoben und hören einen Ton.
- **Name:** Rang-Prefixe schickt spawn (`DisplayName` → Kanal `craftattack:chat`, gilt nur solange - **Name:** Rang-Prefixe schickt spawn (`DisplayName` → Kanal `craftattack:chat`, gilt nur solange
der Spieler auf diesem Server ist), blau mit Permission `adminmarker`, 🎮 für Bedrock. der Spieler auf diesem Server ist), blau mit Permission `adminmarker`, 🎮 für Bedrock.
- **/msg** (`tell`, `w`, `whisper`), **/r**, **/adminchat** (Permission `admin`). - **/msg** (`tell`, `w`, `whisper`), **/r**, **/adminchat** (Permission `admin`).
@@ -17,6 +61,13 @@ verteilt sie selbst.
LuckPerms-Permission `chatbridge.muted` in der gemeinsamen MariaDB, läuft von selbst ab. Gestummte LuckPerms-Permission `chatbridge.muted` in der gemeinsamen MariaDB, läuft von selbst ab. Gestummte
dürfen weder öffentlich noch per /msg schreiben, außer an Admins. dürfen weder öffentlich noch per /msg schreiben, außer an Admins.
- **Join/Leave** nur beim Ein- und Ausloggen ins Netzwerk, nicht beim Serverwechsel. - **Join/Leave** nur beim Ein- und Ausloggen ins Netzwerk, nicht beim Serverwechsel.
- **Einstellungen** in `/settings` auf spawn (Join/Leave anzeigen, Erwähnungen hervorheben, Erwähnungston):
Velocity speichert sie in `plugins/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
`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.
- **Log:** Chat, /msg, Adminchat und Mutes stehen im Velocity-Log (`[chat]`, `[msg]`, `[adminchat]`, `[mute]`). - **Log:** Chat, /msg, Adminchat und Mutes stehen im Velocity-Log (`[chat]`, `[msg]`, `[adminchat]`, `[mute]`).
Silencer auf den Backends: Silencer auf den Backends:
@@ -25,13 +76,12 @@ Silencer auf den Backends:
|---|---| |---|---|
| craftattack, arena | spawn, Appliance `NetworkChat` (verwirft Chat und `/me`, `/teammsg`, `minecraft:msg`) | | craftattack, arena | spawn, Appliance `NetworkChat` (verwirft Chat und `/me`, `/teammsg`, `minecraft:msg`) |
| limbo | EndlessJumper | | limbo | EndlessJumper |
| event | Minigames, `velocity.chat-via-proxy: true` in `resources/config.yml` | | event | Minigames (ab `develop` 8dc1659), `velocity.chat-via-proxy: true` in `resources/config.yml` |
Server ohne Silencer stehen in `unsilenced-servers`: Deren Spieler bekommen den Chat vom eigenen Server ohne Silencer stehen in `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. 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). Einstellungen: `plugins/proxy/config.properties` (wird beim ersten Start angelegt, kommentiert).
Braucht LuckPerms auf Velocity.
## Bauen und Deploy ## Bauen und Deploy
@@ -39,5 +89,23 @@ Java 25, Gradle-Wrapper.
```bash ```bash
./gradlew build # inkl. Tests ./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.
+2 -2
View File
@@ -3,7 +3,7 @@ plugins {
} }
group = 'eu.mhsl.craftattack' group = 'eu.mhsl.craftattack'
version = '1.0.0' version = '2.0.0'
repositories { repositories {
mavenCentral() mavenCentral()
@@ -47,7 +47,7 @@ tasks.register('uploadPlugin') {
doLast { doLast {
def file = jar.archiveFile.get().asFile def file = jar.archiveFile.get().asFile
def checksum = file.bytes.digest('SHA-256') 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 { providers.exec {
commandLine 'scp', file.absolutePath, "minecraft@10.20.9.3:${target}.new" commandLine 'scp', file.absolutePath, "minecraft@10.20.9.3:${target}.new"
}.result.get() }.result.get()
+1 -1
View File
@@ -1 +1 @@
rootProject.name = 'chat-bridge' rootProject.name = 'proxy'
@@ -1,61 +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;
@Plugin(
id = "chat-bridge",
name = "chat-bridge",
version = "1.0.0",
description = "Serverübergreifender Chat: öffentlicher Chat, /msg, Adminchat, Mute und 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). */
static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.create("craftattack", "chat");
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;
}
Names names = new Names(config);
Mutes mutes = new Mutes(LuckPermsProvider.get());
Chat chat = new Chat(this.proxy, this.logger, config, names, mutes);
PrivateMessages privateMessages = new PrivateMessages(this.proxy, this.logger, config, names, chat);
this.proxy.getChannelRegistrar().register(CHANNEL);
this.proxy.getEventManager().register(this, new ChatListener(this, this.proxy, this.logger, names, chat, privateMessages));
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,91 +0,0 @@
package eu.mhsl.craftattack.chatbridge;
import com.google.common.io.ByteArrayDataInput;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.DisconnectEvent;
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;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
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 PrivateMessages privateMessages;
/** Wer eine Join-Meldung bekommen hat, bekommt beim Verlassen auch eine. */
private final Set<UUID> announced = ConcurrentHashMap.newKeySet();
ChatListener(Object plugin, ProxyServer proxy, Logger logger, Names names, Chat chat, PrivateMessages privateMessages) {
this.plugin = plugin;
this.proxy = proxy;
this.logger = logger;
this.names = names;
this.chat = chat;
this.privateMessages = privateMessages;
}
/**
* Nur lesen, nie ablehnen: Eine signierte Nachricht abzulehnen trennt den Spieler. Das Backend
* verwirft den Chat selbst.
*/
@Subscribe
public void onChat(PlayerChatEvent event) {
this.chat.handle(event.getPlayer(), event.getMessage());
}
@Subscribe
public void onPluginMessage(PluginMessageEvent event) {
if (!event.getIdentifier().equals(ChatBridge.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;
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));
}
} catch (Exception e) {
this.logger.warn("Ungültige Nachricht von {} für {}", connection.getServerInfo().getName(),
connection.getPlayer().getUsername(), e);
}
}
@Subscribe
public void onServerPostConnect(ServerPostConnectEvent event) {
if (event.getPreviousServer() != null) return;
Player player = event.getPlayer();
this.proxy.getScheduler().buildTask(this.plugin, () -> {
if (!player.isActive() || !this.announced.add(player.getUniqueId())) return;
this.chat.announceJoin(player);
}).delay(JOIN_DELAY_MS, TimeUnit.MILLISECONDS).schedule();
}
@Subscribe
public void onDisconnect(DisconnectEvent event) {
Player player = event.getPlayer();
if (this.announced.remove(player.getUniqueId())) this.chat.announceLeave(player);
this.names.forget(player.getUniqueId());
this.privateMessages.forget(player.getUniqueId());
}
}
@@ -1,183 +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;
/** 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<String> 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 <Nachricht>");
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 <Spieler> <Stunden>", NamedTextColor.RED));
return;
}
resolve(proxy, mutes, args[0])
.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));
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<String> 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 <Spieler>", NamedTextColor.RED));
return;
}
resolve(proxy, mutes, args[0])
.thenCompose(mutes::unmute)
.whenComplete((ignored, error) -> {
if (error != null) {
invocation.source().sendMessage(Component.text("Der Spieler " + args[0] + " ist unbekannt.", NamedTextColor.RED));
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<String> 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());
}
});
}
/** 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);
return uuid;
}));
}
private static List<String> 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."));
}
}
}
@@ -1,69 +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<String, String> serverTags,
Set<String> unsilencedServers,
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-tags", "")),
Set.copyOf(split(p.getProperty("unsilenced-servers", ""))),
p.getProperty("admin-permission", "admin").trim(),
p.getProperty("marker-permission", "adminmarker").trim()
);
}
private static Map<String, String> pairs(String value) {
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");
pairs.put(entry.substring(0, separator).trim(), entry.substring(separator + 1).trim());
}
return Map.copyOf(pairs);
}
private static List<String> split(String value) {
return Arrays.stream(value.split("\\|"))
.map(String::trim)
.filter(entry -> !entry.isEmpty())
.collect(Collectors.toList());
}
}
@@ -1,77 +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.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<Instant> 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<Void> 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());
});
}
CompletableFuture<Void> unmute(UUID player) {
return this.modify(player, user -> user.data().clear(node -> node.getKey().equals(NODE)));
}
CompletableFuture<UUID> lookup(String name) {
return this.luckPerms.getUserManager().lookupUniqueId(name);
}
private CompletableFuture<Void> modify(UUID player, Consumer<User> 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();
}
}
@@ -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<Object> listeners() {
return List.of(new ChatListener(this, this.logger(), this.config, this.names, this.publicChat, this.privateMessages, this.settings));
}
@Override
protected List<ApplianceCommand> 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();
}
}
@@ -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<ApplianceCommand> 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<String> 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 <Nachricht>");
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);
}
}
@@ -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<String> 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")
);
}
}
@@ -0,0 +1,130 @@
package eu.mhsl.craftattack.proxy.appliances.chat;
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;
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.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;
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 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;
/** Wer eine Join-Meldung bekommen hat, bekommt beim Verlassen auch eine. */
private final Set<UUID> announced = new HashSet<>();
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;
}
/**
* Nur lesen, nie ablehnen: Eine signierte Nachricht abzulehnen trennt den Spieler. Das Backend
* verwirft den Chat selbst.
*/
@Subscribe
public void onChat(PlayerChatEvent event) {
this.publicChat.handle(event.getPlayer(), event.getMessage());
}
@Subscribe
public void onPluginMessage(PluginMessageEvent event) {
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;
try {
ByteArrayDataInput in = event.dataAsDataStream();
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(),
connection.getPlayer().getUsername(), e);
}
}
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(Chat.CHANNEL, out.toByteArray());
}
@Subscribe
public void onServerPostConnect(ServerPostConnectEvent event) {
if (event.getPreviousServer() != null) return;
Player player = event.getPlayer();
this.chat.later(() -> {
synchronized (this.announced) {
if (!player.isActive() || !this.announced.add(player.getUniqueId())) return;
}
this.publicChat.announceJoin(player);
}, JOIN_DELAY_MS);
}
@Subscribe
public void onDisconnect(DisconnectEvent event) {
Player player = event.getPlayer();
boolean wasAnnounced;
synchronized (this.announced) {
wasAnnounced = this.announced.remove(player.getUniqueId());
}
if (wasAnnounced) this.publicChat.announceLeave(player);
this.names.forget(player.getUniqueId());
this.privateMessages.forget(player.getUniqueId());
}
}
@@ -0,0 +1,86 @@
package eu.mhsl.craftattack.proxy.appliances.chat;
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/proxy/chat/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: 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);
}
}
}
@@ -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.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.Component;
import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.event.ClickEvent; 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 * 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. * 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 static final TextColor ADMIN = TextColor.color(0x00FFFF);
private record Prefix(String server, Component component) { private record Prefix(String server, Component component) {
} }
private final Config config; private final Servers servers;
private final ChatConfig config;
private final Map<UUID, Prefix> prefixes = new ConcurrentHashMap<>(); private final Map<UUID, Prefix> prefixes = new ConcurrentHashMap<>();
Names(Config config) { Names(Servers servers, ChatConfig config) {
this.servers = servers;
this.config = config; this.config = config;
} }
@@ -43,11 +45,16 @@ final class Names {
} }
/** Name ohne Klick, z. B. für die eigene Seite einer Privatnachricht. */ /** 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. */
public Component plain(Player player, TextColor color) {
TextComponent.Builder name = Component.text(); TextComponent.Builder name = Component.text();
Prefix prefix = this.prefixes.get(player.getUniqueId()); 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)) { if (prefix != null && Objects.equals(prefix.server(), server)) {
name.append(prefix.component()).append(Component.space()); name.append(prefix.component()).append(Component.space());
} }
@@ -60,17 +67,34 @@ final class Names {
} }
boolean marked = player.hasPermission(this.config.markerPermission()); boolean marked = player.hasPermission(this.config.markerPermission());
name.append(Component.text(player.getUsername(), marked ? ADMIN : NamedTextColor.WHITE)); name.append(Component.text(player.getUsername(), marked ? ADMIN : color));
return name.build(); return name.build();
} }
/** Name mit Hover und Klick, der {@code /msg <name>} vorschlägt. */ /** Name mit Hover und Klick, der {@code /msg <name>} vorschlägt. */
Component clickable(Player player) { public Component clickable(Player player) {
return this.plain(player) return this.plain(player)
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um " + player.getUsername() + " zu schreiben", NamedTextColor.GOLD))) .hoverEvent(HoverEvent.showText(Component.text("Klicke, um " + player.getUsername() + " zu schreiben", NamedTextColor.GOLD)))
.clickEvent(ClickEvent.suggestCommand("/msg " + player.getUsername() + " ")); .clickEvent(ClickEvent.suggestCommand("/msg " + player.getUsername() + " "));
} }
/**
* Symbol des Servers, auf dem {@code player} ist, samt Leerzeichen; leer, wenn der Betrachter auf
* demselben Server ist oder der Server kein Symbol hat.
*/
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.servers.symbol(server);
if (symbol == null) return Component.empty();
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))))
.append(Component.space())
.build();
}
/** Floodgate vergibt Bedrock-Spielern UUIDs, deren obere 64 Bit null sind. */ /** Floodgate vergibt Bedrock-Spielern UUIDs, deren obere 64 Bit null sind. */
static boolean isBedrock(UUID uuid) { static boolean isBedrock(UUID uuid) {
return uuid.getMostSignificantBits() == 0; return uuid.getMostSignificantBits() == 0;
@@ -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.Player;
import com.velocitypowered.api.proxy.ProxyServer; 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.Component;
import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.ClickEvent;
@@ -30,17 +32,17 @@ final class PrivateMessages {
private final ProxyServer proxy; private final ProxyServer proxy;
private final Logger logger; private final Logger logger;
private final Config config; private final String adminPermission;
private final Names names; private final Names names;
private final Chat chat; private final Mute mute;
private final Map<UUID, List<Conversation>> replyMapping = new ConcurrentHashMap<>(); private final Map<UUID, List<Conversation>> 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.proxy = proxy;
this.logger = logger; this.logger = logger;
this.config = config; this.adminPermission = adminPermission;
this.names = names; this.names = names;
this.chat = chat; this.mute = mute;
} }
void forget(UUID player) { void forget(UUID player) {
@@ -50,6 +52,8 @@ final class PrivateMessages {
void reply(Player sender, String message) { void reply(Player sender, String message) {
List<Conversation> replyList = this.replyMapping.computeIfAbsent(sender.getUniqueId(), uuid -> new ArrayList<>()); List<Conversation> replyList = this.replyMapping.computeIfAbsent(sender.getUniqueId(), uuid -> new ArrayList<>());
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
// whisper() sperrt die Liste des Empfängers; erst nach diesem Block, sonst Deadlock bei gleichzeitigem /r
Player whisperTo = null;
synchronized (replyList) { synchronized (replyList) {
replyList.removeIf(conversation -> conversation.lastSet() < now - CONVERSATION_TIMEOUT_MINUTES * 60_000L); replyList.removeIf(conversation -> conversation.lastSet() < now - CONVERSATION_TIMEOUT_MINUTES * 60_000L);
@@ -81,36 +85,40 @@ final class PrivateMessages {
return; return;
} }
replyList.clear(); replyList.clear();
this.whisper(sender, target, message); whisperTo = target;
return; } else {
this.offerChoice(sender, message, replyList, old);
} }
TextComponent.Builder choice = Component.text()
.append(Component.newline())
.append(PREFIX)
.append(Component.text("Das Ziel für /r hat sich bei dir in den letzten " + TARGET_CHANGE_TIMEOUT_SECONDS
+ " Sekunden geändert. Wer soll deine Nachricht erhalten? ", NamedTextColor.RED))
.append(Component.newline())
.append(Component.newline());
if (!old.isEmpty()) {
Conversation youngestOld = old.stream()
.max(Comparator.comparingLong(Conversation::lastSet))
.orElse(old.getLast());
replyList.removeAll(old);
replyList.add(youngestOld);
}
replyList.stream().map(Conversation::targetName).distinct().forEach(name -> choice
.append(Component.text("[")
.append(Component.text(name, NamedTextColor.GOLD))
.append(Component.text("]"))
.clickEvent(ClickEvent.runCommand("/msg " + name + " " + message))
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesem Spieler zu schreiben.", NamedTextColor.GOLD))))
.append(Component.text(" ")));
choice.append(Component.newline());
sender.sendMessage(choice.build());
} }
if (whisperTo != null) this.whisper(sender, whisperTo, message);
}
private void offerChoice(Player sender, String message, List<Conversation> replyList, List<Conversation> old) {
TextComponent.Builder choice = Component.text()
.append(Component.newline())
.append(PREFIX)
.append(Component.text("Das Ziel für /r hat sich bei dir in den letzten " + TARGET_CHANGE_TIMEOUT_SECONDS
+ " Sekunden geändert. Wer soll deine Nachricht erhalten? ", NamedTextColor.RED))
.append(Component.newline())
.append(Component.newline());
if (!old.isEmpty()) {
Conversation youngestOld = old.stream()
.max(Comparator.comparingLong(Conversation::lastSet))
.orElse(old.getLast());
replyList.removeAll(old);
replyList.add(youngestOld);
}
replyList.stream().map(Conversation::targetName).distinct().forEach(name -> choice
.append(Component.text("[")
.append(Component.text(name, NamedTextColor.GOLD))
.append(Component.text("]"))
.clickEvent(ClickEvent.runCommand("/msg " + name + " " + message))
.hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesem Spieler zu schreiben.", NamedTextColor.GOLD))))
.append(Component.text(" ")));
choice.append(Component.newline());
sender.sendMessage(choice.build());
} }
void message(Player sender, String targetName, String message) { void message(Player sender, String targetName, String message) {
@@ -124,7 +132,7 @@ final class PrivateMessages {
private void whisper(Player sender, Player receiver, String message) { private void whisper(Player sender, Player receiver, String message) {
// Gestummte dürfen nur noch Admins schreiben // 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); this.logger.info("[msg] {} -> {} (gestummt): {}", sender.getUsername(), receiver.getUsername(), message);
return; return;
} }
@@ -143,11 +151,13 @@ final class PrivateMessages {
.append(PREFIX.clickEvent(ClickEvent.suggestCommand("/msg " + receiver.getUsername() + " "))) .append(PREFIX.clickEvent(ClickEvent.suggestCommand("/msg " + receiver.getUsername() + " ")))
.append(this.names.plain(sender)) .append(this.names.plain(sender))
.append(Component.text(" zu ", NamedTextColor.GRAY)) .append(Component.text(" zu ", NamedTextColor.GRAY))
.append(this.names.marker(receiver, Servers.of(sender)))
.append(this.names.clickable(receiver)) .append(this.names.clickable(receiver))
.append(Component.text(" > ", NamedTextColor.GRAY)) .append(Component.text(" > ", NamedTextColor.GRAY))
.append(Component.text(message))); .append(Component.text(message)));
receiver.sendMessage(Component.text() receiver.sendMessage(Component.text()
.append(PREFIX.clickEvent(ClickEvent.suggestCommand("/msg " + sender.getUsername() + " "))) .append(PREFIX.clickEvent(ClickEvent.suggestCommand("/msg " + sender.getUsername() + " ")))
.append(this.names.marker(sender, Servers.of(receiver)))
.append(this.names.clickable(sender)) .append(this.names.clickable(sender))
.append(Component.text(" zu ", NamedTextColor.GRAY)) .append(Component.text(" zu ", NamedTextColor.GRAY))
.append(this.names.plain(receiver)) .append(this.names.plain(receiver))
@@ -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.Player;
import com.velocitypowered.api.proxy.ProxyServer; 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.key.Key;
import net.kyori.adventure.sound.Sound; import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TextComponent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextColor;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger; import org.slf4j.Logger;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; 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. * Ö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. * 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 GRAY = TextColor.color(0x808080);
private static final TextColor SILVER = TextColor.color(0xC0C0C0); private static final TextColor SILVER = TextColor.color(0xC0C0C0);
/** Wort mit optionalem @ davor und Satzzeichen dahinter; Bedrock-Namen beginnen mit einem Punkt. */ /** Wort mit optionalem @ davor und Satzzeichen dahinter; Bedrock-Namen beginnen mit einem Punkt. */
@@ -36,70 +33,60 @@ final class Chat {
private final ProxyServer proxy; private final ProxyServer proxy;
private final Logger logger; private final Logger logger;
private final Config config; private final ChatConfig config;
private final Names names; 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) { PublicChat(ProxyServer proxy, Logger logger, ChatConfig config, Names names, Mute mute, ChatSettings settings) {
this.proxy = proxy; this.proxy = proxy;
this.logger = logger; this.logger = logger;
this.config = config; this.config = config;
this.names = names; this.names = names;
this.mutes = mutes; this.mute = mute;
this.settings = settings;
} }
void handle(Player sender, String message) { void handle(Player sender, String message) {
String server = serverOf(sender); String server = Servers.of(sender);
if (this.denyMuted(sender)) { if (this.mute.denyMuted(sender)) {
this.logger.info("[chat] [{}] {} (gestummt): {}", server, sender.getUsername(), message); this.logger.info("[chat] [{}] {} (gestummt): {}", server, sender.getUsername(), message);
return; return;
} }
Set<Player> mentioned = new LinkedHashSet<>(); Set<Player> mentioned = new LinkedHashSet<>();
Component line = Component.text() Component highlighted = this.body(message, mentioned);
.append(this.tag(server)) Component plain = Component.text(message);
.append(this.names.clickable(sender)) Component name = this.names.clickable(sender);
.append(Component.text(" > ", GRAY))
.append(this.body(message, mentioned).colorIfAbsent(SILVER))
.build();
for (Player player : this.proxy.getAllPlayers()) { for (Player player : this.proxy.getAllPlayers()) {
if (server != null && this.config.unsilencedServers().contains(server) && server.equals(serverOf(player))) continue; String viewerServer = Servers.of(player);
player.sendMessage(line); if (server != null && this.config.unsilencedServers().contains(server) && server.equals(viewerServer)) continue;
ChatSettings.Values values = this.settings.of(player.getUniqueId());
player.sendMessage(Component.text()
.append(this.names.marker(sender, viewerServer))
.append(name)
.append(Component.text(" > ", GRAY))
.append((values.highlightMentions() ? highlighted : plain).colorIfAbsent(SILVER)));
if (mentioned.contains(player) && !player.equals(sender) && values.mentionSound()) player.playSound(MENTION);
} }
mentioned.stream()
.filter(player -> !player.equals(sender))
.forEach(player -> player.playSound(MENTION));
this.logger.info("[chat] [{}] {}: {}", server, sender.getUsername(), message); this.logger.info("[chat] [{}] {}: {}", server, sender.getUsername(), message);
} }
void announceJoin(Player player) { void announceJoin(Player player) {
this.broadcast(Component.text(">>> ", NamedTextColor.GREEN).append(this.names.clickable(player))); this.announce(player, Component.text(">>> ", NamedTextColor.GREEN));
} }
void announceLeave(Player player) { void announceLeave(Player player) {
this.broadcast(Component.text("<<< ", NamedTextColor.RED).append(this.names.clickable(player))); this.announce(player, Component.text("<<< ", NamedTextColor.RED));
} }
void broadcast(Component line) { private void announce(Player subject, Component arrow) {
this.proxy.getAllPlayers().forEach(player -> player.sendMessage(line)); Component name = this.names.clickable(subject);
} this.proxy.getAllPlayers().stream()
.filter(player -> this.settings.of(player.getUniqueId()).joinAndLeave())
/** Meldet einem gestummten Spieler die Restzeit; true, wenn er gestummt ist. */ .forEach(player -> player.sendMessage(arrow.append(this.names.marker(subject, Servers.of(player))).append(name)));
boolean denyMuted(Player player) {
Optional<Instant> 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;
}
private Component tag(@Nullable String server) {
String tag = server == null ? null : this.config.serverTags().get(server);
if (tag == null) return Component.empty();
return Component.text("[" + tag + "] ", NamedTextColor.DARK_GRAY);
} }
/** Text der Nachricht; Namen von Spielern im Netzwerk werden golden als @Name hervorgehoben. */ /** 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())); boolean endOk = matcher.end() == message.length() || Character.isWhitespace(message.charAt(matcher.end()));
return startOk && endOk; return startOk && endOk;
} }
static @Nullable String serverOf(Player player) {
return player.getCurrentServer().map(ServerConnection::getServerInfo).map(info -> info.getName()).orElse(null);
}
} }
@@ -0,0 +1,95 @@
package eu.mhsl.craftattack.proxy.appliances.commandwhitelist;
import com.mojang.brigadier.tree.CommandNode;
import com.mojang.brigadier.tree.RootCommandNode;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.command.CommandExecuteEvent;
import com.velocitypowered.api.event.command.CommandExecuteEvent.CommandResult;
import com.velocitypowered.api.event.command.CommandExecuteEvent.SignedState;
import com.velocitypowered.api.event.command.PlayerAvailableCommandsEvent;
import com.velocitypowered.api.network.ProtocolVersion;
import com.velocitypowered.api.proxy.Player;
import org.slf4j.Logger;
import java.util.List;
import java.util.Locale;
import java.util.function.Supplier;
final class CommandFilterListener {
private final Supplier<WhitelistConfig> config;
private final Logger logger;
CommandFilterListener(Supplier<WhitelistConfig> config, Logger logger) {
this.config = config;
this.logger = logger;
}
// Highest priority: runs before every other listener, so no other proxy plugin even sees blocked commands.
@Subscribe(priority = Short.MAX_VALUE, async = false)
public void onCommandExecute(CommandExecuteEvent event) {
if (!(event.getCommandSource() instanceof Player player)) {
return;
}
WhitelistConfig config = this.config.get();
if (player.hasPermission(config.bypassPermission())) {
return;
}
String root = rootCommand(event.getCommand());
if (config.isAllowed(root)) {
return;
}
event.setResult(this.blockResult(player, event, root));
}
// Lowest priority: runs after every other listener, so commands other plugins inject into the tree are removed too.
@Subscribe(priority = Short.MIN_VALUE, async = false)
public void onAvailableCommands(PlayerAvailableCommandsEvent event) {
RootCommandNode<?> root = event.getRootNode();
WhitelistConfig config = this.config.get();
root.removeChildByName(CommandWhitelist.SWALLOW_COMMAND);
if (event.getPlayer().hasPermission(config.bypassPermission())) {
return;
}
List<String> blocked = root.getChildren().stream()
.map(CommandNode::getName)
.filter(name -> !config.isAllowed(name.toLowerCase(Locale.ROOT)))
.toList();
blocked.forEach(root::removeChildByName);
}
/**
* Picks a result that drops the command silently. Velocity disconnects the player when a command packet carrying
* chat signatures is denied, because the signed message chain would break.
*/
private CommandResult blockResult(Player player, CommandExecuteEvent event, String root) {
// 1.19.1/1.19.2 clients sign every command packet (salt != 0), and Velocity kicks on denied() for them.
// A command that was handled by the proxy is dropped silently instead, so we hand it to our no-op command.
if (player.getProtocolVersion() == ProtocolVersion.MINECRAFT_1_19_1) {
return CommandResult.command(CommandWhitelist.SWALLOW_COMMAND);
}
if (event.getInvocationInfo().signedState() == SignedState.SIGNED_WITH_ARGS) {
// The filtered tree never lets a client sign a blocked command. This only happens with an outdated tree
// (config reload, bypass permission removed) or a manipulated client. Blocked anyway; Velocity kicks.
this.logger.warn("{} sent signed arguments for blocked command /{}. Blocking it; Velocity will "
+ "disconnect them. If this follows a reload, their command tree updates on the next server switch.",
player.getUsername(), root);
}
return CommandResult.denied();
}
/** First token of the command line, without slash, lowercased. "/Msg Foo hi" -> "msg". */
static String rootCommand(String commandLine) {
String command = commandLine.startsWith("/") ? commandLine.substring(1) : commandLine;
int space = command.indexOf(' ');
return (space == -1 ? command : command.substring(0, space)).toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,81 @@
package eu.mhsl.craftattack.proxy.appliances.commandwhitelist;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.tree.LiteralCommandNode;
import com.velocitypowered.api.command.BrigadierCommand;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.command.SimpleCommand;
import eu.mhsl.craftattack.proxy.core.appliance.Appliance;
import eu.mhsl.craftattack.proxy.core.appliance.ApplianceCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import java.util.List;
public final class CommandWhitelist extends Appliance {
static final String ADMIN_PERMISSION = "commandwhitelist.admin";
/**
* Internal no-op command. Blocked commands of 1.19.1/1.19.2 clients are redirected here, see
* {@link CommandFilterListener}. It is always removed from the command tree, so nobody can see it.
*/
static final String SWALLOW_COMMAND = "commandwhitelist:swallow";
private volatile WhitelistConfig config = WhitelistConfig.EMPTY;
@Override
public String name() {
return "commandwhitelist";
}
@Override
protected void onEnable() {
try {
this.config = WhitelistConfig.load(this.dataDirectory());
this.logger().info("Loaded {} allowed commands.", this.config.allowedCommands().size());
} catch (Exception e) {
this.logger().error("Could not load commandWhitelist.yml, blocking ALL commands for non-bypass players.", e);
}
}
@Override
protected List<Object> listeners() {
return List.of(new CommandFilterListener(() -> this.config, this.logger()));
}
@Override
protected List<ApplianceCommand> commands() {
LiteralCommandNode<CommandSource> admin = BrigadierCommand.literalArgumentBuilder("commandwhitelist")
.requires(source -> source.hasPermission(ADMIN_PERMISSION))
.then(BrigadierCommand.literalArgumentBuilder("reload")
.executes(context -> {
this.reload(context.getSource());
return Command.SINGLE_SUCCESS;
}))
.build();
SimpleCommand swallow = invocation -> { };
return List.of(
ApplianceCommand.of("commandwhitelist", new BrigadierCommand(admin), "cwl"),
ApplianceCommand.of(SWALLOW_COMMAND, swallow));
}
private void reload(CommandSource source) {
try {
this.config = WhitelistConfig.load(this.dataDirectory());
} catch (Exception e) {
this.logger().error("Could not reload commandWhitelist.yml, keeping the previous configuration.", e);
source.sendMessage(Component.text(
"Reload failed, the previous configuration is still active. See the console for details.",
NamedTextColor.RED));
return;
}
source.sendMessage(Component.text(
"CommandWhitelist reloaded: " + this.config.allowedCommands().size() + " allowed commands. "
+ "Tab completion updates on each player's next server switch.",
NamedTextColor.GREEN));
}
}
@@ -0,0 +1,64 @@
package eu.mhsl.craftattack.proxy.appliances.commandwhitelist;
import org.spongepowered.configurate.CommentedConfigurationNode;
import org.spongepowered.configurate.yaml.YamlConfigurationLoader;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Immutable snapshot of commandWhitelist.yml. A reload swaps the whole instance, so readers never see a half-loaded state.
*/
record WhitelistConfig(Set<String> allowedCommands, String bypassPermission) {
static final String DEFAULT_BYPASS_PERMISSION = "commandwhitelist.bypass";
private static final String FILE_NAME = "commandWhitelist.yml";
/** Used if the config can't be read on startup: block everything for non-bypass players (fail closed). */
static final WhitelistConfig EMPTY = new WhitelistConfig(Set.of(), DEFAULT_BYPASS_PERMISSION);
boolean isAllowed(String rootCommand) {
return this.allowedCommands.contains(rootCommand);
}
static WhitelistConfig load(Path dataDirectory) throws IOException {
Path file = dataDirectory.resolve(FILE_NAME);
if (Files.notExists(file)) {
Files.createDirectories(dataDirectory);
try (InputStream defaults = WhitelistConfig.class.getResourceAsStream("/" + FILE_NAME)) {
Files.copy(Objects.requireNonNull(defaults, "default config missing in jar"), file);
}
}
CommentedConfigurationNode root = YamlConfigurationLoader.builder().path(file).build().load();
Set<String> allowed = root.node("allowed-commands").getList(String.class, List.of()).stream()
.filter(Objects::nonNull)
.map(WhitelistConfig::normalize)
.filter(command -> !command.isEmpty())
.collect(Collectors.toUnmodifiableSet());
String bypass = root.node("bypass-permission").getString(DEFAULT_BYPASS_PERMISSION).trim();
if (bypass.isEmpty()) {
bypass = DEFAULT_BYPASS_PERMISSION;
}
return new WhitelistConfig(allowed, bypass);
}
/** Lowercases and strips an accidental leading slash, e.g. "/MSG" -> "msg". */
static String normalize(String command) {
String trimmed = command.trim();
if (trimmed.startsWith("/")) {
trimmed = trimmed.substring(1);
}
return trimmed.toLowerCase(Locale.ROOT);
}
}
@@ -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<Instant> 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<Instant> 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<Void> 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<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());
}
private CompletableFuture<Void> modify(UUID player, Consumer<User> 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<UUID> 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<ApplianceCommand> 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 <Spieler> <Stunden>", 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<String> 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 <Spieler>", 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<String> 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);
}
}
}
@@ -0,0 +1,178 @@
package eu.mhsl.craftattack.proxy.appliances.signup;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import com.velocitypowered.api.scheduler.ScheduledTask;
import eu.mhsl.craftattack.proxy.appliances.signup.SignupApi.Status;
import eu.mhsl.craftattack.proxy.core.Config;
import eu.mhsl.craftattack.proxy.core.appliance.Appliance;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
public final class Signup extends Appliance {
enum Phase { BEFORE, LOGIN, OPEN, LATE }
enum Target { LOGIN, CRAFTATTACK }
private RegisteredServer login;
private RegisteredServer craftattack;
private RegisteredServer fallback;
private Duration pingTimeout;
private ZoneId zone;
private Instant registrationRequiredFrom;
private Instant openForAllFrom;
private Instant lateSignupFrom;
private SignupApi api;
private @Nullable ScheduledTask recheckTask;
private final Set<UUID> checking = ConcurrentHashMap.newKeySet();
private static final DateTimeFormatter START_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm");
@Override
protected void onEnable() {
Config config = this.config();
this.login = this.server(config.string("login-server", "login"));
this.craftattack = this.server(config.string("craftattack-server", "craftattack"));
this.fallback = this.server(config.string("fallback-server", "limbo"));
this.pingTimeout = Duration.ofSeconds(config.integer("ping-timeout-seconds", 3));
ZoneId zone = this.zone = ZoneId.of(config.string("timezone", "Europe/Berlin"));
this.registrationRequiredFrom = LocalDateTime.parse(config.string("registration-required-from", "2026-12-01T00:00")).atZone(zone).toInstant();
this.openForAllFrom = LocalDateTime.parse(config.string("open-for-all-from", "2026-12-26T14:00")).atZone(zone).toInstant();
this.lateSignupFrom = LocalDateTime.parse(config.string("late-signup-from", "2026-12-29T14:00")).atZone(zone).toInstant();
if (!this.registrationRequiredFrom.isBefore(this.openForAllFrom) || !this.openForAllFrom.isBefore(this.lateSignupFrom))
throw new IllegalArgumentException("signup: registration-required-from < open-for-all-from < late-signup-from erwartet");
this.api = new SignupApi(
config.string("api-url", ""),
config.string("late-signup-path", "signup"),
config.string("api-secret", ""),
Duration.ofSeconds(config.integer("timeout-seconds", 5)),
this.logger());
int recheck = config.integer("recheck-seconds", 10);
if (recheck > 0) this.recheckTask = this.task(this::recheck).repeat(Duration.ofSeconds(recheck)).schedule();
}
private RegisteredServer server(String name) {
return this.proxy().getServer(name).orElseThrow(() -> new IllegalArgumentException("Server " + name + " ist in Velocity nicht eingetragen"));
}
Phase phase(Instant now) {
return phase(now, this.registrationRequiredFrom, this.openForAllFrom, this.lateSignupFrom);
}
static Phase phase(Instant now, Instant registrationRequiredFrom, Instant openForAllFrom, Instant lateSignupFrom) {
if (now.isBefore(registrationRequiredFrom)) return Phase.BEFORE;
if (now.isBefore(openForAllFrom)) return Phase.LOGIN;
if (now.isBefore(lateSignupFrom)) return Phase.OPEN;
return Phase.LATE;
}
static Target target(Phase phase, Status registered, Status signedUp) {
if (registered == Status.FOUND || registered == Status.BLOCKED) return Target.CRAFTATTACK;
return switch (phase) {
case BEFORE, OPEN -> Target.CRAFTATTACK;
case LOGIN -> Target.LOGIN;
case LATE -> signedUp == Status.FOUND ? Target.CRAFTATTACK : Target.LOGIN;
};
}
CompletableFuture<Decision> decide(Player player) {
Phase phase = this.phase(Instant.now());
// Vor dem 1.12. und im offenen Zeitraum braucht es die Website nicht
if (phase == Phase.BEFORE || phase == Phase.OPEN)
return CompletableFuture.completedFuture(new Decision(phase, Status.UNKNOWN, Status.UNKNOWN));
return this.api.registered(player).thenCompose(registered -> {
// Angemeldete und Gesperrte kommen immer auf CraftAttack, die nachträgliche Anmeldung ist dann egal
if (phase != Phase.LATE || registered == Status.FOUND || registered == Status.BLOCKED)
return CompletableFuture.completedFuture(new Decision(phase, registered, Status.UNKNOWN));
return this.api.signedUp(player).thenApply(signedUp -> new Decision(phase, registered, signedUp));
});
}
record Decision(Phase phase, Status registered, Status signedUp) {
Target target() {
return Signup.target(this.phase, this.registered, this.signedUp);
}
@Override
public String toString() {
return this.phase + ", angemeldet " + this.registered + ", nachträglich " + this.signedUp + " -> " + this.target();
}
}
CompletableFuture<RegisteredServer> server(Target target) {
RegisteredServer server = target == Target.LOGIN ? this.login : this.craftattack;
return server.ping()
.orTimeout(this.pingTimeout.toMillis(), TimeUnit.MILLISECONDS)
.thenApply(ping -> server)
.exceptionally(error -> {
this.logger().warn("[signup] {} nicht erreichbar, stattdessen {}", server.getServerInfo().getName(), this.fallback.getServerInfo().getName());
return this.fallback;
});
}
private void recheck() {
for (Player player : this.login.getPlayersConnected()) {
if (!this.checking.add(player.getUniqueId())) continue;
this.decide(player).thenCompose(decision -> {
if (decision.target() != Target.CRAFTATTACK || !player.isActive()) return CompletableFuture.completedFuture(null);
if (decision.phase() != Phase.LATE && decision.registered() == Status.FOUND) {
this.logger().info("[signup] {} angemeldet, gekickt: {}", player.getUsername(), decision);
player.disconnect(this.thanks());
return CompletableFuture.completedFuture(null);
}
return this.server(Target.CRAFTATTACK).thenAccept(server -> {
this.logger().info("[signup] {} vom Login-Server auf {}: {}", player.getUsername(), server.getServerInfo().getName(), decision);
if (server == this.craftattack)
player.sendMessage(Component.text("Du bist angemeldet und wirst auf CraftAttack verbunden.", NamedTextColor.GREEN));
player.createConnectionRequest(server).fireAndForget();
});
}).whenComplete((ignored, error) -> {
this.checking.remove(player.getUniqueId());
if (error != null) this.logger().error("[signup] Prüfung für {} fehlgeschlagen", player.getUsername(), error);
});
}
}
private Component thanks() {
if (Instant.now().isBefore(this.openForAllFrom))
return Component.text("Danke für deine Anmeldung! CraftAttack startet am "
+ START_FORMAT.format(this.openForAllFrom.atZone(this.zone)) + " Uhr.", NamedTextColor.GREEN);
return Component.text("Danke für deine Anmeldung! Verbinde dich neu, um auf CraftAttack zu spielen.", NamedTextColor.GREEN);
}
boolean isLogin(RegisteredServer server) {
return server.equals(this.login);
}
@Override
protected void onDisable() {
if (this.recheckTask != null) this.recheckTask.cancel();
}
@Override
public Logger logger() {
return super.logger();
}
@Override
protected List<Object> listeners() {
return List.of(new SignupListener(this));
}
}
@@ -0,0 +1,97 @@
package eu.mhsl.craftattack.proxy.appliances.signup;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.velocitypowered.api.proxy.Player;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
final class SignupApi {
enum Status { FOUND, NOT_FOUND, BLOCKED, UNKNOWN }
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private final URI api;
private final String signupPath;
private final @Nullable String authorization;
private final Duration timeout;
private final Logger logger;
SignupApi(String url, String signupPath, String secret, Duration timeout, Logger logger) {
if (url.isEmpty()) throw new IllegalArgumentException("signup.api-url fehlt");
this.api = root(url);
this.signupPath = signupPath.startsWith("/") ? signupPath.substring(1) : signupPath;
// Website: Basic-Auth mit Benutzer „api“ und API_SECRET; ohne Secret prüft sie nichts
this.authorization = secret.isEmpty() ? null
: "Basic " + Base64.getEncoder().encodeToString(("api:" + secret).getBytes(StandardCharsets.UTF_8));
this.timeout = timeout;
this.logger = logger;
}
static URI root(String url) {
String base = url.replaceAll("/+$", "");
if (base.endsWith("/api")) base = base.substring(0, base.length() - "/api".length());
return URI.create(base + "/api/");
}
CompletableFuture<Status> registered(Player player) {
String path = "users/" + player.getUniqueId();
return this.get(path)
.thenApply(response -> switch (response.statusCode()) {
case 200 -> Status.FOUND;
case 403 -> Status.BLOCKED;
case 404 -> Status.NOT_FOUND;
default -> this.unexpected(path, player, response);
})
.exceptionally(error -> this.unreachable(path, player, error));
}
CompletableFuture<Status> signedUp(Player player) {
return this.get(this.signupPath)
.thenApply(response -> {
if (response.statusCode() != 200) return this.unexpected(this.signupPath, player, response);
return uuids(response.body()).contains(player.getUniqueId()) ? Status.FOUND : Status.NOT_FOUND;
})
.exceptionally(error -> this.unreachable(this.signupPath, player, error));
}
static Set<UUID> uuids(String json) {
Set<UUID> uuids = new HashSet<>();
for (JsonElement entry : JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("uuid")) {
String uuid = entry.getAsString().trim().toLowerCase(Locale.ROOT);
if (uuid.length() == 32) uuid = uuid.replaceFirst("(.{8})(.{4})(.{4})(.{4})(.{12})", "$1-$2-$3-$4-$5");
uuids.add(UUID.fromString(uuid));
}
return uuids;
}
private CompletableFuture<HttpResponse<String>> get(String path) {
HttpRequest.Builder request = HttpRequest.newBuilder(this.api.resolve(path))
.timeout(this.timeout)
.GET();
if (this.authorization != null) request.header("Authorization", this.authorization);
return this.http.sendAsync(request.build(), HttpResponse.BodyHandlers.ofString());
}
private Status unexpected(String path, Player player, HttpResponse<?> response) {
this.logger.warn("[signup] Website antwortet {} auf {} für {}", response.statusCode(), path, player.getUsername());
return Status.UNKNOWN;
}
private Status unreachable(String path, Player player, Throwable error) {
this.logger.warn("[signup] Website nicht erreichbar oder Antwort ungültig ({}) für {}: {}", path, player.getUsername(), error.toString());
return Status.UNKNOWN;
}
}
@@ -0,0 +1,32 @@
package eu.mhsl.craftattack.proxy.appliances.signup;
import com.velocitypowered.api.event.EventTask;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.player.KickedFromServerEvent;
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
import com.velocitypowered.api.proxy.Player;
import net.kyori.adventure.text.Component;
final class SignupListener {
private final Signup signup;
SignupListener(Signup signup) {
this.signup = signup;
}
@Subscribe
public EventTask onChooseInitialServer(PlayerChooseInitialServerEvent event) {
Player player = event.getPlayer();
return EventTask.resumeWhenComplete(this.signup.decide(player).thenCompose(decision -> {
this.signup.logger().info("[signup] {} ({}): {}", player.getUsername(), player.getUniqueId(), decision);
return this.signup.server(decision.target());
}).thenAccept(event::setInitialServer));
}
@Subscribe
public void onKicked(KickedFromServerEvent event) {
if (!this.signup.isLogin(event.getServer())) return;
Component reason = event.getServerKickReason().orElse(Component.text("Verbindung zum Login-Server getrennt."));
event.setResult(KickedFromServerEvent.DisconnectPlayer.create(reason));
}
}
@@ -0,0 +1,150 @@
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.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). 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.
* <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.
*/
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 Names names;
private ScheduledTask refreshTask;
/** Je Betrachter die Einträge, die von hier stammen. */
private final Map<UUID, Set<UUID>> added = new ConcurrentHashMap<>();
@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<Object> 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 = Servers.of(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 = Servers.of(other);
if (other.equals(viewer) || otherServer == null || otherServer.equals(viewerServer)) continue;
wanted.add(other.getUniqueId());
Component displayName = this.displayName(other, viewerServer);
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(SPECTATOR)
.listed(true)
.listOrder(REMOTE_ORDER)
.build());
ours.add(other.getUniqueId());
} else {
if (!Objects.equals(entry.getDisplayNameComponent().orElse(null), displayName)) entry.setDisplayName(displayName);
if (entry.getGameMode() != SPECTATOR) entry.setGameMode(SPECTATOR);
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(Servers::of)
.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);
}
/** Symbol des Servers, Name grau (Admins blau); der Spectator-Modus macht ihn zusätzlich kursiv. */
private Component displayName(Player player, String viewerServer) {
return Component.text()
.append(this.names.marker(player, viewerServer))
.append(this.names.plain(player, NamedTextColor.GRAY))
.build();
}
}
@@ -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.<schlüssel>}.
*/
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 <name>.} 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<String> 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<String, String> pairs(String key) {
Map<String, String> 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);
}
}
@@ -0,0 +1,142 @@
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.commandwhitelist.CommandWhitelist;
import eu.mhsl.craftattack.proxy.appliances.mute.Mute;
import eu.mhsl.craftattack.proxy.appliances.signup.Signup;
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<Supplier<Appliance>> APPLIANCES = List.of(
Mute::new,
Chat::new,
NetworkTabList::new,
Signup::new,
CommandWhitelist::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<Appliance> 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<String> disabled = Set.copyOf(this.config.list("disabled-appliances"));
for (Supplier<Appliance> 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 <T extends Appliance> java.util.Optional<T> appliance(Class<T> 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");
}
}
@@ -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<String, String> symbols, Map<String, String> 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 &lt;server&gt;“. */
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);
}
}
@@ -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}.
* <p>
* 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<Object> listeners = new ArrayList<>();
private final List<CommandMeta> 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<Object> listeners() {
return List.of();
}
/** Befehle auf dem Proxy; werden nach {@link #onEnable()} registriert. */
protected List<ApplianceCommand> 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 extends Appliance> T queryAppliance(Class<T> 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 <name>.<schlüssel>}). */
protected Config config() {
return this.main.config().section(this.name());
}
/** Eigener Ordner für Daten: {@code plugins/proxy/<name>/}, 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);
}
}
@@ -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<String> aliases, Command command) {
public static ApplianceCommand of(String name, Command command, String... aliases) {
return new ApplianceCommand(name, List.of(aliases), command);
}
}
@@ -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<String> 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";
}
}
+17
View File
@@ -0,0 +1,17 @@
# CommandWhitelist
#
# Only the root commands listed here can be seen (tab completion) and executed by
# players without the bypass permission. Everything else is silently swallowed on
# the proxy and never reaches a backend server.
#
# - Without leading slash, case-insensitive.
# - Aliases and namespaces are NOT allowed automatically:
# "minecraft:msg" is only allowed if "minecraft:msg" itself is in this list.
allowed-commands:
- msg
- tell
- r
- spawn
# Players with this permission are completely unaffected by the plugin.
bypass-permission: commandwhitelist.bypass
+66 -8
View File
@@ -1,16 +1,74 @@
# chat-bridge: serverübergreifender Chat # proxy: CraftAttack-Netzwerk auf Velocity
# Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity. # Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity.
# Schlüssel einer Appliance beginnen mit ihrem Namen (chat., tablist. …).
# Kürzel vor Nachrichten von diesem Server (Name aus velocity.toml=Kürzel). # Appliances, die nicht geladen werden (mute, chat, tablist, signup, commandwhitelist). chat braucht mute, tablist braucht chat.
# Server ohne Eintrag (der Hauptserver) bekommen keins. disabled-appliances=
server-tags=arena=Arena|limbo=Limbo|event=Event|grand-event=Event|worldmuseum=Museum
# 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 „<Name> ist gerade <server-where>“. Bewusst einfache Zeichen ohne
# Klammern, damit sie nicht mit Rängen ([🌟], 👑, 🗡 …) verwechselt werden. Server=Symbol.
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). # Server, die ihren Chat nicht selbst unterdrücken (kein spawn-Plugin bzw. kein Silencer).
# Deren Spieler sehen den Chat vom eigenen Server schon lokal und bekommen ihn von Velocity nicht # Deren Spieler sehen den Chat vom eigenen Server schon lokal und bekommen ihn von Velocity nicht
# noch einmal. Achtung: Dort greift der Mute nur für die anderen Server. # noch einmal. Achtung: Dort greift der Mute nur für die anderen Server.
unsilenced-servers=event|grand-event|worldmuseum 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.
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) # 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
# --- signup -----------------------------------------------------------------------------------
# Erster Server beim Join. Wer angemeldet oder gesperrt ist, kommt immer auf craftattack-server.
signup.login-server=login
signup.craftattack-server=craftattack
# Antwortet login-server bzw. craftattack-server nicht innerhalb von ping-timeout-seconds auf einen
# Ping, kommt der Spieler stattdessen hierhin.
signup.fallback-server=limbo
signup.ping-timeout-seconds=3
# Was mit nicht Angemeldeten passiert, je Zeitpunkt (JJJJ-MM-TTTHH:MM in timezone):
# davor -> craftattack-server
# ab registration-required-from -> login-server
# ab open-for-all-from -> craftattack-server
# ab late-signup-from -> login-server, wenn sie in der nachträglichen Anmeldung
# stehen (late-signup-path), sonst craftattack-server
signup.registration-required-from=2026-12-01T00:00
signup.open-for-all-from=2026-12-26T14:00
signup.late-signup-from=2026-12-29T14:00
signup.timezone=Europe/Berlin
# Website wie api.url und api.secret im Login-Server (mit oder ohne /api am Ende), Secret wie
# API_SECRET der Website. Angemeldet: GET <website>/api/users/<uuid>.
# Nachträgliche Anmeldung: GET <website>/api/<late-signup-path> gibt ein JSON-Objekt mit UUID-Liste
# zurück, z. B. {"uuid":["069a79f4-44e9-4726-a5be-fca90e38aaf5"]}.
# Antwortet die Website nicht, kommt der Spieler auf login-server, wenn er dort hin könnte.
signup.api-url=
signup.api-secret=
signup.late-signup-path=whitelist
signup.timeout-seconds=5
# Spieler auf login-server werden so oft (Sekunden) neu geprüft. Wer sich angemeldet hat, wird vor
# late-signup-from gekickt, danach auf craftattack-server geschickt. 0 = aus.
# Wer vom login-server gekickt wird, fliegt auch vom Proxy.
signup.recheck-seconds=10
@@ -1,33 +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("Arena", config.serverTags().get("arena"));
assertEquals(Set.of("event", "grand-event", "worldmuseum"), config.unsilencedServers());
assertEquals("adminmarker", config.markerPermission());
}
@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)));
}
}
@@ -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)));
}
}