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,5 @@
|
||||
.gradle/
|
||||
build/
|
||||
*.iml
|
||||
.idea/
|
||||
run/
|
||||
@@ -0,0 +1,26 @@
|
||||
# limbo-queue
|
||||
|
||||
Schlankes Velocity-Plugin für CraftAttack: Wer den Hauptserver verliert (Neustart, Absturz) oder beim
|
||||
Join nicht draufkommt (Server down, Wartung, Volllast), wartet im Limbo und wird der Reihe nach
|
||||
zurückverbunden. Ersetzt den velocity-limbo-handler.
|
||||
|
||||
- **Niemand blockiert die Schlange.** Scheitert ein Spieler, behält er seinen Platz, wird aber erst nach
|
||||
einer Pause (5 s, 10 s, dann 30 s) wieder versucht; die anderen kommen in der Zeit dran.
|
||||
- **Endgültige Ablehnungen** (Bann, nicht angemeldet, `/kick`, veralteter Client …) trennen den Spieler
|
||||
mit genau diesem Kicktext vom Proxy, statt ihn in den Limbo zu schicken. Vorübergehend ist nur, was in
|
||||
`temporary-titles`/`temporary-keys` steht, sowie ein Kick ohne Text (Server down).
|
||||
- **Kein Chat-Spam.** Platz und Grund stehen in einer Bossbar, nur solange man im Limbo ist.
|
||||
- **Vorrang** mit der Permission `limboqueue.priority` (LuckPerms auf Velocity): vorne einreihen.
|
||||
- Solange jemand wartet, stellen sich neue Spieler hinten an. Wer selbst per `/server limbo` wechselt,
|
||||
wird nicht eingereiht.
|
||||
|
||||
Einstellungen: `plugins/limbo-queue/config.properties` (wird beim ersten Start angelegt, kommentiert).
|
||||
|
||||
## Bauen und Deploy
|
||||
|
||||
Java 25, Gradle-Wrapper.
|
||||
|
||||
```bash
|
||||
./gradlew build # inkl. Tests
|
||||
./gradlew uploadPlugin # nach ~/velocity/plugins/limbo-queue.jar, danach: sudo systemctl restart velocity
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'eu.mhsl.craftattack'
|
||||
version = '1.0.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
name = 'papermc'
|
||||
url = 'https://repo.papermc.io/repository/maven-public/'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Velocity 4.2.x auf dem Proxy (Stand 2026-10)
|
||||
compileOnly 'com.velocitypowered:velocity-api:4.2.0'
|
||||
annotationProcessor 'com.velocitypowered:velocity-api:4.2.0'
|
||||
|
||||
testImplementation 'com.velocitypowered:velocity-api:4.2.0'
|
||||
testImplementation platform('org.junit:junit-bom:5.13.4')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
options.release.set(25)
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// Hochladen auf den Proxy: ./gradlew uploadPlugin, danach `sudo systemctl restart velocity`.
|
||||
// Erst in eine Temp-Datei, Prüfsumme vergleichen, dann atomar umbenennen.
|
||||
tasks.register('uploadPlugin') {
|
||||
dependsOn jar
|
||||
doLast {
|
||||
def file = jar.archiveFile.get().asFile
|
||||
def checksum = file.bytes.digest('SHA-256')
|
||||
def target = '/home/minecraft/velocity/plugins/limbo-queue.jar'
|
||||
providers.exec {
|
||||
commandLine 'scp', file.absolutePath, "minecraft@10.20.9.3:${target}.new"
|
||||
}.result.get()
|
||||
providers.exec {
|
||||
commandLine 'ssh', 'minecraft@10.20.9.3',
|
||||
"echo '${checksum} ${target}.new' | sha256sum -c --quiet && mv -f ${target}.new ${target}"
|
||||
}.result.get()
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'limbo-queue'
|
||||
@@ -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