diff --git a/plugins/README.md b/plugins/README.md index b0af72b..03d5e9d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -87,8 +87,8 @@ What it does when routing is active: | Surface | Behavior | | ------- | -------- | -| Backend registry | Polls `GET /api/v1/servers` every 15 s and reconciles Velocity's dynamic registry. A failed poll **keeps existing registrations** — a control-plane blip never deregisters live backends. Addresses are registered *unresolved* (a sleeping backend's Service DNS may not resolve yet). | -| Join (`PlayerChooseInitialServerEvent`) | Resolves `subdomain.` → server. **Ready** → send straight in. **Not ready + lobby** → park in the lobby, wake, and transfer when ready. **Not ready + no lobby** → disconnect with a "reconnect shortly" message, still firing the wake so the reconnect lands faster. | +| Backend registry | Polls `GET /api/v1/servers` every 15 s and reconciles Velocity's dynamic registry. A failed poll **keeps existing registrations** — a control-plane blip never deregisters live backends. The API advertises each backend Service's host-routable ClusterIP, avoiding cluster-DNS names on the host-run proxy. | +| Join (`PlayerChooseInitialServerEvent`) | Resolves `subdomain.` and remembers the target, but every fresh connection still enters `login`. When the login gate requests its post-auth lobby transfer, Velocity re-checks link status: a ready remembered target is selected immediately; an asleep target is woken and queued from the lobby. | | Waiting queue | One scheduled drain every 2 s polls status once per distinct waited-on server; a waiter drops out on transfer, on the player leaving, or after a 120 s timeout. | | Wake gate | The wake is `POST /api/v1/internal/servers/{name}/wake` keyed on the player's online-mode UUID. **403** (policy refused) tells the player and stops; **429** (wake already in flight) keeps waiting. | | Server-list ping (`ProxyPingEvent`) | Answers from the cached lifecycle view with a phase-aware MOTD (online / starting / sleeping) — **read-only, never wakes** anything. Mirroring each backend's own MOTD by background-pinging ready servers is a later slice. | @@ -101,7 +101,8 @@ Velocity-only config keys (read from the same `felis-link.properties` / env as | Key | Env | Meaning | | --- | --- | ------- | | `root-domain` | `FELIS_ROOT_DOMAIN` | Routing zone, e.g. `mc.example.net`. Unset → routing off. | -| `lobby-server` | `FELIS_LOBBY_SERVER` | A `velocity.toml` static server to park players in while a backend wakes. Unset → players are asked to reconnect instead. Its name must not collide with a felis server name. | +| `login-server` | `FELIS_LOGIN_SERVER` | The system auth gate every fresh connection must pass. Defaults to `login`. | +| `lobby-server` | `FELIS_LOBBY_SERVER` | The distinct post-auth holding server used while a backend wakes. Defaults to `lobby`; it must not equal `login-server`. | ## Lobby menu (§12) diff --git a/plugins/velocity/build.gradle b/plugins/velocity/build.gradle index d4696c8..faff81a 100644 --- a/plugins/velocity/build.gradle +++ b/plugins/velocity/build.gradle @@ -5,12 +5,34 @@ plugins { group = 'best.lolicon.felis' version = '0.1.0' -// JDK 17 is the ceiling the whole plugin suite targets (Velocity 3.3.0 is a -// Java-17 line); we run Gradle on JDK 17 and compile to 17 bytecode rather than -// provisioning a separate toolchain. +// The proxy API this jar compiles against. Default = the newest RELEASED velocity-api, +// which is what deploy/bootstrap.sh installs. velocity-api is compileOnly, so this +// selects the surface we are CHECKED against, not one that ships in the jar. +// +// Do NOT drift this back to a -SNAPSHOT of the 3.x line: 3.3.0-SNAPSHOT (the previous +// value) froze in 2024 and tops out at MINECRAFT_1_21, so it cannot even name the +// protocol the rest of the stack speaks. +def velocityApi = findProperty('velocityApi') ?: '3.5.1' + +// 21 is the floor of the proxy we ship against: velocity-api 3.5.1's Gradle module +// metadata declares `org.gradle.jvm.version = 21`, so 17 does not buy backward reach — +// it makes resolution fail outright. 21 bytecode also loads on Velocity 4, which runs a +// newer JVM still, so ONE jar serves both lines. +// +// The knob exists only for the Velocity-4 compile check: velocity-api 4.0.0-SNAPSHOT +// demands `jvm.version = 25`, and Gradle refuses to put a 25 library on a 21 consumer's +// classpath. To prove these sources also compile against the 4 API (4.0.0 itself is +// unreleased — zero published builds; only the snapshot exists), build with a JDK 25 +// toolchain and both knobs turned up: +// +// gradle build -PvelocityApi=4.0.0-SNAPSHOT -PjavaTarget=25 +// +// That build is a CHECK, not an artifact — the jar we deploy is the default 21 one. +def javaTarget = JavaVersion.toVersion(findProperty('javaTarget') ?: '21') + java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = javaTarget + targetCompatibility = javaTarget } repositories { @@ -24,8 +46,8 @@ repositories { dependencies { // velocity-api is compile-only (the proxy provides it at runtime); the // annotation processor turns @Plugin into the generated velocity-plugin.json. - compileOnly 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT' - annotationProcessor 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT' + compileOnly "com.velocitypowered:velocity-api:${velocityApi}" + annotationProcessor "com.velocitypowered:velocity-api:${velocityApi}" } // The platform-agnostic link core lives in ../shared and is compiled straight diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java index 6cb42e3..77ad0a3 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityConfig.java @@ -11,35 +11,44 @@ import java.util.Locale; import java.util.Properties; /** - * FelisVelocityConfig extends the shared link config with the two inputs only the - * full proxy needs: the {@code root-domain} the deployment serves (the zone - * subdomains are carved from) and the {@code lobby-server} waiters are parked in - * while their backend wakes. It reuses {@link LinkConfigLoader} for the API base + * FelisVelocityConfig extends the shared link config with the inputs only the full + * proxy needs: the {@code root-domain} the deployment serves (the zone subdomains + * are carved from), the {@code login-server} every fresh connection must pass + * through, and the post-auth {@code lobby-server} waiters are parked in while their + * backend wakes. It reuses {@link LinkConfigLoader} for the API base * URL + service token (and its first-run template), so {@code /link} keeps working * exactly as before; these extra keys are read from the same properties file (or - * {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOBBY_SERVER}). + * {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOGIN_SERVER} / + * {@code FELIS_LOBBY_SERVER}). * - *

Both extras are optional at load time and the routing layer degrades rather + *

The routing extras are optional at load time and the routing layer degrades rather * than crashing: a missing {@code root-domain} disables routing (with a clear log - * line) while {@code /link} still runs, and a missing {@code lobby-server} means - * the proxy has nowhere to hold waiters, so it refuses the join with a "reconnect - * shortly" message instead of dropping the player onto a not-yet-ready backend. - * The root domain is the only place the deployment zone enters the proxy — it is - * never compiled in (CI red line). + * line) while {@code /link} still runs. The two server names default to the system + * names ({@code login}/{@code lobby}) but must remain distinct: collapsing them + * would put the waiting area on the unauthenticated side of the gate. The root + * domain is the only place the deployment zone enters the proxy — it is never + * compiled in (CI red line). */ final class FelisVelocityConfig { static final String ENV_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN"; + static final String ENV_LOGIN = "FELIS_LOGIN_SERVER"; static final String ENV_LOBBY = "FELIS_LOBBY_SERVER"; private static final String KEY_ROOT_DOMAIN = "root-domain"; + private static final String KEY_LOGIN = "login-server"; private static final String KEY_LOBBY = "lobby-server"; + private static final String DEFAULT_LOGIN = "login"; + private static final String DEFAULT_LOBBY = "lobby"; private final LinkConfig linkConfig; private final String rootDomain; // null → routing disabled - private final String lobbyServer; // null → no lobby to park waiters in + private final String loginServer; + private final String lobbyServer; - private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain, String lobbyServer) { + private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain, + String loginServer, String lobbyServer) { this.linkConfig = linkConfig; this.rootDomain = rootDomain; + this.loginServer = loginServer; this.lobbyServer = lobbyServer; } @@ -52,8 +61,15 @@ final class FelisVelocityConfig { } } String root = trimToNull(firstNonBlank(System.getenv(ENV_ROOT_DOMAIN), props.getProperty(KEY_ROOT_DOMAIN))); + String login = trimToNull(firstNonBlank(System.getenv(ENV_LOGIN), props.getProperty(KEY_LOGIN))); String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY))); - return new FelisVelocityConfig(link, root == null ? null : root.toLowerCase(Locale.ROOT), lobby); + login = login == null ? DEFAULT_LOGIN : login; + lobby = lobby == null ? DEFAULT_LOBBY : lobby; + if (login.equalsIgnoreCase(lobby)) { + throw new IOException("login-server and lobby-server must be different"); + } + return new FelisVelocityConfig( + link, root == null ? null : root.toLowerCase(Locale.ROOT), login, lobby); } LinkConfig linkConfig() { @@ -69,7 +85,12 @@ final class FelisVelocityConfig { return rootDomain != null; } - /** lobbyServer is the velocity.toml server name waiters are parked in, or null. */ + /** loginServer is the only server a fresh connection may enter. */ + String loginServer() { + return loginServer; + } + + /** lobbyServer is the post-auth server name waiters are parked in. */ String lobbyServer() { return lobbyServer; } diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index 962d91f..d4f00cb 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -27,7 +27,6 @@ import org.slf4j.Logger; import java.nio.file.Path; import java.time.Duration; -import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -116,7 +115,8 @@ public final class FelisVelocityPlugin { this.apiClient = new FelisApiClient(config.linkConfig()); this.registry = new ServerRegistry(proxy, logger, config.rootDomain()); - this.router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer()); + this.router = new WaitingRouter(proxy, logger, apiClient, registry, this, + config.loginServer(), config.lobbyServer()); MotdResponder motd = new MotdResponder(registry); proxy.getEventManager().register(this, router); proxy.getEventManager().register(this, motd); @@ -135,12 +135,8 @@ public final class FelisVelocityPlugin { repeating(WAIT_POLL, router::tick); this.routingActive = true; - if (config.lobbyServer() == null) { - logger.warn("Felis routing active without a lobby-server: a player whose target is asleep will be " - + "asked to reconnect rather than parked. Set 'lobby-server=' to enable the waiting queue."); - } - logger.info("Felis routing ready: rootDomain={}, lobby={}. /link and /felis registered.", - config.rootDomain(), config.lobbyServer() == null ? "" : config.lobbyServer()); + logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link and /felis registered.", + config.rootDomain(), config.loginServer(), config.lobbyServer()); } /** async runs a task on Velocity's scheduler so felis-api I/O never blocks the proxy thread. */ @@ -235,11 +231,6 @@ public final class FelisVelocityPlugin { * (slash, dot, whitespace) is refused client-side rather than sent. */ private static final Pattern OP_LOGIN_CODE = Pattern.compile("^[A-Za-z0-9_-]{1,128}$"); - /** The always-on login limbo (LOOHP/Limbo) — the reserved system name - * {@code naming.SystemLoginServer}, which users can never claim, so gating on the - * server name is stable. */ - private static final String LOGIN_LIMBO = "login"; - private void registerFelisCommand() { CommandManager commands = proxy.getCommandManager(); LiteralCommandNode node = BrigadierCommand.literalArgumentBuilder("felis") @@ -316,7 +307,7 @@ public final class FelisVelocityPlugin { "Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW)); return false; } - if (LOGIN_LIMBO.equalsIgnoreCase(current.get().getServerInfo().getName())) { + if (config.loginServer().equalsIgnoreCase(current.get().getServerInfo().getName())) { player.sendMessage(Component.text( "Finish signing in first — /felis isn't available from the login area.", NamedTextColor.YELLOW)); @@ -347,7 +338,8 @@ public final class FelisVelocityPlugin { return; } source.sendMessage(field("root-domain", config.rootDomain())); - source.sendMessage(field("lobby", config.lobbyServer() == null ? "" : config.lobbyServer())); + source.sendMessage(field("login", config.loginServer())); + source.sendMessage(field("lobby", config.lobbyServer())); source.sendMessage(field("servers", String.valueOf(registry.all().size()))); source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY)); } @@ -371,7 +363,9 @@ public final class FelisVelocityPlugin { source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW)); return; } - Collection servers = registry.all(); + List servers = registry.all().stream() + .filter(v -> !isSystemServer(v.name())) + .toList(); if (servers.isEmpty()) { source.sendMessage(Component.text("No felis servers known yet.", NamedTextColor.GRAY)); return; @@ -397,7 +391,7 @@ public final class FelisVelocityPlugin { String target = serverArg.trim(); ServerView match = null; for (ServerView v : registry.all()) { - if (v.name().equalsIgnoreCase(target)) { + if (!isSystemServer(v.name()) && v.name().equalsIgnoreCase(target)) { match = v; break; } @@ -555,6 +549,11 @@ public final class FelisVelocityPlugin { NamedTextColor.YELLOW); } + private boolean isSystemServer(String name) { + return config.loginServer().equalsIgnoreCase(name) + || config.lobbyServer().equalsIgnoreCase(name); + } + // claimError maps the felis-api claim refusals (spec §9.3) to player-safe text. private static String claimError(LinkException e, String server) { switch (e.statusCode()) { diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java index a50f3a9..a091b61 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/ServerRegistry.java @@ -29,10 +29,10 @@ import java.util.concurrent.ConcurrentHashMap; * untouched (spec §11 keep-old-on-failure) — a transient control-plane blip must * never deregister live backends out from under connected players. * - *

Only felis-managed servers live in this registry; servers defined statically - * in {@code velocity.toml} (notably the lobby) are never added here and so are - * never deregistered by a refresh. Static server names must therefore not collide - * with felis server names. + *

Every API-reported backend, including the system login and lobby, lives in + * this registry. The generated {@code velocity.toml} contains a deliberately dead + * login placeholder only so Velocity can validate {@code try = ["login"]}; the + * first successful refresh replaces that placeholder with the live ClusterIP. */ final class ServerRegistry { private static final int DEFAULT_PORT = 25565; @@ -138,8 +138,9 @@ final class ServerRegistry { if (idx > 0 && idx < addr.length() - 1) { try { int port = Integer.parseInt(addr.substring(idx + 1)); - // Unresolved: the backend's DNS (a K8s Service) may not resolve yet - // while the server is asleep; Velocity resolves at connect time. + // Keep address parsing side-effect-free; Velocity resolves hostnames + // at connect time. Bootstrap deployments normally advertise a + // host-routable Service ClusterIP here. return InetSocketAddress.createUnresolved(addr.substring(0, idx), port); } catch (NumberFormatException ignored) { // not host:port → fall through to the default Minecraft port diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java index 2833ecc..adf4611 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java @@ -4,9 +4,12 @@ import best.lolicon.felis.link.FelisApiClient; import best.lolicon.felis.link.LinkException; import best.lolicon.felis.link.ServerView; +import com.velocitypowered.api.event.EventTask; import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; +import com.velocitypowered.api.event.player.ServerPreConnectEvent; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.server.RegisteredServer; @@ -26,13 +29,13 @@ import java.util.concurrent.ConcurrentHashMap; /** * WaitingRouter implements the §11 domain-autostart routing loop and its waiting * queue. It resolves the virtual host a player connected with to a felis server - * and decides what happens next: + * and remembers the requested backend while the player passes the login gate: * *

- *   host has no felis subdomain        → leave Velocity's default routing alone
- *   server ready + registered          → set it as the initial server (straight in)
- *   server not ready, lobby configured → park in lobby, wake it, enqueue a transfer
- *   server not ready, no lobby          → refuse cleanly ("reconnect shortly"), wake
+ *   fresh connection                  → login (always; never a user backend)
+ *   login says linked + target ready  → requested backend
+ *   login says linked + target asleep → post-auth lobby, wake, queued transfer
+ *   login requests any other target   → deny (fail closed)
  * 
* *

The queue is drained by {@link #tick()}, scheduled by the plugin on the async @@ -40,11 +43,12 @@ import java.util.concurrent.ConcurrentHashMap; * reports ready, transfers everyone waiting on it. A waiter drops out when it times * out, when the player leaves the proxy, or on a successful transfer. * - *

The wake is gated server-side by autostartPolicy keyed on the player's - * online-mode UUID: a 403 means this player may not start the server (we tell them - * and stop), a 429 means a wake is already in flight (we keep waiting). Real joins - * to a felis backend are reported back so the reaper sees activity and the player - * is auto-added to the allowlist. + *

Every transition out of login is checked against felis-api's link status, and + * command/menu queue entries are checked the same way. The wake is then gated + * server-side by autostartPolicy keyed on the player's online-mode UUID: a 403 means + * this player may not start the server (we tell them and stop), a 429 means a wake is + * already in flight (we keep waiting). Real user-backend joins are reported back so + * the reaper sees activity and the player is auto-added to the allowlist. */ public final class WaitingRouter { private static final long WAIT_TIMEOUT_MILLIS = 120_000L; @@ -54,9 +58,11 @@ public final class WaitingRouter { private final FelisApiClient api; private final ServerRegistry registry; private final FelisVelocityPlugin plugin; - private final String lobbyServer; // may be null → no lobby + private final String loginServer; + private final String lobbyServer; private final Map waiting = new ConcurrentHashMap<>(); + private final Map pendingTargets = new ConcurrentHashMap<>(); // Notified just before a menu-originated waiter is transferred, so the lobby's // felis:control face can tell the player's GUI the backend is ready. Null until @@ -64,12 +70,13 @@ public final class WaitingRouter { private volatile MenuTransferListener menuListener; WaitingRouter(ProxyServer proxy, Logger log, FelisApiClient api, ServerRegistry registry, - FelisVelocityPlugin plugin, String lobbyServer) { + FelisVelocityPlugin plugin, String loginServer, String lobbyServer) { this.proxy = proxy; this.log = log; this.api = api; this.registry = registry; this.plugin = plugin; + this.loginServer = loginServer; this.lobbyServer = lobbyServer; } @@ -91,7 +98,7 @@ public final class WaitingRouter { * fire {@link MenuTransferListener} on transfer. */ void enqueueFromMenu(Player player, String serverName) { - wakeAndWait(player, serverName, true); + authorizeAndWait(player, serverName, true); } /** @@ -104,50 +111,130 @@ public final class WaitingRouter { * exactly as the other origins, so this adds a new entry point, not a new authority. */ void enqueueFromCommand(Player player, String serverName) { - wakeAndWait(player, serverName, false); + authorizeAndWait(player, serverName, false); } @Subscribe public void onChooseInitialServer(PlayerChooseInitialServerEvent event) { Player player = event.getPlayer(); + UUID id = player.getUniqueId(); + pendingTargets.remove(id); // a reconnect must never inherit an earlier host Optional host = virtualHost(player); if (host.isEmpty()) { - return; // direct connect / no SRV host → leave default routing + return; // velocity.toml's only fallback is login } Optional targetOpt = registry.resolveByHost(host.get()); if (targetOpt.isEmpty()) { - return; // host is not a felis subdomain → leave default routing + return; // unknown host also falls through to login } ServerView target = targetOpt.get(); - Optional backend = registry.registered(target.name()); - if (target.ready() && backend.isPresent()) { - event.setInitialServer(backend.get()); // ready → straight in + Optional login = login(); + if (login.isEmpty()) { + event.setInitialServer(null); + player.disconnect(Component.text( + "The Felis login gate is unavailable. Please reconnect shortly.", + NamedTextColor.RED)); + return; + } + // login. is a valid system hostname, but it is the gate rather + // than a post-auth destination. Remembering it would redirect the successful + // lobby release straight back into login and loop forever. + if (!target.name().equalsIgnoreCase(loginServer)) { + pendingTargets.put(id, target.name()); + } + event.setInitialServer(login.get()); + } + + /** + * The login backend requests the configured lobby only after its own link poll + * succeeds. Re-check that state on the trusted proxy boundary, then either route + * the remembered virtual-host target or admit the player to the post-auth lobby. + */ + @Subscribe + public EventTask onServerPreConnect(ServerPreConnectEvent event) { + RegisteredServer previous = event.getPreviousServer(); + if (previous == null || !serverNamed(previous, loginServer)) { + return null; + } + + Player player = event.getPlayer(); + event.setResult(ServerPreConnectEvent.ServerResult.denied()); + if (!serverNamed(event.getOriginalServer(), lobbyServer)) { + player.sendMessage(Component.text( + "The login gate may only release players to the lobby.", NamedTextColor.RED)); + log.warn("Felis: denied login-gate transfer for {} to {}", + player.getUniqueId(), event.getOriginalServer().getServerInfo().getName()); + return null; + } + + return EventTask.async(() -> authorizeLoginRelease(event)); + } + + private void authorizeLoginRelease(ServerPreConnectEvent event) { + Player player = event.getPlayer(); + UUID id = player.getUniqueId(); + try { + if (!api.linkStatus(id)) { + player.sendMessage(Component.text( + "Finish signing in before leaving the login area.", NamedTextColor.YELLOW)); + return; + } + } catch (LinkException e) { + log.warn("Felis: could not verify login release for {} (status={}): {}", + id, e.statusCode(), e.getMessage()); + player.sendMessage(Component.text( + "Login verification is temporarily unavailable. Please wait and try again.", + NamedTextColor.RED)); return; } - Optional lobby = lobby(); - if (lobby.isEmpty()) { - // Nowhere to hold the player while the backend wakes: refuse cleanly so - // they reconnect onto a ready server, rather than dropping them onto a - // backend that is still starting. Still fire the wake so the reconnect - // lands faster. - player.disconnect(Component.text( - "« " + target.name() + " » is starting up — please reconnect in a moment.", - NamedTextColor.YELLOW)); - fireWake(player.getUniqueId(), target.name()); + String targetName = pendingTargets.get(id); + if (targetName == null + || targetName.equalsIgnoreCase(loginServer) + || targetName.equalsIgnoreCase(lobbyServer)) { + event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer())); + pendingTargets.remove(id); return; } - event.setInitialServer(lobby.get()); // park in lobby - wakeAndWait(player, target.name(), false); + + ServerView target = registry.view(targetName); + if (target == null) { + pendingTargets.remove(id, targetName); + event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer())); + player.sendMessage(Component.text( + "« " + targetName + " » is no longer available.", NamedTextColor.YELLOW)); + return; + } + Optional backend = registry.registered(targetName); + if (target.ready() && backend.isPresent()) { + // Keep pendingTargets until ServerConnectedEvent confirms the redirect. + // If the connect fails, Limbo retries its lobby release and we retry too. + event.setResult(ServerPreConnectEvent.ServerResult.allowed(backend.get())); + return; + } + + pendingTargets.remove(id, targetName); + event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer())); + wakeAndWaitLinked(player, targetName, false); + } + + @Subscribe + public void onDisconnect(DisconnectEvent event) { + UUID id = event.getPlayer().getUniqueId(); + pendingTargets.remove(id); + waiting.remove(id); } @Subscribe public void onServerConnected(ServerConnectedEvent event) { String name = event.getServer().getServerInfo().getName(); - if (!registry.isManaged(name)) { - return; // lobby / static server → not a felis backend, nothing to report - } UUID id = event.getPlayer().getUniqueId(); + pendingTargets.remove(id, name); + if (!registry.isManaged(name) + || name.equalsIgnoreCase(loginServer) + || name.equalsIgnoreCase(lobbyServer)) { + return; // system/static servers do not affect user-server activity + } plugin.async(() -> { try { api.reportJoin(name, id); @@ -177,7 +264,7 @@ public final class WaitingRouter { waiting.remove(id); player.sendMessage(Component.text( "« " + w.serverName + " » is taking longer than expected to start. " - + "You can keep waiting in the lobby or try again later.", NamedTextColor.YELLOW)); + + "You can try again from the lobby later.", NamedTextColor.YELLOW)); continue; } Boolean ready = readyCache.get(w.serverName); @@ -196,6 +283,19 @@ public final class WaitingRouter { if (backend.isEmpty()) { continue; // ready but not yet registered → next tick } + try { + if (!api.linkStatus(id)) { + waiting.remove(id); + player.sendMessage(Component.text( + "Your account is no longer linked. Reconnect to sign in again.", + NamedTextColor.RED)); + continue; + } + } catch (LinkException ex) { + // Fail closed on an ambiguous identity. Keep the waiter so a later + // tick can retry the check without losing the requested target. + continue; + } waiting.remove(id); player.sendMessage(Component.text( "« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN)); @@ -209,44 +309,55 @@ public final class WaitingRouter { } } - private void wakeAndWait(Player player, String serverName, boolean fromMenu) { + private void authorizeAndWait(Player player, String serverName, boolean fromMenu) { UUID id = player.getUniqueId(); plugin.async(() -> { try { - api.wake(serverName, id); - } catch (LinkException e) { - switch (e.statusCode()) { - case 403: - player.sendMessage(Component.text( - "You're not allowed to start « " + serverName + " ».", NamedTextColor.RED)); - return; // policy gate refused → do not enqueue - case 429: - break; // a wake is already in flight → fall through to waiting - default: - log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage()); - player.sendMessage(Component.text( - "Couldn't start « " + serverName + " » right now. Try again shortly.", - NamedTextColor.RED)); - return; + if (!api.linkStatus(id)) { + player.sendMessage(Component.text( + "Finish signing in before joining a server.", NamedTextColor.YELLOW)); + return; } + } catch (LinkException e) { + log.warn("Felis: could not verify queue entry for {} (status={}): {}", + id, e.statusCode(), e.getMessage()); + player.sendMessage(Component.text( + "Login verification is temporarily unavailable. Please try again shortly.", + NamedTextColor.RED)); + return; } - player.sendMessage(Component.text( - "Starting « " + serverName + " » — you'll be moved in automatically.", - NamedTextColor.GRAY)); - waiting.put(id, new Waiter(serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu)); + wakeAndWaitLinked(player, serverName, fromMenu); }); } - private void fireWake(UUID id, String serverName) { - plugin.async(() -> { - try { - api.wake(serverName, id); - } catch (LinkException e) { - if (e.statusCode() != 429 && e.statusCode() != 403) { + // Caller already ran the authoritative link-status check and is off the event + // thread. Keep the wake and queue mutation together so every entry has passed + // both the account gate and the server-side autostart policy. + private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) { + UUID id = player.getUniqueId(); + try { + api.wake(serverName, id); + } catch (LinkException e) { + switch (e.statusCode()) { + case 403: + player.sendMessage(Component.text( + "You're not allowed to start « " + serverName + " ».", NamedTextColor.RED)); + return; + case 429: + break; // a wake is already in flight → join the existing wait + default: log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage()); - } + player.sendMessage(Component.text( + "Couldn't start « " + serverName + " » right now. Try again shortly.", + NamedTextColor.RED)); + return; } - }); + } + player.sendMessage(Component.text( + "Starting « " + serverName + " » — you'll be moved in automatically.", + NamedTextColor.GRAY)); + waiting.put(id, new Waiter( + serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu)); } private void transfer(Player player, String serverName, RegisteredServer backend) { @@ -259,8 +370,12 @@ public final class WaitingRouter { }); } - private Optional lobby() { - return lobbyServer == null ? Optional.empty() : proxy.getServer(lobbyServer); + private Optional login() { + return proxy.getServer(loginServer); + } + + private static boolean serverNamed(RegisteredServer server, String name) { + return server.getServerInfo().getName().equalsIgnoreCase(name); } private static Optional virtualHost(Player player) {