Warteschlange im Limbo als eigenes Velocity-Plugin
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user