Warteschlange im Limbo als eigenes Velocity-Plugin

This commit is contained in:
MineTec committed 2026-10-17 20:54:44 +02:00
commit 7111e382a5
17 files changed
+1244

No files matched your search

@@ -0,0 +1,80 @@
package eu.mhsl.craftattack.limboqueue;
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.List;
import java.util.Properties;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Einstellungen aus {@code plugins/limbo-queue/config.properties}. Fehlt die Datei, wird die
* kommentierte Vorlage aus dem JAR geschrieben.
*/
record Config(
String limbo,
String target,
String targetDisplayName,
long intervalMs,
int perTick,
int maxInFlight,
List<Long> backoffSeconds,
String priorityPermission,
Set<String> temporaryTitles,
Set<String> temporaryKeys
) {
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) {
Config config = new Config(
p.getProperty("limbo", "limbo").trim(),
p.getProperty("target", "craftattack").trim(),
p.getProperty("target-display-name", "CraftAttack").trim(),
Long.parseLong(p.getProperty("interval-ms", "1000").trim()),
Integer.parseInt(p.getProperty("per-tick", "1").trim()),
Integer.parseInt(p.getProperty("max-in-flight", "3").trim()),
split(p.getProperty("backoff-seconds", "5|10|30")).stream().map(Long::parseLong).toList(),
p.getProperty("priority-permission", "limboqueue.priority").trim(),
Set.copyOf(split(p.getProperty("temporary-titles", ""))),
Set.copyOf(split(p.getProperty("temporary-keys", "")))
);
if (config.intervalMs() < 100) throw new IllegalArgumentException("interval-ms muss mindestens 100 sein");
if (config.perTick() < 1) throw new IllegalArgumentException("per-tick muss mindestens 1 sein");
if (config.maxInFlight() < 1) throw new IllegalArgumentException("max-in-flight muss mindestens 1 sein");
if (config.backoffSeconds().isEmpty()) throw new IllegalArgumentException("backoff-seconds ist leer");
return config;
}
/** Wartezeit nach dem n-ten Fehlschlag (ab 1); danach gilt der letzte Wert. */
long backoffMillis(int failures) {
int index = Math.min(Math.max(failures, 1), this.backoffSeconds.size()) - 1;
return this.backoffSeconds.get(index) * 1000;
}
private static List<String> split(String value) {
return Arrays.stream(value.split("\\|"))
.map(String::trim)
.filter(entry -> !entry.isEmpty())
.collect(Collectors.toList());
}
}
@@ -0,0 +1,179 @@
package eu.mhsl.craftattack.limboqueue;
import com.velocitypowered.api.proxy.ConnectionRequestBuilder;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Läuft alle {@code interval-ms}: prüft per Ping, ob das Ziel erreichbar ist, verbindet die
* fälligen Spieler und aktualisiert die Bossbars im Limbo. Verbindet still über
* {@code connect()}: Velocity schreibt dabei nichts in den Chat.
*/
final class Dispatcher {
private final ProxyServer proxy;
private final Logger logger;
private final Config config;
private final WaitingQueue queue;
private final KickClassifier classifier;
private final RegisteredServer limbo;
private final RegisteredServer target;
private final Set<UUID> connecting = ConcurrentHashMap.newKeySet();
private final Map<UUID, BossBar> bars = new ConcurrentHashMap<>();
private final AtomicBoolean pinging = new AtomicBoolean();
private volatile long reachableUntil;
Dispatcher(ProxyServer proxy, Logger logger, Config config, WaitingQueue queue, KickClassifier classifier,
RegisteredServer limbo, RegisteredServer target) {
this.proxy = proxy;
this.logger = logger;
this.config = config;
this.queue = queue;
this.classifier = classifier;
this.limbo = limbo;
this.target = target;
}
/** Ob diese Verbindung zum Ziel von uns stammt (dann nicht umleiten). */
boolean isConnecting(UUID id) {
return this.connecting.contains(id);
}
void tick() {
if (this.queue.isEmpty()) return;
this.pingTarget();
long now = System.currentTimeMillis();
if (this.isTargetReachable(now)) {
for (UUID id : this.queue.takeDue(now, this.config.perTick(), this.config.maxInFlight())) {
this.connect(id, now);
}
}
this.updateBars(now);
}
/** Spieler verlässt die Warteschlange (verbunden, getrennt oder endgültig abgelehnt). */
void leave(UUID id) {
this.queue.remove(id);
BossBar bar = this.bars.remove(id);
if (bar != null) this.proxy.getPlayer(id).ifPresent(player -> player.hideBossBar(bar));
}
private void pingTarget() {
if (!this.pinging.compareAndSet(false, true)) return;
this.target.ping().whenComplete((ping, error) -> {
if (error == null) this.reachableUntil = System.currentTimeMillis() + 3 * this.config.intervalMs();
else this.reachableUntil = 0;
this.pinging.set(false);
});
}
private boolean isTargetReachable(long now) {
return now <= this.reachableUntil;
}
private void connect(UUID id, long now) {
Player player = this.proxy.getPlayer(id).orElse(null);
if (player == null || !player.isActive()) {
this.leave(id);
return;
}
if (!this.isOn(player, this.limbo)) {
// schon am Ziel oder gerade im Wechsel; im zweiten Fall später erneut
if (this.isOn(player, this.target)) this.leave(id);
else this.queue.failed(id, now, this.config::backoffMillis, null);
return;
}
this.connecting.add(id);
player.createConnectionRequest(this.target).connect().whenComplete((result, error) -> {
this.connecting.remove(id);
long finishedAt = System.currentTimeMillis();
if (error != null) {
this.queue.failed(id, finishedAt, this.config::backoffMillis, null);
return;
}
this.handleResult(player, result, finishedAt);
});
}
private void handleResult(Player player, ConnectionRequestBuilder.Result result, long now) {
UUID id = player.getUniqueId();
switch (result.getStatus()) {
case SUCCESS, ALREADY_CONNECTED -> this.leave(id);
case SERVER_DISCONNECTED -> {
Optional<Component> reason = result.getReasonComponent();
if (this.classifier.classify(reason) == KickClassifier.Kind.FINAL) {
this.leave(id);
this.logger.info("{} von {} endgültig abgelehnt ({}), wird getrennt",
player.getUsername(), this.target.getServerInfo().getName(),
KickClassifier.title(reason.orElseThrow()));
player.disconnect(reason.orElseThrow());
} else {
String title = reason.map(KickClassifier::title).filter(text -> !text.isEmpty()).orElse(null);
this.queue.failed(id, now, this.config::backoffMillis, title);
}
}
default -> this.queue.failed(id, now, this.config::backoffMillis, null);
}
}
private void updateBars(long now) {
boolean reachable = this.isTargetReachable(now);
for (UUID id : this.queue.ids()) {
Player player = this.proxy.getPlayer(id).orElse(null);
WaitingQueue.Snapshot snapshot = this.queue.snapshot(id, now);
if (player == null || snapshot == null) continue;
if (!this.isOn(player, this.limbo)) {
BossBar bar = this.bars.remove(id);
if (bar != null) player.hideBossBar(bar);
continue;
}
String name = this.config.targetDisplayName();
String place = "Platz " + snapshot.position() + " von " + snapshot.size();
Component text;
BossBar.Color color = BossBar.Color.YELLOW;
if (snapshot.state() == WaitingQueue.State.CONNECTING) {
text = Component.text("Du wirst mit " + name + " verbunden ...", NamedTextColor.GREEN);
color = BossBar.Color.GREEN;
} else if (!reachable) {
text = Component.text(name + " ist gerade nicht erreichbar · " + place, NamedTextColor.RED);
color = BossBar.Color.RED;
} else if (snapshot.state() == WaitingQueue.State.PAUSED && snapshot.lastReason() != null) {
text = Component.text(snapshot.lastReason() + " · " + place, NamedTextColor.RED);
color = BossBar.Color.RED;
} else {
text = Component.text("Warteschlange für " + name + " · " + place, NamedTextColor.YELLOW);
}
float progress = snapshot.size() <= 1 ? 1f : 1f - (float) (snapshot.position() - 1) / snapshot.size();
BossBar bar = this.bars.get(id);
if (bar == null) {
bar = BossBar.bossBar(text, progress, color, BossBar.Overlay.PROGRESS);
this.bars.put(id, bar);
player.showBossBar(bar);
} else {
bar.name(text).color(color).progress(progress);
}
}
}
private boolean isOn(Player player, RegisteredServer server) {
return player.getCurrentServer()
.map(connection -> connection.getServerInfo().getName().equalsIgnoreCase(server.getServerInfo().getName()))
.orElse(false);
}
}
@@ -0,0 +1,51 @@
package eu.mhsl.craftattack.limboqueue;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TranslatableComponent;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import java.util.Optional;
import java.util.Set;
/**
* Entscheidet, ob eine Ablehnung durch den Zielserver vorübergehend ist (warten und erneut
* versuchen) oder endgültig (Spieler mit dem Kicktext trennen).
*/
final class KickClassifier {
enum Kind { TEMPORARY, FINAL }
private final Set<String> temporaryTitles;
private final Set<String> temporaryKeys;
KickClassifier(Set<String> temporaryTitles, Set<String> temporaryKeys) {
this.temporaryTitles = temporaryTitles;
this.temporaryKeys = temporaryKeys;
}
Kind classify(Optional<Component> reason) {
if (reason.isEmpty()) return Kind.TEMPORARY;
Component component = reason.get();
if (this.containsTemporaryKey(component)) return Kind.TEMPORARY;
if (this.temporaryTitles.contains(title(component))) return Kind.TEMPORARY;
return Kind.FINAL;
}
/** Erste nicht leere Zeile des Texts, getrimmt; leer, wenn es keine gibt. */
static String title(Component component) {
String plain = PlainTextComponentSerializer.plainText().serialize(component);
return plain.lines().map(String::trim).filter(line -> !line.isEmpty()).findFirst().orElse("");
}
private boolean containsTemporaryKey(Component component) {
if (component instanceof TranslatableComponent translatable) {
if (this.temporaryKeys.contains(translatable.key())) return true;
for (var argument : translatable.arguments()) {
if (this.containsTemporaryKey(argument.asComponent())) return true;
}
}
for (Component child : component.children()) {
if (this.containsTemporaryKey(child)) return true;
}
return false;
}
}
@@ -0,0 +1,65 @@
package eu.mhsl.craftattack.limboqueue;
import com.google.inject.Inject;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import org.slf4j.Logger;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
@Plugin(
id = "limbo-queue",
name = "limbo-queue",
version = "1.0.0",
description = "Warteschlange im Limbo: holt Spieler nacheinander auf den Zielserver zurück",
authors = {"MHSL"}
)
public final class LimboQueue {
private final ProxyServer proxy;
private final Logger logger;
private final Path dataDirectory;
@Inject
public LimboQueue(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, Warteschlange ist aus", e);
return;
}
RegisteredServer limbo = this.proxy.getServer(config.limbo()).orElse(null);
RegisteredServer target = this.proxy.getServer(config.target()).orElse(null);
if (limbo == null || target == null) {
this.logger.error("Server '{}' oder '{}' fehlt in velocity.toml, Warteschlange ist aus",
config.limbo(), config.target());
return;
}
WaitingQueue queue = new WaitingQueue();
KickClassifier classifier = new KickClassifier(config.temporaryTitles(), config.temporaryKeys());
Dispatcher dispatcher = new Dispatcher(this.proxy, this.logger, config, queue, classifier, limbo, target);
this.proxy.getEventManager().register(this,
new QueueListener(this.logger, config, queue, classifier, dispatcher, limbo, target));
this.proxy.getScheduler().buildTask(this, dispatcher::tick)
.repeat(config.intervalMs(), TimeUnit.MILLISECONDS)
.schedule();
this.logger.info("Warteschlange {} -> {}: {} Spieler alle {} ms, Pausen {} s",
config.limbo(), config.target(), config.perTick(), config.intervalMs(), config.backoffSeconds());
}
}
@@ -0,0 +1,126 @@
package eu.mhsl.craftattack.limboqueue;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.player.KickedFromServerEvent;
import com.velocitypowered.api.event.player.ServerPostConnectEvent;
import com.velocitypowered.api.event.player.ServerPreConnectEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import net.kyori.adventure.text.Component;
import org.slf4j.Logger;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Bringt Spieler in die Warteschlange und wieder heraus:
* <ul>
* <li>Kick vom Ziel (auch beim Join): vorübergehend → still in den Limbo, endgültig → mit dem
* Kicktext vom Proxy trennen. Ohne das würde Velocity jeden Kick auf den nächsten
* {@code try}-Server (den Limbo) umleiten und den Grund in den Chat schreiben.</li>
* <li>Solange jemand wartet, stellen sich neue Spieler im Limbo hinten an.</li>
* <li>Wer im Limbo ankommt, weil wir ihn dorthin geschickt haben oder er dort beim Join landet, wird
* eingereiht. Wer selbst per {@code /server limbo} wechselt, nicht.</li>
* </ul>
*/
final class QueueListener {
private final Logger logger;
private final Config config;
private final WaitingQueue queue;
private final KickClassifier classifier;
private final Dispatcher dispatcher;
private final RegisteredServer limbo;
private final RegisteredServer target;
/** Von uns in den Limbo geschickt, aber noch nicht angekommen. */
private final Set<UUID> sentToLimbo = ConcurrentHashMap.newKeySet();
QueueListener(Logger logger, Config config, WaitingQueue queue, KickClassifier classifier, Dispatcher dispatcher,
RegisteredServer limbo, RegisteredServer target) {
this.logger = logger;
this.config = config;
this.queue = queue;
this.classifier = classifier;
this.dispatcher = dispatcher;
this.limbo = limbo;
this.target = target;
}
@Subscribe
public void onKicked(KickedFromServerEvent event) {
Player player = event.getPlayer();
// Wechsel von einem anderen Server (z. B. Arena) scheitert: Velocity-Standard, Spieler bleibt dort
if (!this.is(event.getServer(), this.target) || event.kickedDuringServerConnect() || !player.isActive()) {
return;
}
Optional<Component> reason = event.getServerKickReason();
if (this.classifier.classify(reason) == KickClassifier.Kind.FINAL) {
event.setResult(KickedFromServerEvent.DisconnectPlayer.create(reason.orElseThrow()));
return;
}
this.sentToLimbo.add(player.getUniqueId());
// Component.empty() verhindert die Chat-Meldung, die Velocity sonst nach der Umleitung schickt
event.setResult(KickedFromServerEvent.RedirectPlayer.create(this.limbo, Component.empty()));
}
@Subscribe
public void onPreConnect(ServerPreConnectEvent event) {
Player player = event.getPlayer();
if (!this.is(event.getOriginalServer(), this.target) || !event.getResult().isAllowed()) return;
if (this.dispatcher.isConnecting(player.getUniqueId()) || this.queue.isEmpty()) return;
if (this.isOn(player, this.limbo)) {
// schon im Limbo: anstellen statt vordrängeln
this.enqueue(player);
event.setResult(ServerPreConnectEvent.ServerResult.denied());
return;
}
this.sentToLimbo.add(player.getUniqueId());
event.setResult(ServerPreConnectEvent.ServerResult.allowed(this.limbo));
}
@Subscribe
public void onPostConnect(ServerPostConnectEvent event) {
Player player = event.getPlayer();
UUID id = player.getUniqueId();
boolean sent = this.sentToLimbo.remove(id);
if (this.isOn(player, this.limbo)) {
if (sent || event.getPreviousServer() == null) this.enqueue(player);
return;
}
if (this.queue.contains(id)) {
if (this.isOn(player, this.target)) this.logger.info("{} aus der Warteschlange verbunden", player.getUsername());
this.dispatcher.leave(id);
}
}
@Subscribe
public void onDisconnect(DisconnectEvent event) {
UUID id = event.getPlayer().getUniqueId();
this.sentToLimbo.remove(id);
this.dispatcher.leave(id);
}
private void enqueue(Player player) {
boolean priority = player.hasPermission(this.config.priorityPermission());
int position = this.queue.add(player.getUniqueId(), priority);
if (position > 0) {
this.logger.info("{} wartet auf {} (Platz {} von {}{})", player.getUsername(),
this.target.getServerInfo().getName(), position, this.queue.size(), priority ? ", Vorrang" : "");
}
}
private boolean isOn(Player player, RegisteredServer server) {
return player.getCurrentServer().map(connection -> this.is(connection.getServer(), server)).orElse(false);
}
private boolean is(RegisteredServer a, RegisteredServer b) {
return a.getServerInfo().getName().equalsIgnoreCase(b.getServerInfo().getName());
}
}
@@ -0,0 +1,112 @@
package eu.mhsl.craftattack.limboqueue;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.IntToLongFunction;
/**
* Reihenfolge der wartenden Spieler. Spieler mit Vorrang stehen vor allen anderen, sonst gilt
* die Ankunftszeit. Wer gerade pausiert (nach einem Fehlschlag) oder schon verbunden wird,
* behält seinen Platz, wird aber übersprungen: Niemand kann die Schlange blockieren.
*
* <p>Alle Methoden sind synchronisiert; Zeiten kommen von außen, damit sich das testen lässt.
*/
final class WaitingQueue {
enum State { WAITING, CONNECTING, PAUSED }
record Snapshot(int position, int size, State state, String lastReason) {}
private static final class Entry {
final UUID id;
final boolean priority;
int failures;
long nextAttemptAt;
boolean connecting;
String lastReason;
Entry(UUID id, boolean priority) {
this.id = id;
this.priority = priority;
}
}
private final List<Entry> entries = new ArrayList<>();
/** Reiht ein; gibt die Position zurück, oder -1, wenn der Spieler schon wartet. */
synchronized int add(UUID id, boolean priority) {
if (this.indexOf(id) >= 0) return -1;
int index = this.entries.size();
if (priority) {
index = 0;
while (index < this.entries.size() && this.entries.get(index).priority) index++;
}
this.entries.add(index, new Entry(id, priority));
return index + 1;
}
synchronized boolean remove(UUID id) {
return this.entries.removeIf(entry -> entry.id.equals(id));
}
synchronized boolean contains(UUID id) {
return this.indexOf(id) >= 0;
}
synchronized boolean isEmpty() {
return this.entries.isEmpty();
}
synchronized int size() {
return this.entries.size();
}
synchronized List<UUID> ids() {
return this.entries.stream().map(entry -> entry.id).toList();
}
/**
* Nimmt bis zu {@code max} fällige Spieler von vorne und markiert sie als „wird verbunden“,
* ohne dass insgesamt mehr als {@code maxConnecting} gleichzeitig verbunden werden.
*/
synchronized List<UUID> takeDue(long now, int max, int maxConnecting) {
int connecting = (int) this.entries.stream().filter(entry -> entry.connecting).count();
int budget = Math.min(max, maxConnecting - connecting);
List<UUID> due = new ArrayList<>();
for (Entry entry : this.entries) {
if (due.size() >= budget) break;
if (entry.connecting || entry.nextAttemptAt > now) continue;
entry.connecting = true;
due.add(entry.id);
}
return due;
}
/** Verbindungsversuch gescheitert: Platz behalten, aber erst nach der Pause wieder versuchen. */
synchronized void failed(UUID id, long now, IntToLongFunction backoffMillis, String reason) {
int index = this.indexOf(id);
if (index < 0) return;
Entry entry = this.entries.get(index);
entry.connecting = false;
entry.failures++;
entry.nextAttemptAt = now + backoffMillis.applyAsLong(entry.failures);
if (reason != null) entry.lastReason = reason;
}
synchronized Snapshot snapshot(UUID id, long now) {
int index = this.indexOf(id);
if (index < 0) return null;
Entry entry = this.entries.get(index);
State state = entry.connecting ? State.CONNECTING
: entry.nextAttemptAt > now ? State.PAUSED
: State.WAITING;
return new Snapshot(index + 1, this.entries.size(), state, entry.lastReason);
}
private int indexOf(UUID id) {
for (int i = 0; i < this.entries.size(); i++) {
if (this.entries.get(i).id.equals(id)) return i;
}
return -1;
}
}
+30
View File
@@ -0,0 +1,30 @@
# limbo-queue: Warteschlange im Limbo
# Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity.
# Server, auf dem gewartet wird, und Ziel der Warteschlange (Namen aus velocity.toml)
limbo=limbo
target=craftattack
# Name des Ziels in der Bossbar
target-display-name=CraftAttack
# Alle interval-ms wird verbunden: höchstens per-tick Spieler, nie mehr als max-in-flight gleichzeitig
interval-ms=1000
per-tick=1
max-in-flight=3
# Pause nach dem 1., 2., 3. ... Fehlschlag in Sekunden; der letzte Wert gilt danach dauerhaft.
# Wer pausiert, behält seinen Platz, die anderen kommen in der Zeit trotzdem dran.
backoff-seconds=5|10|30
# Wer diese Permission hat (LuckPerms auf Velocity), wird vorne eingereiht
priority-permission=limboqueue.priority
# Vorübergehende Ablehnungen: Der Spieler wartet im Limbo und wird später erneut verbunden.
# Jede andere Ablehnung mit Text (Bann, nicht angemeldet, /kick, veralteter Client ...) gilt als
# endgültig: Der Spieler wird mit genau diesem Text vom Proxy getrennt.
# Ein Kick ohne Text (Server down, abgestürzt) ist immer vorübergehend.
#
# temporary-titles: erste nicht leere Zeile des Kicktexts, exakt (bei spawn der Titel von DisconnectInfo)
temporary-titles=Server closed|Connection throttled! Please wait before reconnecting.|Server is restarting|Hohe Serverauslastung|Server für Spieler nicht verfügbar|Nutzerdaten nicht geladen|Interner Serverfehler
# temporary-keys: Übersetzungsschlüssel irgendwo im Kicktext
temporary-keys=multiplayer.disconnect.server_shutdown|velocity.error.internal-server-connection-error|velocity.error.connecting-server-error|velocity.error.connected-server-error
@@ -0,0 +1,91 @@
package eu.mhsl.craftattack.limboqueue;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.Properties;
import static eu.mhsl.craftattack.limboqueue.KickClassifier.Kind.FINAL;
import static eu.mhsl.craftattack.limboqueue.KickClassifier.Kind.TEMPORARY;
import static org.junit.jupiter.api.Assertions.assertEquals;
class KickClassifierTest {
private final KickClassifier classifier = defaultClassifier();
/** Aufbau wie spawns DisconnectInfo: zwei Leerzeilen, Titel, Beschreibung, Hilfe, UUID. */
private static Component disconnectInfo(String title, String description) {
return Component.text()
.appendNewline().appendNewline()
.append(Component.text(title, NamedTextColor.DARK_RED)).appendNewline()
.append(Component.text(description, NamedTextColor.RED)).appendNewline().appendNewline()
.append(Component.text("Hilfetext", NamedTextColor.GRAY)).appendNewline().appendNewline()
.append(Component.text("00000000-0000-0000-0000-000000000000", NamedTextColor.DARK_GRAY))
.build();
}
private static KickClassifier defaultClassifier() {
Properties properties = new Properties();
try (var in = KickClassifierTest.class.getResourceAsStream("/config.properties")) {
properties.load(new InputStreamReader(in, StandardCharsets.UTF_8));
} catch (IOException e) {
throw new IllegalStateException(e);
}
Config config = Config.fromProperties(properties);
return new KickClassifier(config.temporaryTitles(), config.temporaryKeys());
}
@Test
void ohneGrundIstVoruebergehend() {
assertEquals(TEMPORARY, this.classifier.classify(Optional.empty()));
}
@Test
void spawnAblehnungenAusDenLogs() {
assertEquals(TEMPORARY, this.classify(disconnectInfo("Hohe Serverauslastung", "Kapazitätsgrenze")));
assertEquals(TEMPORARY, this.classify(disconnectInfo("Server für Spieler nicht verfügbar", "Nur Admins")));
assertEquals(TEMPORARY, this.classify(disconnectInfo("Interner Serverfehler", "API")));
assertEquals(TEMPORARY, this.classify(disconnectInfo("Nutzerdaten nicht geladen", "warte")));
assertEquals(FINAL, this.classify(disconnectInfo("Du wurdest vom Server gebannt.", "Dein Bann läuft nicht ab!")));
assertEquals(FINAL, this.classify(disconnectInfo("Nicht angemeldet", "nicht registriert")));
assertEquals(FINAL, this.classify(disconnectInfo("Temporäre Sperre", "PanicBan")));
assertEquals(FINAL, this.classify(disconnectInfo("Administrator Eingriff", "/kick")));
assertEquals(FINAL, this.classify(disconnectInfo("Nutzername geändert", "Name")));
}
@Test
void shutdownUndVelocityFehler() {
assertEquals(TEMPORARY, this.classify(Component.text("Server closed")));
assertEquals(TEMPORARY, this.classify(Component.text("Server is restarting")));
assertEquals(TEMPORARY, this.classify(Component.text("Connection throttled! Please wait before reconnecting.")));
assertEquals(TEMPORARY, this.classify(Component.translatable("multiplayer.disconnect.server_shutdown")));
assertEquals(TEMPORARY, this.classify(Component.translatable("velocity.error.internal-server-connection-error")));
// verschachtelt, wie Velocity Gründe einpackt
assertEquals(TEMPORARY, this.classify(Component.translatable("velocity.error.cant-connect",
Component.text("craftattack"), Component.translatable("multiplayer.disconnect.server_shutdown"))));
}
@Test
void sonstigeKicksSindEndgueltig() {
assertEquals(FINAL, this.classify(Component.text("Outdated client! Please use 26.3")));
assertEquals(FINAL, this.classify(Component.empty()));
assertEquals(FINAL, this.classify(Component.translatable("multiplayer.disconnect.banned")));
// nur der Titel zählt, nicht ein Treffer irgendwo im Text
assertEquals(FINAL, this.classify(Component.text("Gebannt\nServer closed")));
}
@Test
void titelIstErsteNichtLeereZeile() {
assertEquals("Hohe Serverauslastung", KickClassifier.title(disconnectInfo("Hohe Serverauslastung", "x")));
assertEquals("", KickClassifier.title(Component.text("\n \n")));
}
private KickClassifier.Kind classify(Component reason) {
return this.classifier.classify(Optional.of(reason));
}
}
@@ -0,0 +1,74 @@
package eu.mhsl.craftattack.limboqueue;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WaitingQueueTest {
private static final UUID A = new UUID(0, 1);
private static final UUID B = new UUID(0, 2);
private static final UUID C = new UUID(0, 3);
private static final UUID ADMIN = new UUID(0, 9);
@Test
void vorrangKommtVorAlleAnderenAberHinterAndereMitVorrang() {
WaitingQueue queue = new WaitingQueue();
queue.add(A, false);
queue.add(B, false);
assertEquals(1, queue.add(ADMIN, true));
assertEquals(2, queue.add(C, true));
assertEquals(List.of(ADMIN, C, A, B), queue.ids());
assertEquals(-1, queue.add(A, false));
}
@Test
void gescheiterterSpielerBlockiertNiemanden() {
WaitingQueue queue = new WaitingQueue();
queue.add(A, false);
queue.add(B, false);
queue.add(C, false);
assertEquals(List.of(A), queue.takeDue(0, 1, 3));
queue.failed(A, 0, failures -> 5_000, "Server für Spieler nicht verfügbar");
// A pausiert, behält aber Platz 1; B ist dran
assertEquals(List.of(B), queue.takeDue(1_000, 1, 3));
assertEquals(1, queue.snapshot(A, 1_000).position());
assertEquals(WaitingQueue.State.PAUSED, queue.snapshot(A, 1_000).state());
assertEquals("Server für Spieler nicht verfügbar", queue.snapshot(A, 1_000).lastReason());
// nach der Pause ist A wieder vor C dran
queue.remove(B);
assertEquals(List.of(A), queue.takeDue(5_000, 1, 3));
}
@Test
void pausenWerdenLaenger() {
WaitingQueue queue = new WaitingQueue();
queue.add(A, false);
Config config = Config.fromProperties(new java.util.Properties());
for (int i = 0; i < 4; i++) {
queue.takeDue(Long.MAX_VALUE / 2, 1, 1);
queue.failed(A, 0, config::backoffMillis, null);
}
// 5 s, 10 s, 30 s, dann bleibt es bei 30 s
assertTrue(queue.takeDue(29_999, 1, 1).isEmpty());
assertEquals(List.of(A), queue.takeDue(30_000, 1, 1));
}
@Test
void begrenztGleichzeitigeVerbindungen() {
WaitingQueue queue = new WaitingQueue();
queue.add(A, false);
queue.add(B, false);
queue.add(C, false);
assertEquals(List.of(A, B), queue.takeDue(0, 5, 2));
assertTrue(queue.takeDue(0, 5, 2).isEmpty());
queue.remove(A);
assertEquals(List.of(C), queue.takeDue(0, 5, 2));
}
}