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>
This commit is contained in:
MineTecandClaude Opus 5.5 committed 2026-11-07 08:56:15 +01:00
1 parent 6d383eaca1
commit ad532c8b99
26 files changed
+1052 -595

No files matched your search

+65 -9
View File
@@ -1,7 +1,46 @@
# chat-bridge # proxy
Velocity-Plugin für CraftAttack: Chat und Tabliste gelten 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` |
## 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,
@@ -21,9 +60,9 @@ verteilt sie selbst.
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): - **Einstellungen** in `/settings` auf spawn (Join/Leave anzeigen, Erwähnungen hervorheben, Erwähnungston):
Velocity speichert sie in `plugins/chat-bridge/settings.properties` und gibt sie beim Join an spawn Velocity speichert sie in `plugins/proxy/chat/settings.properties` und gibt sie beim Join an spawn
zurück, damit die Arena dieselben Werte zeigt. Unbekannte Spieler übernimmt Velocity nur vom zurück, damit die Arena dieselben Werte zeigt. Unbekannte Spieler übernimmt Velocity nur vom
`settings-server` (craftattack), damit die Defaults der Arena keine echten Werte verdrängen. `chat.settings-server` (craftattack), damit die Defaults der Arena keine echten Werte verdrängen.
- **Tabliste:** Spieler anderer Server stehen unten, mit Symbol, grau und als Spectator (kursiv). - **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 Die Einträge vom eigenen Server schickt weiter das Backend (Skins, Spielmodus, Prefixe). Abgleich
alle 2 s und kurz nach Join, Wechsel und Quit. alle 2 s und kurz nach Join, Wechsel und Quit.
@@ -37,11 +76,10 @@ Silencer auf den Backends:
| limbo | EndlessJumper | | limbo | EndlessJumper |
| event | Minigames (ab `develop` 8dc1659), `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
@@ -49,5 +87,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.2.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,76 +0,0 @@
package eu.mhsl.craftattack.chatbridge;
import com.google.inject.Inject;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Dependency;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.messages.ChannelIdentifier;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import net.luckperms.api.LuckPermsProvider;
import org.slf4j.Logger;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
@Plugin(
id = "chat-bridge",
name = "chat-bridge",
version = "1.2.0",
description = "Serverübergreifender Chat und Tabliste: öffentlicher Chat, /msg, Adminchat, Mute, Join/Leave",
authors = {"MHSL"},
dependencies = {@Dependency(id = "luckperms")}
)
public final class ChatBridge {
/** Kanal zu spawn (Appliance NetworkChat): Rang-Prefixe und Chat-Einstellungen, Format siehe dort. */
static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.create("craftattack", "chat");
/** Abgleich der Tablisten; nach Join, Wechsel und Quit zusätzlich kurz danach. */
static final long TAB_REFRESH_SECONDS = 2;
private final ProxyServer proxy;
private final Logger logger;
private final Path dataDirectory;
@Inject
public ChatBridge(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) {
this.proxy = proxy;
this.logger = logger;
this.dataDirectory = dataDirectory;
}
@Subscribe
public void onProxyInitialize(ProxyInitializeEvent event) {
Config config;
try {
config = Config.load(this.dataDirectory);
} catch (Exception e) {
this.logger.error("Konfiguration ungültig, chat-bridge ist aus", e);
return;
}
ChatSettings settings;
try {
settings = new ChatSettings(this.dataDirectory, this.logger);
} catch (Exception e) {
this.logger.error("settings.properties nicht lesbar, chat-bridge ist aus", e);
return;
}
Names names = new Names(config);
Mutes mutes = new Mutes(LuckPermsProvider.get());
Chat chat = new Chat(this.proxy, this.logger, config, names, mutes, settings);
PrivateMessages privateMessages = new PrivateMessages(this.proxy, this.logger, config, names, chat);
TabLists tabLists = new TabLists(this.proxy, config, names);
this.proxy.getChannelRegistrar().register(CHANNEL);
this.proxy.getEventManager().register(this, new ChatListener(this, this.proxy, this.logger, config, names, chat, privateMessages, settings, tabLists));
this.proxy.getScheduler().buildTask(this, tabLists::refresh).repeat(TAB_REFRESH_SECONDS, TimeUnit.SECONDS).schedule();
Commands.register(this, this.proxy, this.logger, config, names, chat, privateMessages, mutes);
this.logger.info("chat-bridge aktiv, Symbole {}, ohne Silencer {}", config.serverSymbols(), config.unsilencedServers());
}
}
@@ -1,204 +0,0 @@
package eu.mhsl.craftattack.chatbridge;
import com.velocitypowered.api.command.CommandManager;
import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
/** Befehle auf dem Proxy; sie greifen vor dem Backend und funktionieren damit auf jedem Server. */
final class Commands {
private Commands() {
}
static void register(Object plugin, ProxyServer proxy, Logger logger, Config config, Names names, Chat chat,
PrivateMessages privateMessages, Mutes mutes) {
CommandManager manager = proxy.getCommandManager();
// tell/w/whisper überdecken die Vanilla-Befehle, die am Mute vorbei flüstern würden
manager.register(manager.metaBuilder("msg").aliases("tell", "w", "whisper").plugin(plugin).build(),
new PlayerCommand() {
@Override
void run(Player sender, String[] args) {
if (args.length < 2) {
PrivateMessages.error(sender, "Es muss ein Spieler sowie eine Nachricht angegeben werden.");
return;
}
privateMessages.message(sender, args[0], String.join(" ", Arrays.copyOfRange(args, 1, args.length)));
}
@Override
public List<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) {
reportError(invocation, logger, args[0], error);
return;
}
invocation.source().sendMessage(Component.text(args[0] + " wurde für " + hours + " Stunden gestummt!", NamedTextColor.GREEN));
logger.info("[mute] {} stummt {} für {} h", sourceName(invocation), args[0], hours);
});
}
@Override
public List<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((wasMuted, error) -> {
if (error != null) {
reportError(invocation, logger, args[0], error);
return;
}
if (!wasMuted) {
invocation.source().sendMessage(Component.text(args[0] + " war nicht gestummt.", NamedTextColor.GRAY));
return;
}
invocation.source().sendMessage(Component.text(args[0] + " ist nicht mehr gestummt.", NamedTextColor.GREEN));
logger.info("[mute] {} hebt Mute von {} auf", sourceName(invocation), args[0]);
});
}
@Override
public List<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());
}
});
}
private static void reportError(SimpleCommand.Invocation invocation, Logger logger, String name, Throwable error) {
Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
if (cause instanceof UnknownPlayerException) {
invocation.source().sendMessage(Component.text("Der Spieler " + name + " ist unbekannt.", NamedTextColor.RED));
return;
}
invocation.source().sendMessage(Component.text("Konnte nicht gespeichert werden, siehe Velocity-Log.", NamedTextColor.RED));
logger.error("[mute] Fehler für {}", name, cause);
}
private static final class UnknownPlayerException extends RuntimeException {
UnknownPlayerException(String name) {
super("unbekannt: " + name);
}
}
/** Online zuerst über den Proxy, sonst über LuckPerms (Spieler, die schon mal da waren). */
private static CompletableFuture<UUID> resolve(ProxyServer proxy, Mutes mutes, String name) {
return proxy.getPlayer(name)
.map(player -> CompletableFuture.completedFuture(player.getUniqueId()))
.orElseGet(() -> mutes.lookup(name).thenApply(uuid -> {
if (uuid == null) throw new UnknownPlayerException(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,73 +0,0 @@
package eu.mhsl.craftattack.chatbridge;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Einstellungen aus {@code plugins/chat-bridge/config.properties}. Fehlt die Datei, wird die
* kommentierte Vorlage aus dem JAR geschrieben.
*/
record Config(
Map<String, String> serverSymbols,
Map<String, String> serverWhere,
Set<String> unsilencedServers,
String settingsServer,
String adminPermission,
String markerPermission
) {
static Config load(Path dataDirectory) throws IOException {
Path file = dataDirectory.resolve("config.properties");
if (Files.notExists(file)) {
Files.createDirectories(dataDirectory);
try (InputStream template = Config.class.getResourceAsStream("/config.properties")) {
if (template == null) throw new IOException("config.properties fehlt im JAR");
Files.copy(template, file);
}
}
Properties properties = new Properties();
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
properties.load(reader);
}
return fromProperties(properties);
}
static Config fromProperties(Properties p) {
return new Config(
pairs(p.getProperty("server-symbols", "")),
pairs(p.getProperty("server-where", "")),
Set.copyOf(split(p.getProperty("unsilenced-servers", ""))),
p.getProperty("settings-server", "craftattack").trim(),
p.getProperty("admin-permission", "admin").trim(),
p.getProperty("marker-permission", "adminmarker").trim()
);
}
private static Map<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("'" + 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,84 +0,0 @@
package eu.mhsl.craftattack.chatbridge;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.model.user.User;
import net.luckperms.api.node.Node;
import net.luckperms.api.node.NodeType;
import net.luckperms.api.node.types.PermissionNode;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
* Mute als zeitlich begrenzte LuckPerms-Permission {@value #NODE}. Liegt damit in der gemeinsamen
* MariaDB, gilt auf allen Servern und läuft von selbst ab.
*/
final class Mutes {
static final String NODE = "chatbridge.muted";
private final LuckPerms luckPerms;
Mutes(LuckPerms luckPerms) {
this.luckPerms = luckPerms;
}
/** Ende des Mutes eines online Spielers, leer wenn nicht gestummt. */
Optional<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());
});
}
/** true, wenn ein (noch laufender) Mute entfernt wurde. */
CompletableFuture<Boolean> unmute(UUID player) {
AtomicBoolean wasMuted = new AtomicBoolean();
return this.modify(player, user -> {
wasMuted.set(user.getNodes(NodeType.PERMISSION).stream()
.anyMatch(node -> node.getPermission().equals(NODE) && !node.hasExpired()));
user.data().clear(node -> node.getKey().equals(NODE));
}).thenApply(ignored -> wasMuted.get());
}
CompletableFuture<UUID> lookup(String name) {
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")
);
}
}
@@ -1,4 +1,4 @@
package eu.mhsl.craftattack.chatbridge; package eu.mhsl.craftattack.proxy.appliances.chat;
import com.google.common.io.ByteArrayDataInput; import com.google.common.io.ByteArrayDataInput;
import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteArrayDataOutput;
@@ -9,7 +9,6 @@ import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.event.player.PlayerChatEvent; import com.velocitypowered.api.event.player.PlayerChatEvent;
import com.velocitypowered.api.event.player.ServerPostConnectEvent; import com.velocitypowered.api.event.player.ServerPostConnectEvent;
import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.ServerConnection;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -17,35 +16,30 @@ import org.slf4j.Logger;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit;
final class ChatListener { final class ChatListener {
/** Join-Meldung erst nach dieser Zeit, damit das Backend die Rang-Prefixe schon geschickt hat. */ /** Join-Meldung erst nach dieser Zeit, damit das Backend die Rang-Prefixe schon geschickt hat. */
private static final long JOIN_DELAY_MS = 1000; 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 Chat chat;
private final Logger logger;
private final ChatConfig config;
private final Names names;
private final PublicChat publicChat;
private final PrivateMessages privateMessages; private final PrivateMessages privateMessages;
private final ChatSettings settings; private final ChatSettings settings;
private final Config config;
private final TabLists tabLists;
/** Wer eine Join-Meldung bekommen hat, bekommt beim Verlassen auch eine. */ /** Wer eine Join-Meldung bekommen hat, bekommt beim Verlassen auch eine. */
private final Set<UUID> announced = new HashSet<>(); private final Set<UUID> announced = new HashSet<>();
ChatListener(Object plugin, ProxyServer proxy, Logger logger, Config config, Names names, Chat chat, ChatListener(Chat chat, Logger logger, ChatConfig config, Names names, PublicChat publicChat, PrivateMessages privateMessages,
PrivateMessages privateMessages, ChatSettings settings, TabLists tabLists) { ChatSettings settings) {
this.plugin = plugin;
this.proxy = proxy;
this.logger = logger;
this.names = names;
this.chat = chat; this.chat = chat;
this.logger = logger;
this.config = config;
this.names = names;
this.publicChat = publicChat;
this.privateMessages = privateMessages; this.privateMessages = privateMessages;
this.settings = settings; this.settings = settings;
this.config = config;
this.tabLists = tabLists;
} }
/** /**
@@ -54,12 +48,12 @@ final class ChatListener {
*/ */
@Subscribe @Subscribe
public void onChat(PlayerChatEvent event) { public void onChat(PlayerChatEvent event) {
this.chat.handle(event.getPlayer(), event.getMessage()); this.publicChat.handle(event.getPlayer(), event.getMessage());
} }
@Subscribe @Subscribe
public void onPluginMessage(PluginMessageEvent event) { public void onPluginMessage(PluginMessageEvent event) {
if (!event.getIdentifier().equals(ChatBridge.CHANNEL)) return; if (!event.getIdentifier().equals(Chat.CHANNEL)) return;
event.setResult(PluginMessageEvent.ForwardResult.handled()); event.setResult(PluginMessageEvent.ForwardResult.handled());
// Nur Backends dürfen Prefixe setzen, ein Client könnte sich sonst einen eigenen geben // Nur Backends dürfen Prefixe setzen, ein Client könnte sich sonst einen eigenen geben
if (!(event.getSource() instanceof ServerConnection connection)) return; if (!(event.getSource() instanceof ServerConnection connection)) return;
@@ -107,21 +101,19 @@ final class ChatListener {
out.writeBoolean(values.joinAndLeave()); out.writeBoolean(values.joinAndLeave());
out.writeBoolean(values.highlightMentions()); out.writeBoolean(values.highlightMentions());
out.writeBoolean(values.mentionSound()); out.writeBoolean(values.mentionSound());
connection.sendPluginMessage(ChatBridge.CHANNEL, out.toByteArray()); connection.sendPluginMessage(Chat.CHANNEL, out.toByteArray());
} }
@Subscribe @Subscribe
public void onServerPostConnect(ServerPostConnectEvent event) { public void onServerPostConnect(ServerPostConnectEvent event) {
Player player = event.getPlayer();
this.tabLists.reset(player.getUniqueId());
this.refreshTabListsSoon();
if (event.getPreviousServer() != null) return; if (event.getPreviousServer() != null) return;
this.proxy.getScheduler().buildTask(this.plugin, () -> { Player player = event.getPlayer();
this.chat.later(() -> {
synchronized (this.announced) { synchronized (this.announced) {
if (!player.isActive() || !this.announced.add(player.getUniqueId())) return; if (!player.isActive() || !this.announced.add(player.getUniqueId())) return;
} }
this.chat.announceJoin(player); this.publicChat.announceJoin(player);
}).delay(JOIN_DELAY_MS, TimeUnit.MILLISECONDS).schedule(); }, JOIN_DELAY_MS);
} }
@Subscribe @Subscribe
@@ -131,13 +123,8 @@ final class ChatListener {
synchronized (this.announced) { synchronized (this.announced) {
wasAnnounced = this.announced.remove(player.getUniqueId()); wasAnnounced = this.announced.remove(player.getUniqueId());
} }
if (wasAnnounced) this.chat.announceLeave(player); if (wasAnnounced) this.publicChat.announceLeave(player);
this.names.forget(player.getUniqueId()); this.names.forget(player.getUniqueId());
this.privateMessages.forget(player.getUniqueId()); this.privateMessages.forget(player.getUniqueId());
this.refreshTabListsSoon();
}
private void refreshTabListsSoon() {
this.proxy.getScheduler().buildTask(this.plugin, this.tabLists::refresh).delay(500, TimeUnit.MILLISECONDS).schedule();
} }
} }
@@ -1,4 +1,4 @@
package eu.mhsl.craftattack.chatbridge; package eu.mhsl.craftattack.proxy.appliances.chat;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -17,7 +17,7 @@ import java.util.concurrent.ConcurrentHashMap;
/** /**
* Chat-Einstellungen der Spieler, Quelle der Wahrheit für das ganze Netzwerk. Geändert werden sie in * 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 * {@code /settings} auf einem spawn-Server, der sie herschickt; beim Join bekommt spawn die
* gespeicherten Werte zurück. Gespeichert in {@code plugins/chat-bridge/settings.properties}. * gespeicherten Werte zurück. Gespeichert in {@code plugins/proxy/chat/settings.properties}.
*/ */
final class ChatSettings { final class ChatSettings {
record Values(boolean joinAndLeave, boolean highlightMentions, boolean mentionSound) { record Values(boolean joinAndLeave, boolean highlightMentions, boolean mentionSound) {
@@ -76,7 +76,7 @@ final class ChatSettings {
try { try {
Files.createDirectories(this.file.getParent()); Files.createDirectories(this.file.getParent());
try (Writer writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8)) { try (Writer writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8)) {
properties.store(writer, "chat-bridge: UUID=Join/Leave,Erwähnungen hervorheben,Erwähnungston"); properties.store(writer, "chat: UUID=Join/Leave,Erwähnungen hervorheben,Erwähnungston");
} }
Files.move(temporary, this.file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); Files.move(temporary, this.file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) { } catch (IOException 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,16 +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); return this.plain(player, NamedTextColor.WHITE);
} }
/** Name ohne Klick; {@code color} gilt für Nicht-Admins. */ /** Name ohne Klick; {@code color} gilt für Nicht-Admins. */
Component plain(Player player, TextColor color) { public Component plain(Player player, TextColor color) {
TextComponent.Builder name = Component.text(); 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());
} }
@@ -70,7 +72,7 @@ final class Names {
} }
/** 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() + " "));
@@ -80,12 +82,12 @@ final class Names {
* Symbol des Servers, auf dem {@code player} ist, samt Leerzeichen; leer, wenn der Betrachter auf * Symbol des Servers, auf dem {@code player} ist, samt Leerzeichen; leer, wenn der Betrachter auf
* demselben Server ist oder der Server kein Symbol hat. * demselben Server ist oder der Server kein Symbol hat.
*/ */
Component marker(Player player, @Nullable String viewerServer) { public Component marker(Player player, @Nullable String viewerServer) {
String server = player.getCurrentServer().map(ServerConnection::getServerInfo).map(info -> info.getName()).orElse(null); String server = Servers.of(player);
if (server == null || server.equals(viewerServer)) return Component.empty(); if (server == null || server.equals(viewerServer)) return Component.empty();
String symbol = this.config.serverSymbols().get(server); String symbol = this.servers.symbol(server);
if (symbol == null) return Component.empty(); if (symbol == null) return Component.empty();
String where = this.config.serverWhere().getOrDefault(server, "auf " + server); String where = this.servers.where(server);
return Component.text() return Component.text()
.append(Component.text(symbol, NamedTextColor.DARK_GRAY) .append(Component.text(symbol, NamedTextColor.DARK_GRAY)
.hoverEvent(HoverEvent.showText(Component.text(player.getUsername() + " ist gerade " + where, NamedTextColor.GRAY)))) .hoverEvent(HoverEvent.showText(Component.text(player.getUsername() + " ist gerade " + where, NamedTextColor.GRAY))))
@@ -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) {
@@ -130,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;
} }
@@ -149,13 +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, Chat.serverOf(sender))) .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, Chat.serverOf(receiver))) .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,23 +33,23 @@ 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; private final ChatSettings settings;
Chat(ProxyServer proxy, Logger logger, Config config, Names names, Mutes mutes, ChatSettings settings) { PublicChat(ProxyServer proxy, Logger logger, ChatConfig config, Names names, Mute mute, ChatSettings settings) {
this.proxy = proxy; this.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; 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;
} }
@@ -63,7 +60,7 @@ final class Chat {
Component name = this.names.clickable(sender); Component name = this.names.clickable(sender);
for (Player player : this.proxy.getAllPlayers()) { for (Player player : this.proxy.getAllPlayers()) {
String viewerServer = serverOf(player); String viewerServer = Servers.of(player);
if (server != null && this.config.unsilencedServers().contains(server) && server.equals(viewerServer)) continue; if (server != null && this.config.unsilencedServers().contains(server) && server.equals(viewerServer)) continue;
ChatSettings.Values values = this.settings.of(player.getUniqueId()); ChatSettings.Values values = this.settings.of(player.getUniqueId());
player.sendMessage(Component.text() player.sendMessage(Component.text()
@@ -89,17 +86,7 @@ final class Chat {
Component name = this.names.clickable(subject); Component name = this.names.clickable(subject);
this.proxy.getAllPlayers().stream() this.proxy.getAllPlayers().stream()
.filter(player -> this.settings.of(player.getUniqueId()).joinAndLeave()) .filter(player -> this.settings.of(player.getUniqueId()).joinAndLeave())
.forEach(player -> player.sendMessage(arrow.append(this.names.marker(subject, serverOf(player))).append(name))); .forEach(player -> player.sendMessage(arrow.append(this.names.marker(subject, Servers.of(player))).append(name)));
}
/** Meldet einem gestummten Spieler die Restzeit; true, wenn er gestummt ist. */
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;
} }
/** 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,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);
}
}
}
@@ -1,22 +1,31 @@
package eu.mhsl.craftattack.chatbridge; package eu.mhsl.craftattack.proxy.appliances.tablist;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.player.ServerPostConnectEvent;
import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.player.TabList; import com.velocitypowered.api.proxy.player.TabList;
import com.velocitypowered.api.proxy.player.TabListEntry; 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.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/** /**
* Ergänzt die Tabliste jedes Spielers um die Spieler auf anderen Servern (grau, kursiv, unten, mit * Ergänzt die Tabliste jedes Spielers um die Spieler auf anderen Servern (grau, kursiv, unten, mit
* Server-Symbol). * Server-Symbol). Braucht die Appliance {@link Chat} für Namen und Rang-Prefixe.
* Die Einträge der Spieler auf dem eigenen Server schickt weiter das Backend (Skins, Spielmodus, * Die Einträge der Spieler auf dem eigenen Server schickt weiter das Backend (Skins, Spielmodus,
* Prefixe); die werden hier nie angefasst. * Prefixe); die werden hier nie angefasst.
* <p> * <p>
@@ -24,34 +33,68 @@ import java.util.concurrent.ConcurrentHashMap;
* in unbestimmter Reihenfolge auf, {@link #refresh()} läuft deshalb regelmäßig und stellt den * in unbestimmter Reihenfolge auf, {@link #refresh()} läuft deshalb regelmäßig und stellt den
* Sollzustand her. * Sollzustand her.
*/ */
final class TabLists { public final class NetworkTabList extends Appliance {
/** Vanilla sortiert höhere Werte nach oben; Backends lassen 0 stehen. */ /** Vanilla sortiert höhere Werte nach oben; Backends lassen 0 stehen. */
private static final int REMOTE_ORDER = -1000; private static final int REMOTE_ORDER = -1000;
/** Der Client zeigt Spectator kursiv und blass; ohne Welt hat der Modus sonst keine Wirkung. */ /** Der Client zeigt Spectator kursiv und blass; ohne Welt hat der Modus sonst keine Wirkung. */
private static final int SPECTATOR = 3; private static final int SPECTATOR = 3;
/** Nach Join, Wechsel und Quit zusätzlich so kurz danach. */
private static final long SOON_MS = 500;
private final ProxyServer proxy; private Names names;
private final Config config; private ScheduledTask refreshTask;
private final Names names;
/** Je Betrachter die Einträge, die von hier stammen. */ /** Je Betrachter die Einträge, die von hier stammen. */
private final Map<UUID, Set<UUID>> added = new ConcurrentHashMap<>(); private final Map<UUID, Set<UUID>> added = new ConcurrentHashMap<>();
TabLists(ProxyServer proxy, Config config, Names names) { @Override
this.proxy = proxy; public String name() {
this.config = config; return "tablist";
this.names = names; }
@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() { synchronized void refresh() {
for (Player viewer : this.proxy.getAllPlayers()) { for (Player viewer : this.proxy().getAllPlayers()) {
String viewerServer = Chat.serverOf(viewer); String viewerServer = Servers.of(viewer);
if (viewerServer == null) continue; if (viewerServer == null) continue;
TabList tabList = viewer.getTabList(); TabList tabList = viewer.getTabList();
Set<UUID> ours = this.added.computeIfAbsent(viewer.getUniqueId(), uuid -> new HashSet<>()); Set<UUID> ours = this.added.computeIfAbsent(viewer.getUniqueId(), uuid -> new HashSet<>());
Set<UUID> wanted = new HashSet<>(); Set<UUID> wanted = new HashSet<>();
for (Player other : this.proxy.getAllPlayers()) { for (Player other : this.proxy().getAllPlayers()) {
String otherServer = Chat.serverOf(other); String otherServer = Servers.of(other);
if (other.equals(viewer) || otherServer == null || otherServer.equals(viewerServer)) continue; if (other.equals(viewer) || otherServer == null || otherServer.equals(viewerServer)) continue;
wanted.add(other.getUniqueId()); wanted.add(other.getUniqueId());
@@ -81,15 +124,15 @@ final class TabLists {
ours.removeIf(uuid -> { ours.removeIf(uuid -> {
if (wanted.contains(uuid)) return false; if (wanted.contains(uuid)) return false;
// Jetzt auf demselben Server: Der Eintrag gehört dem Backend, nicht entfernen // Jetzt auf demselben Server: Der Eintrag gehört dem Backend, nicht entfernen
boolean sameServer = this.proxy.getPlayer(uuid) boolean sameServer = this.proxy().getPlayer(uuid)
.map(Chat::serverOf) .map(Servers::of)
.map(viewerServer::equals) .map(viewerServer::equals)
.orElse(false); .orElse(false);
if (!sameServer) tabList.removeEntry(uuid); if (!sameServer) tabList.removeEntry(uuid);
return true; return true;
}); });
} }
this.added.keySet().removeIf(uuid -> this.proxy.getPlayer(uuid).isEmpty()); this.added.keySet().removeIf(uuid -> this.proxy().getPlayer(uuid).isEmpty());
} }
/** Nach einem Serverwechsel hat der Client eine frische Liste, eigene Einträge neu anlegen. */ /** Nach einem Serverwechsel hat der Client eine frische Liste, eigene Einträge neu anlegen. */
@@ -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,138 @@
package eu.mhsl.craftattack.proxy.core;
import com.google.inject.Inject;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
import com.velocitypowered.api.plugin.Dependency;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import eu.mhsl.craftattack.proxy.appliances.chat.Chat;
import eu.mhsl.craftattack.proxy.appliances.mute.Mute;
import eu.mhsl.craftattack.proxy.appliances.tablist.NetworkTabList;
import eu.mhsl.craftattack.proxy.core.appliance.Appliance;
import net.luckperms.api.LuckPerms;
import net.luckperms.api.LuckPermsProvider;
import org.slf4j.Logger;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
/**
* Velocity-Plugin des CraftAttack-Netzwerks. Wie spawn auf den Backends besteht es aus Appliances:
* jede ist ein kleines Teil-Plugin mit eigenen Listenern, Befehlen und eigenem Config-Abschnitt.
*/
@Plugin(
id = "proxy",
name = "proxy",
version = "2.0.0",
description = "CraftAttack-Netzwerk auf Velocity: Chat, Tabliste, Mute und weitere Appliances",
authors = {"MHSL"},
dependencies = {@Dependency(id = "luckperms")}
)
public final class Main {
/**
* Alle Appliances, in Startreihenfolge: Eine Appliance kann in {@code onEnable} nur die davor
* abfragen. Neue Appliances hier eintragen.
*/
private static final List<Supplier<Appliance>> APPLIANCES = List.of(
Mute::new,
Chat::new,
NetworkTabList::new
);
private final ProxyServer proxy;
private final Logger logger;
private final Path dataDirectory;
private Config config;
private Servers servers;
private LuckPerms luckPerms;
private final List<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";
}
}
+20 -6
View File
@@ -1,5 +1,12 @@
# 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. …).
# Appliances, die nicht geladen werden (mute, chat, tablist). chat braucht mute, tablist braucht chat.
disabled-appliances=
# Adminchat, /mute und /unmute (LuckPerms auf Velocity)
admin-permission=admin
# Symbol vor Spielern, die auf einem anderen Server sind als man selbst (Chat, /msg, Join/Leave, # 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 # Tabliste), dunkelgrau, im Hover „<Name> ist gerade <server-where>“. Bewusst einfache Zeichen ohne
@@ -7,16 +14,23 @@
server-symbols=craftattack=⛏|arena=◎|limbo=⌛|event=⚄|grand-event=⚄|worldmuseum=♜ 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 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=worldmuseum chat.unsilenced-servers=worldmuseum
# Chat-Einstellungen (/settings in spawn) kennt Velocity zuerst von diesem Server; andere spawn-Server # 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. # (Arena) haben eigene Spielerdaten und übernehmen dann die Werte von Velocity.
settings-server=craftattack chat.settings-server=craftattack
# Adminchat, /mute und /unmute (LuckPerms auf Velocity)
admin-permission=admin
# Wer diese Permission hat, erscheint blau (invis_admin und technical haben sie auf false) # 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
@@ -1,35 +0,0 @@
package eu.mhsl.craftattack.chatbridge;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Properties;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ConfigTest {
@Test
void templateLoads() throws Exception {
Properties properties = new Properties();
try (InputStream in = ConfigTest.class.getResourceAsStream("/config.properties")) {
properties.load(new InputStreamReader(in, StandardCharsets.UTF_8));
}
Config config = Config.fromProperties(properties);
assertEquals("◎", config.serverSymbols().get("arena"));
assertEquals("im Warteraum", config.serverWhere().get("limbo"));
assertEquals(Set.of("worldmuseum"), config.unsilencedServers());
assertEquals("adminmarker", config.markerPermission());
assertEquals("craftattack", config.settingsServer());
}
@Test
void formatsMuteDuration() {
assertEquals("weniger als eine Minute", Mutes.format(Duration.ofSeconds(30)));
assertEquals("1 Stunde 5 Minuten", Mutes.format(Duration.ofMinutes(65)));
assertEquals("48 Stunden", Mutes.format(Duration.ofHours(48)));
}
}
@@ -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)));
}
}