added move to login or craftattack

This commit is contained in:
jannis committed 2026-11-07 15:46:40 +01:00
1 parent ad532c8b99
commit 869efecee6
6 files changed
+350 -2

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ Listenern, Befehlen und eigenem Config-Abschnitt. Braucht LuckPerms auf Velocity
| `mute` | `/mute`, `/unmute`, LuckPerms-Permission `chatbridge.muted` | – | | `mute` | `/mute`, `/unmute`, LuckPerms-Permission `chatbridge.muted` | – |
| `chat` | öffentlicher Chat, `/msg`, `/r`, `/adminchat`, Join/Leave, Chat-Einstellungen | `mute` | | `chat` | öffentlicher Chat, `/msg`, `/r`, `/adminchat`, Join/Leave, Chat-Einstellungen | `mute` |
| `tablist` | Spieler anderer Server in der Tabliste | `chat` | | `tablist` | Spieler anderer Server in der Tabliste | `chat` |
| `signup` | erster Server beim Join: Login-Server oder CraftAttack, je nach Anmeldung auf der Website | – |
## Aufbau ## Aufbau
@@ -0,0 +1,178 @@
package eu.mhsl.craftattack.proxy.appliances.signup;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import com.velocitypowered.api.scheduler.ScheduledTask;
import eu.mhsl.craftattack.proxy.appliances.signup.SignupApi.Status;
import eu.mhsl.craftattack.proxy.core.Config;
import eu.mhsl.craftattack.proxy.core.appliance.Appliance;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
public final class Signup extends Appliance {
enum Phase { BEFORE, LOGIN, OPEN, LATE }
enum Target { LOGIN, CRAFTATTACK }
private RegisteredServer login;
private RegisteredServer craftattack;
private RegisteredServer fallback;
private Duration pingTimeout;
private ZoneId zone;
private Instant registrationRequiredFrom;
private Instant openForAllFrom;
private Instant lateSignupFrom;
private SignupApi api;
private @Nullable ScheduledTask recheckTask;
private final Set<UUID> checking = ConcurrentHashMap.newKeySet();
private static final DateTimeFormatter START_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm");
@Override
protected void onEnable() {
Config config = this.config();
this.login = this.server(config.string("login-server", "login"));
this.craftattack = this.server(config.string("craftattack-server", "craftattack"));
this.fallback = this.server(config.string("fallback-server", "limbo"));
this.pingTimeout = Duration.ofSeconds(config.integer("ping-timeout-seconds", 3));
ZoneId zone = this.zone = ZoneId.of(config.string("timezone", "Europe/Berlin"));
this.registrationRequiredFrom = LocalDateTime.parse(config.string("registration-required-from", "2026-12-01T00:00")).atZone(zone).toInstant();
this.openForAllFrom = LocalDateTime.parse(config.string("open-for-all-from", "2026-12-26T14:00")).atZone(zone).toInstant();
this.lateSignupFrom = LocalDateTime.parse(config.string("late-signup-from", "2026-12-29T14:00")).atZone(zone).toInstant();
if (!this.registrationRequiredFrom.isBefore(this.openForAllFrom) || !this.openForAllFrom.isBefore(this.lateSignupFrom))
throw new IllegalArgumentException("signup: registration-required-from < open-for-all-from < late-signup-from erwartet");
this.api = new SignupApi(
config.string("api-url", ""),
config.string("late-signup-path", "signup"),
config.string("api-secret", ""),
Duration.ofSeconds(config.integer("timeout-seconds", 5)),
this.logger());
int recheck = config.integer("recheck-seconds", 10);
if (recheck > 0) this.recheckTask = this.task(this::recheck).repeat(Duration.ofSeconds(recheck)).schedule();
}
private RegisteredServer server(String name) {
return this.proxy().getServer(name).orElseThrow(() -> new IllegalArgumentException("Server " + name + " ist in Velocity nicht eingetragen"));
}
Phase phase(Instant now) {
return phase(now, this.registrationRequiredFrom, this.openForAllFrom, this.lateSignupFrom);
}
static Phase phase(Instant now, Instant registrationRequiredFrom, Instant openForAllFrom, Instant lateSignupFrom) {
if (now.isBefore(registrationRequiredFrom)) return Phase.BEFORE;
if (now.isBefore(openForAllFrom)) return Phase.LOGIN;
if (now.isBefore(lateSignupFrom)) return Phase.OPEN;
return Phase.LATE;
}
static Target target(Phase phase, Status registered, Status signedUp) {
if (registered == Status.FOUND || registered == Status.BLOCKED) return Target.CRAFTATTACK;
return switch (phase) {
case BEFORE, OPEN -> Target.CRAFTATTACK;
case LOGIN -> Target.LOGIN;
case LATE -> signedUp == Status.NOT_FOUND ? Target.CRAFTATTACK : Target.LOGIN;
};
}
CompletableFuture<Decision> decide(Player player) {
Phase phase = this.phase(Instant.now());
// Vor dem 1.12. und im offenen Zeitraum braucht es die Website nicht
if (phase == Phase.BEFORE || phase == Phase.OPEN)
return CompletableFuture.completedFuture(new Decision(phase, Status.UNKNOWN, Status.UNKNOWN));
return this.api.registered(player).thenCompose(registered -> {
// Angemeldete und Gesperrte kommen immer auf CraftAttack, die nachträgliche Anmeldung ist dann egal
if (phase != Phase.LATE || registered == Status.FOUND || registered == Status.BLOCKED)
return CompletableFuture.completedFuture(new Decision(phase, registered, Status.UNKNOWN));
return this.api.signedUp(player).thenApply(signedUp -> new Decision(phase, registered, signedUp));
});
}
record Decision(Phase phase, Status registered, Status signedUp) {
Target target() {
return Signup.target(this.phase, this.registered, this.signedUp);
}
@Override
public String toString() {
return this.phase + ", angemeldet " + this.registered + ", nachträglich " + this.signedUp + " -> " + this.target();
}
}
CompletableFuture<RegisteredServer> server(Target target) {
RegisteredServer server = target == Target.LOGIN ? this.login : this.craftattack;
return server.ping()
.orTimeout(this.pingTimeout.toMillis(), TimeUnit.MILLISECONDS)
.thenApply(ping -> server)
.exceptionally(error -> {
this.logger().warn("[signup] {} nicht erreichbar, stattdessen {}", server.getServerInfo().getName(), this.fallback.getServerInfo().getName());
return this.fallback;
});
}
private void recheck() {
for (Player player : this.login.getPlayersConnected()) {
if (!this.checking.add(player.getUniqueId())) continue;
this.decide(player).thenCompose(decision -> {
if (decision.target() != Target.CRAFTATTACK || !player.isActive()) return CompletableFuture.completedFuture(null);
if (decision.phase() != Phase.LATE && decision.registered() == Status.FOUND) {
this.logger().info("[signup] {} angemeldet, gekickt: {}", player.getUsername(), decision);
player.disconnect(this.thanks());
return CompletableFuture.completedFuture(null);
}
return this.server(Target.CRAFTATTACK).thenAccept(server -> {
this.logger().info("[signup] {} vom Login-Server auf {}: {}", player.getUsername(), server.getServerInfo().getName(), decision);
if (server == this.craftattack)
player.sendMessage(Component.text("Du bist angemeldet und wirst auf CraftAttack verbunden.", NamedTextColor.GREEN));
player.createConnectionRequest(server).fireAndForget();
});
}).whenComplete((ignored, error) -> {
this.checking.remove(player.getUniqueId());
if (error != null) this.logger().error("[signup] Prüfung für {} fehlgeschlagen", player.getUsername(), error);
});
}
}
private Component thanks() {
if (Instant.now().isBefore(this.openForAllFrom))
return Component.text("Danke für deine Anmeldung! CraftAttack startet am "
+ START_FORMAT.format(this.openForAllFrom.atZone(this.zone)) + " Uhr.", NamedTextColor.GREEN);
return Component.text("Danke für deine Anmeldung! Verbinde dich neu, um auf CraftAttack zu spielen.", NamedTextColor.GREEN);
}
boolean isLogin(RegisteredServer server) {
return server.equals(this.login);
}
@Override
protected void onDisable() {
if (this.recheckTask != null) this.recheckTask.cancel();
}
@Override
public Logger logger() {
return super.logger();
}
@Override
protected List<Object> listeners() {
return List.of(new SignupListener(this));
}
}
@@ -0,0 +1,97 @@
package eu.mhsl.craftattack.proxy.appliances.signup;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.velocitypowered.api.proxy.Player;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
final class SignupApi {
enum Status { FOUND, NOT_FOUND, BLOCKED, UNKNOWN }
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private final URI api;
private final String signupPath;
private final @Nullable String authorization;
private final Duration timeout;
private final Logger logger;
SignupApi(String url, String signupPath, String secret, Duration timeout, Logger logger) {
if (url.isEmpty()) throw new IllegalArgumentException("signup.api-url fehlt");
this.api = root(url);
this.signupPath = signupPath.startsWith("/") ? signupPath.substring(1) : signupPath;
// Website: Basic-Auth mit Benutzer „api“ und API_SECRET; ohne Secret prüft sie nichts
this.authorization = secret.isEmpty() ? null
: "Basic " + Base64.getEncoder().encodeToString(("api:" + secret).getBytes(StandardCharsets.UTF_8));
this.timeout = timeout;
this.logger = logger;
}
static URI root(String url) {
String base = url.replaceAll("/+$", "");
if (base.endsWith("/api")) base = base.substring(0, base.length() - "/api".length());
return URI.create(base + "/api/");
}
CompletableFuture<Status> registered(Player player) {
String path = "users/" + player.getUniqueId();
return this.get(path)
.thenApply(response -> switch (response.statusCode()) {
case 200 -> Status.FOUND;
case 403 -> Status.BLOCKED;
case 404 -> Status.NOT_FOUND;
default -> this.unexpected(path, player, response);
})
.exceptionally(error -> this.unreachable(path, player, error));
}
CompletableFuture<Status> signedUp(Player player) {
return this.get(this.signupPath)
.thenApply(response -> {
if (response.statusCode() != 200) return this.unexpected(this.signupPath, player, response);
return uuids(response.body()).contains(player.getUniqueId()) ? Status.FOUND : Status.NOT_FOUND;
})
.exceptionally(error -> this.unreachable(this.signupPath, player, error));
}
static Set<UUID> uuids(String json) {
Set<UUID> uuids = new HashSet<>();
for (JsonElement entry : JsonParser.parseString(json).getAsJsonArray()) {
String uuid = entry.getAsString().trim().toLowerCase(Locale.ROOT);
if (uuid.length() == 32) uuid = uuid.replaceFirst("(.{8})(.{4})(.{4})(.{4})(.{12})", "$1-$2-$3-$4-$5");
uuids.add(UUID.fromString(uuid));
}
return uuids;
}
private CompletableFuture<HttpResponse<String>> get(String path) {
HttpRequest.Builder request = HttpRequest.newBuilder(this.api.resolve(path))
.timeout(this.timeout)
.GET();
if (this.authorization != null) request.header("Authorization", this.authorization);
return this.http.sendAsync(request.build(), HttpResponse.BodyHandlers.ofString());
}
private Status unexpected(String path, Player player, HttpResponse<?> response) {
this.logger.warn("[signup] Website antwortet {} auf {} für {}", response.statusCode(), path, player.getUsername());
return Status.UNKNOWN;
}
private Status unreachable(String path, Player player, Throwable error) {
this.logger.warn("[signup] Website nicht erreichbar oder Antwort ungültig ({}) für {}: {}", path, player.getUsername(), error.toString());
return Status.UNKNOWN;
}
}
@@ -0,0 +1,32 @@
package eu.mhsl.craftattack.proxy.appliances.signup;
import com.velocitypowered.api.event.EventTask;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.player.KickedFromServerEvent;
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
import com.velocitypowered.api.proxy.Player;
import net.kyori.adventure.text.Component;
final class SignupListener {
private final Signup signup;
SignupListener(Signup signup) {
this.signup = signup;
}
@Subscribe
public EventTask onChooseInitialServer(PlayerChooseInitialServerEvent event) {
Player player = event.getPlayer();
return EventTask.resumeWhenComplete(this.signup.decide(player).thenCompose(decision -> {
this.signup.logger().info("[signup] {} ({}): {}", player.getUsername(), player.getUniqueId(), decision);
return this.signup.server(decision.target());
}).thenAccept(event::setInitialServer));
}
@Subscribe
public void onKicked(KickedFromServerEvent event) {
if (!this.signup.isLogin(event.getServer())) return;
Component reason = event.getServerKickReason().orElse(Component.text("Verbindung zum Login-Server getrennt."));
event.setResult(KickedFromServerEvent.DisconnectPlayer.create(reason));
}
}
@@ -10,6 +10,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.ProxyServer;
import eu.mhsl.craftattack.proxy.appliances.chat.Chat; import eu.mhsl.craftattack.proxy.appliances.chat.Chat;
import eu.mhsl.craftattack.proxy.appliances.mute.Mute; import eu.mhsl.craftattack.proxy.appliances.mute.Mute;
import eu.mhsl.craftattack.proxy.appliances.signup.Signup;
import eu.mhsl.craftattack.proxy.appliances.tablist.NetworkTabList; import eu.mhsl.craftattack.proxy.appliances.tablist.NetworkTabList;
import eu.mhsl.craftattack.proxy.core.appliance.Appliance; import eu.mhsl.craftattack.proxy.core.appliance.Appliance;
import net.luckperms.api.LuckPerms; import net.luckperms.api.LuckPerms;
@@ -42,7 +43,8 @@ public final class Main {
private static final List<Supplier<Appliance>> APPLIANCES = List.of( private static final List<Supplier<Appliance>> APPLIANCES = List.of(
Mute::new, Mute::new,
Chat::new, Chat::new,
NetworkTabList::new NetworkTabList::new,
Signup::new
); );
private final ProxyServer proxy; private final ProxyServer proxy;
+39 -1
View File
@@ -2,7 +2,7 @@
# Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity. # Listen werden mit | getrennt. Änderungen brauchen einen Neustart von Velocity.
# Schlüssel einer Appliance beginnen mit ihrem Namen (chat., tablist. …). # Schlüssel einer Appliance beginnen mit ihrem Namen (chat., tablist. …).
# Appliances, die nicht geladen werden (mute, chat, tablist). chat braucht mute, tablist braucht chat. # Appliances, die nicht geladen werden (mute, chat, tablist, signup). chat braucht mute, tablist braucht chat.
disabled-appliances= disabled-appliances=
# Adminchat, /mute und /unmute (LuckPerms auf Velocity) # Adminchat, /mute und /unmute (LuckPerms auf Velocity)
@@ -34,3 +34,41 @@ chat.marker-permission=adminmarker
# Abgleich der Tablisten in Sekunden; nach Join, Wechsel und Quit zusätzlich kurz danach # Abgleich der Tablisten in Sekunden; nach Join, Wechsel und Quit zusätzlich kurz danach
tablist.refresh-seconds=2 tablist.refresh-seconds=2
# --- signup -----------------------------------------------------------------------------------
# Erster Server beim Join. Wer angemeldet oder gesperrt ist, kommt immer auf craftattack-server.
signup.login-server=login
signup.craftattack-server=craftattack
# Antwortet login-server bzw. craftattack-server nicht innerhalb von ping-timeout-seconds auf einen
# Ping, kommt der Spieler stattdessen hierhin.
signup.fallback-server=limbo
signup.ping-timeout-seconds=3
# Was mit nicht Angemeldeten passiert, je Zeitpunkt (JJJJ-MM-TTTHH:MM in timezone):
# davor -> craftattack-server
# ab registration-required-from -> login-server
# ab open-for-all-from -> craftattack-server
# ab late-signup-from -> login-server, wenn sie in der nachträglichen Anmeldung
# stehen (late-signup-path), sonst craftattack-server
signup.registration-required-from=2026-12-01T00:00
signup.open-for-all-from=2026-12-26T14:00
signup.late-signup-from=2026-12-29T14:00
signup.timezone=Europe/Berlin
# Website wie api.url und api.secret im Login-Server (mit oder ohne /api am Ende), Secret wie
# API_SECRET der Website. Angemeldet: GET <website>/api/users/<uuid>.
# Nachträgliche Anmeldung: GET <website>/api/<late-signup-path> gibt eine JSON-Liste aller UUIDs zurück,
# die sich nachträglich anmelden dürfen, z. B. ["069a79f4-44e9-4726-a5be-fca90e38aaf5"].
# Antwortet die Website nicht, kommt der Spieler auf login-server, wenn er dort hin könnte.
signup.api-url=
signup.api-secret=
signup.late-signup-path=signup
signup.timeout-seconds=5
# Spieler auf login-server werden so oft (Sekunden) neu geprüft. Wer sich angemeldet hat, wird vor
# late-signup-from gekickt, danach auf craftattack-server geschickt. 0 = aus.
# Wer vom login-server gekickt wird, fliegt auch vom Proxy.
signup.recheck-seconds=10