From 9a238780e59edb54cb9ec43ccab08ec4615841d0 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 05:55:58 +0800 Subject: [PATCH] =?UTF-8?q?test(plugins):=20=E7=99=BB=E5=BD=95=E9=97=B8?= =?UTF-8?q?=E9=97=A8=E3=80=81=E5=A4=A7=E5=8E=85=E8=8F=9C=E5=8D=95=E5=92=8C?= =?UTF-8?q?=20mod=20/link=20=E6=8A=BD=E6=88=90=E5=8F=AF=E6=B5=8B=E7=B1=BB?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E8=A1=8C=E4=B8=BA=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/README.md | 9 +- .../lolicon/felis/fabric/FelisFabricMod.java | 95 +-- .../lolicon/felis/forge/FelisForgeMod.java | 95 +-- plugins/limbo/build.gradle | 23 + .../lolicon/felis/limbo/FelisLimboPlugin.java | 485 +++---------- .../best/lolicon/felis/limbo/GateConfig.java | 79 +++ .../best/lolicon/felis/limbo/LoginBook.java | 29 + .../best/lolicon/felis/limbo/LoginFlow.java | 312 ++++++++ .../best/lolicon/felis/limbo/Readiness.java | 66 ++ .../lolicon/felis/limbo/LoginFlowTest.java | 667 ++++++++++++++++++ .../felis/neoforge/FelisNeoForgeMod.java | 95 +-- plugins/paper/build.gradle | 19 +- .../lolicon/felis/paper/FelisPaperPlugin.java | 348 +++------ .../best/lolicon/felis/paper/LobbyMenu.java | 347 +++++++++ .../lolicon/felis/paper/LobbyMenuTest.java | 547 ++++++++++++++ .../java/best/lolicon/felis/link/ModLink.java | 110 +++ .../lolicon/felis/link/ModLinkPolicy.java | 24 - .../best/lolicon/felis/link/ModLinkTest.java | 248 +++++++ plugins/test.sh | 30 +- 19 files changed, 2802 insertions(+), 826 deletions(-) create mode 100644 plugins/limbo/src/main/java/best/lolicon/felis/limbo/GateConfig.java create mode 100644 plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginBook.java create mode 100644 plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java create mode 100644 plugins/limbo/src/main/java/best/lolicon/felis/limbo/Readiness.java create mode 100644 plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java create mode 100644 plugins/paper/src/main/java/best/lolicon/felis/paper/LobbyMenu.java create mode 100644 plugins/paper/test/best/lolicon/felis/paper/LobbyMenuTest.java create mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/ModLink.java delete mode 100644 plugins/shared/src/main/java/best/lolicon/felis/link/ModLinkPolicy.java create mode 100644 plugins/shared/test/best/lolicon/felis/link/ModLinkTest.java diff --git a/plugins/README.md b/plugins/README.md index 6dbd926..44317f6 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -326,8 +326,13 @@ minutes; subsequent builds are fast. Jars land in each module's `build/libs`. CI both gates: `bash plugins/test.sh` (JDK 25 — the install-time plugins, the codec/invite/server-list tests, and the proxy routing tests that drive ServerRegistry, WaitingRouter and ControlChannel against the real velocity-api; run those alone with -`plugins/velocity/gradlew -p plugins/velocity routingTest`) and `bash plugins/test-mods.sh` (JDK 17 — the three -loader mods, via the wrappers above). +`plugins/velocity/gradlew -p plugins/velocity routingTest`. It also runs the lobby menu +test that drives LobbyMenu against the real paper-api (`plugins/paper/gradlew -p +plugins/paper lobbyTest`), the login gate test that drives LoginFlow against a stub +felis-api on virtual ticks (`plugins/limbo/gradlew -p plugins/limbo +-PlimboVersion= loginTest`), and ModLinkTest for the `/link` the +loader mods share) and `bash plugins/test-mods.sh` (JDK 17 — the three loader mods, +via the wrappers above). ### Dependency verification diff --git a/plugins/fabric/src/main/java/best/lolicon/felis/fabric/FelisFabricMod.java b/plugins/fabric/src/main/java/best/lolicon/felis/fabric/FelisFabricMod.java index ada1704..71e662a 100644 --- a/plugins/fabric/src/main/java/best/lolicon/felis/fabric/FelisFabricMod.java +++ b/plugins/fabric/src/main/java/best/lolicon/felis/fabric/FelisFabricMod.java @@ -1,11 +1,9 @@ package best.lolicon.felis.fabric; import best.lolicon.felis.link.LinkClient; -import best.lolicon.felis.link.LinkCode; import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfigLoader; -import best.lolicon.felis.link.LinkException; -import best.lolicon.felis.link.ModLinkPolicy; +import best.lolicon.felis.link.ModLink; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.exceptions.CommandSyntaxException; @@ -16,40 +14,39 @@ import net.fabricmc.loader.api.FabricLoader; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; -import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import org.slf4j.Logger; import java.io.IOException; import java.util.UUID; -import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executor; import java.util.concurrent.Executors; /** * FelisFabricMod is the Fabric (dedicated-server) leg of the §10 account-link * flow. A server-side {@code /link} command takes the player's already-verified * UUID, asks felis-api for a one-time code, and shows it in chat; the player then - * redeems it on the web console. The HTTP call is pushed onto a daemon I/O thread - * and the reply is hopped back onto the server thread, so a slow felis-api never - * stalls the tick loop. Failures collapse to a generic chat line with details - * confined to the server log. + * redeems it on the web console. The command itself is the shared {@link ModLink} + * (the HTTP call on a daemon I/O thread, the reply back on the server thread, one + * generic line for any failure); this class registers it and adapts the command + * source. */ public final class FelisFabricMod implements DedicatedServerModInitializer { private static final Logger LOGGER = LogUtils.getLogger(); - private final ExecutorService io = Executors.newSingleThreadExecutor(r -> { + private final Executor io = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "felis-link-io"); t.setDaemon(true); return t; }); - private LinkClient linkClient; + private ModLink link; @Override public void onInitializeServer() { try { LinkConfig config = LinkConfigLoader.load( FabricLoader.getInstance().getConfigDir().resolve("felis-link.properties")); - this.linkClient = new LinkClient(config); + this.link = new ModLink(new LinkClient(config), io, LOGGER::warn); } catch (IOException e) { LOGGER.error("Felis link disabled: {}", e.getMessage()); return; @@ -60,44 +57,48 @@ public final class FelisFabricMod implements DedicatedServerModInitializer { } private void register(CommandDispatcher dispatcher) { - dispatcher.register(Commands.literal("link").executes(ctx -> { - CommandSourceStack source = ctx.getSource(); - ServerPlayer player; - try { - player = source.getPlayerOrException(); - } catch (CommandSyntaxException e) { - source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player.")); - return 0; - } - if (!source.getServer().usesAuthentication()) { - LOGGER.warn(ModLinkPolicy.OFFLINE_LOG); - source.sendFailure(Component.literal(ModLinkPolicy.OFFLINE_REPLY)); - return 0; - } - requestAndReply(source.getServer(), player); - return 1; - })); + dispatcher.register(Commands.literal("link").executes(ctx -> link.run(new LinkSource(ctx.getSource())))); } - private void requestAndReply(MinecraftServer server, ServerPlayer player) { - UUID uuid = player.getUUID(); - player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…")); - io.submit(() -> { + /** LinkSource is a command source as the shared /link sees it. */ + private static final class LinkSource implements ModLink.Source { + private final CommandSourceStack source; + private final ServerPlayer player; // null for the console or a command block + + LinkSource(CommandSourceStack source) { + this.source = source; + ServerPlayer p; try { - LinkCode code = linkClient.requestCode(uuid); - server.execute(() -> { - player.sendSystemMessage(Component.literal( - "绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)")); - player.sendSystemMessage(Component.literal(code.panelUrl() != null - ? "在此完成绑定 / Finish linking at: " + code.panelUrl() - : "在网页控制台 → 账户 中输入 / Enter it on the web console → Account.")); - }); - } catch (LinkException e) { - LOGGER.warn("link code request failed for {} (status={}, code={}): {}", - uuid, e.statusCode(), e.errorCode(), e.getMessage()); - server.execute(() -> player.sendSystemMessage(Component.literal( - "现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment."))); + p = source.getPlayerOrException(); + } catch (CommandSyntaxException e) { + p = null; } - }); + this.player = p; + } + + @Override + public UUID player() { + return player != null ? player.getUUID() : null; + } + + @Override + public boolean onlineMode() { + return source.getServer().usesAuthentication(); + } + + @Override + public void fail(String line) { + source.sendFailure(Component.literal(line)); + } + + @Override + public void tell(String line) { + player.sendSystemMessage(Component.literal(line)); + } + + @Override + public void onServerThread(Runnable task) { + source.getServer().execute(task); + } } } diff --git a/plugins/forge/src/main/java/best/lolicon/felis/forge/FelisForgeMod.java b/plugins/forge/src/main/java/best/lolicon/felis/forge/FelisForgeMod.java index 7cf54a5..952d338 100644 --- a/plugins/forge/src/main/java/best/lolicon/felis/forge/FelisForgeMod.java +++ b/plugins/forge/src/main/java/best/lolicon/felis/forge/FelisForgeMod.java @@ -1,11 +1,9 @@ package best.lolicon.felis.forge; import best.lolicon.felis.link.LinkClient; -import best.lolicon.felis.link.LinkCode; import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfigLoader; -import best.lolicon.felis.link.LinkException; -import best.lolicon.felis.link.ModLinkPolicy; +import best.lolicon.felis.link.ModLink; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.exceptions.CommandSyntaxException; @@ -13,7 +11,6 @@ import com.mojang.logging.LogUtils; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; -import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.RegisterCommandsEvent; @@ -24,34 +21,34 @@ import org.slf4j.Logger; import java.io.IOException; import java.util.UUID; -import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executor; import java.util.concurrent.Executors; /** * FelisForgeMod is the Forge server-side leg of the §10 account-link flow. On * {@link RegisterCommandsEvent} it installs a {@code /link} command that mints a - * one-time code from felis-api for the player's already-verified UUID. As on the - * other loaders, the HTTP call runs on a daemon I/O thread and the reply is hopped - * back onto the server thread; failures collapse to a generic chat line with - * details kept to the server log. If config is missing the mod stays loaded but + * one-time code from felis-api for the player's already-verified UUID. The command + * is the shared {@link ModLink}, as on the other loaders (the HTTP call on a daemon + * I/O thread, the reply back on the server thread, one generic line for any + * failure). If config is missing the mod stays loaded but * never registers the command, so the proxy/server runs un-crippled. */ @Mod("felis_link") public final class FelisForgeMod { private static final Logger LOGGER = LogUtils.getLogger(); - private final ExecutorService io = Executors.newSingleThreadExecutor(r -> { + private final Executor io = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "felis-link-io"); t.setDaemon(true); return t; }); - private LinkClient linkClient; + private ModLink link; public FelisForgeMod() { try { LinkConfig config = LinkConfigLoader.load( FMLPaths.CONFIGDIR.get().resolve("felis-link.properties")); - this.linkClient = new LinkClient(config); + this.link = new ModLink(new LinkClient(config), io, LOGGER::warn); MinecraftForge.EVENT_BUS.register(this); LOGGER.info("Felis link ready; /link will be registered."); } catch (IOException e) { @@ -65,44 +62,48 @@ public final class FelisForgeMod { } private void register(CommandDispatcher dispatcher) { - dispatcher.register(Commands.literal("link").executes(ctx -> { - CommandSourceStack source = ctx.getSource(); - ServerPlayer player; - try { - player = source.getPlayerOrException(); - } catch (CommandSyntaxException e) { - source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player.")); - return 0; - } - if (!source.getServer().usesAuthentication()) { - LOGGER.warn(ModLinkPolicy.OFFLINE_LOG); - source.sendFailure(Component.literal(ModLinkPolicy.OFFLINE_REPLY)); - return 0; - } - requestAndReply(source.getServer(), player); - return 1; - })); + dispatcher.register(Commands.literal("link").executes(ctx -> link.run(new LinkSource(ctx.getSource())))); } - private void requestAndReply(MinecraftServer server, ServerPlayer player) { - UUID uuid = player.getUUID(); - player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…")); - io.submit(() -> { + /** LinkSource is a command source as the shared /link sees it. */ + private static final class LinkSource implements ModLink.Source { + private final CommandSourceStack source; + private final ServerPlayer player; // null for the console or a command block + + LinkSource(CommandSourceStack source) { + this.source = source; + ServerPlayer p; try { - LinkCode code = linkClient.requestCode(uuid); - server.execute(() -> { - player.sendSystemMessage(Component.literal( - "绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)")); - player.sendSystemMessage(Component.literal(code.panelUrl() != null - ? "在此完成绑定 / Finish linking at: " + code.panelUrl() - : "在网页控制台 → 账户 中输入 / Enter it on the web console → Account.")); - }); - } catch (LinkException e) { - LOGGER.warn("link code request failed for {} (status={}, code={}): {}", - uuid, e.statusCode(), e.errorCode(), e.getMessage()); - server.execute(() -> player.sendSystemMessage(Component.literal( - "现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment."))); + p = source.getPlayerOrException(); + } catch (CommandSyntaxException e) { + p = null; } - }); + this.player = p; + } + + @Override + public UUID player() { + return player != null ? player.getUUID() : null; + } + + @Override + public boolean onlineMode() { + return source.getServer().usesAuthentication(); + } + + @Override + public void fail(String line) { + source.sendFailure(Component.literal(line)); + } + + @Override + public void tell(String line) { + player.sendSystemMessage(Component.literal(line)); + } + + @Override + public void onServerThread(Runnable task) { + source.getServer().execute(task); + } } } diff --git a/plugins/limbo/build.gradle b/plugins/limbo/build.gradle index a756a6e..24b267d 100644 --- a/plugins/limbo/build.gradle +++ b/plugins/limbo/build.gradle @@ -58,3 +58,26 @@ tasks.withType(JavaCompile).configureEach { // bytecode so the plugin loads on any Limbo running Java 17+. options.release = 17 } + +// The login gate's self-test: LoginFlowTest runs the whole login flow on a virtual +// clock (a fake scheduler and players around the real LoginFlow, the real felis-api +// clients against a stub) plus the readiness endpoint, the env parsing and the book. +// A framework-free main like the other plugins' tests; plugins/test.sh runs +// `./gradlew loginTest`. The Limbo jar is on the classpath for the adventure types the +// book is built from. +sourceSets { + loginTest { + java { + srcDir 'test' + } + compileClasspath += sourceSets.main.output + configurations.compileClasspath + runtimeClasspath += output + compileClasspath + } +} + +tasks.register('loginTest', JavaExec) { + group = 'verification' + description = 'Runs the LoginFlowTest self-test main.' + classpath = sourceSets.loginTest.runtimeClasspath + mainClass = 'best.lolicon.felis.limbo.LoginFlowTest' +} diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java index fdecf92..37844c2 100644 --- a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java @@ -1,39 +1,24 @@ package best.lolicon.felis.limbo; import best.lolicon.felis.link.Control; -import best.lolicon.felis.link.ControlFrame; import best.lolicon.felis.link.FelisApiClient; import best.lolicon.felis.link.LinkClient; import best.lolicon.felis.link.LinkCode; import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfigLoader; -import best.lolicon.felis.link.LinkException; -import best.lolicon.felis.link.OutageTracker; import com.loohp.limbo.events.EventHandler; import com.loohp.limbo.events.Listener; import com.loohp.limbo.events.player.PlayerJoinEvent; import com.loohp.limbo.player.Player; import com.loohp.limbo.plugins.LimboPlugin; +import com.loohp.limbo.scheduler.LimboScheduler; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; - -import net.kyori.adventure.inventory.Book; import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.format.NamedTextColor; import java.io.File; import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.util.Set; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; @@ -46,14 +31,14 @@ import java.util.logging.Logger; * RCON readiness probe. Left to a plain TCP check the pod would report ready the * instant the socket binds. This plugin serves {@code GET /healthz} on * {@code FELIS_HEALTH_PORT} (default 8080) that flips from 503 to 200 only after the - * first server tick — the MinecraftServer CRD's HTTP readinessProbe follows that - * true signal. Fail-closed: if the endpoint cannot bind, readiness never turns green - * and the operator keeps the gate in Starting rather than advertising an unstarted - * auth gate. + * first server tick ({@link Readiness}) — the MinecraftServer CRD's HTTP + * readinessProbe follows that true signal. Fail-closed: if the endpoint cannot bind, + * readiness never turns green and the operator keeps the gate in Starting rather than + * advertising an unstarted auth gate. * *

2. In-game login. A player who reaches the limbo has been UUID-verified * upstream (Velocity online-mode) but is not yet linked to a web account. On join - * the plugin, off the tick thread: + * {@link LoginFlow}, off the tick thread: *

    *
  1. checks the username-collision blacklist and disconnects a barred squatter * UUID (the genuine Mojang player, different UUID, passes);
  2. @@ -72,9 +57,9 @@ import java.util.logging.Logger; *
* *

A felis-api outage at join (transport error or 5xx) is retried with backoff for - * {@link #START_RETRY_WINDOW_MILLIS} before the player is turned away. Each retry - * also sends a {@code LoginRelease}: the proxy keeps recent link confirmations for - * exactly this case, so a player who was signed in minutes ago can still get + * {@link LoginFlow#START_RETRY_WINDOW_MILLIS} before the player is turned away. Each + * retry also sends a {@code LoginRelease}: the proxy keeps recent link confirmations + * for exactly this case, so a player who was signed in minutes ago can still get * through a felis-api restart, and anyone else is refused there. * *

The release used to be a BungeeCord {@code Connect} on {@code bungeecord:main}. @@ -88,137 +73,74 @@ import java.util.logging.Logger; * (env wins, else a {@code felis-link.properties} template in the plugin data dir) via * the shared {@link LinkConfigLoader}. {@code FELIS_ROOT_DOMAIN} builds the console * link; {@code FELIS_LOBBY_SERVER} (default {@code lobby}) is the transfer target; - * {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 600) bounds the login window. If the - * link config or the root domain is absent the login flow stays OFF and the plugin - * runs readiness-only — the same "load un-crippled" fail-safe the other Felis plugins - * use — so a bare image still boots and serves readiness; production must supply the - * config for the gate to authenticate. + * {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 600) bounds the login window + * ({@link GateConfig}). If the link config or the root domain is absent the login + * flow stays OFF and the plugin runs readiness-only — the same "load un-crippled" + * fail-safe the other Felis plugins use — so a bare image still boots and serves + * readiness; production must supply the config for the gate to authenticate. * - *

CODE-ONLY in the Go repo: it compiles against the Limbo API + the shared link - * core and is bundled into the login image (deploy/limbo), not built by the Go CI. + *

This class is the Limbo glue only. The flow, the readiness endpoint, the env + * parsing and the book live in plain classes that {@code ./gradlew loginTest} (run by + * plugins/test.sh) drives on a virtual clock against a stub felis-api. */ public final class FelisLimboPlugin extends LimboPlugin implements Listener { private static final Logger LOG = Logger.getLogger("FelisLimbo"); - private static final String HEALTH_PATH = "/healthz"; // Limbo deprecated the String channel overload; its Key overload sends key.toString(), // which for "felis:control" is the same channel string. private static final Key CONTROL_CHANNEL = Key.key(Control.CHANNEL); - private static final int DEFAULT_PORT = 8080; - // Poll cadence and window. 20 ticks ≈ 1s at Limbo's tick rate; polling once a - // second is responsive without hammering felis-api. The default window (10 min) - // matches the Bind Code TTL (linkCodeTTL in internal/api) — no point holding a - // player past code expiry, and no point cutting them off while it is still valid. - private static final long POLL_PERIOD_TICKS = 20L; - private static final long DEFAULT_TIMEOUT_SECONDS = 600L; - private static final long MIN_TIMEOUT_SECONDS = 30L; - private static final long MAX_TIMEOUT_SECONDS = 3600L; - - // Backoff between retries, in ticks (≈1s, 2s, 4s, then every 8s): used both for a - // felis-api outage at join and for re-sending the release until the player leaves. - private static final long[] BACKOFF_TICKS = {20L, 40L, 80L, 160L}; - // How long a felis-api outage at join is retried before the player is turned away. - static final long START_RETRY_WINDOW_MILLIS = 60_000L; - // How long the release is re-sent after sign-in before giving up with a message. - private static final long RELEASE_WINDOW_MILLIS = 120_000L; - // How often a link-status outage is summarized while it lasts. - private static final long POLL_OUTAGE_REPEAT_MILLIS = 5 * 60_000L; - - // ---- readiness state ---- - private final AtomicBoolean ready = new AtomicBoolean(false); - private volatile HttpServer http; - - // ---- login state (populated only when the flow is configured) ---- - private volatile boolean loginEnabled; - private volatile LinkClient linkClient; - private volatile FelisApiClient apiClient; - private volatile String consoleUrl; - private volatile String lobbyServer; - private volatile long timeoutMillis; - - // Per-player task ids (the link poll, a join retry, or the next release), so a - // completed/abandoned login cancels its own timer rather than polling a departed - // UUID forever. - private final ConcurrentHashMap pollTasks = new ConcurrentHashMap<>(); - // Players whose release loop is running; the async link poll can observe "linked" - // twice before its cancellation lands, and the release must start once. - private final Set releasing = ConcurrentHashMap.newKeySet(); - // One tracker for every player's link poll: the polls share felis-api, so an outage - // is one event, reported when it starts, every few minutes while it lasts, and when - // it ends, rather than once a second per waiting player (or never, at FINE). - private final OutageTracker pollOutage = - new OutageTracker(POLL_OUTAGE_REPEAT_MILLIS, System::currentTimeMillis); + private volatile Readiness readiness; + private volatile LoginFlow flow; @Override public void onEnable() { startReadiness(); configureLogin(); - if (loginEnabled) { + if (flow != null) { getServer().getEventsManager().registerEvents(this, this); - LOG.info("FelisLimbo: login flow ON — console=" + consoleUrl - + ", lobby=" + lobbyServer + ", window=" + (timeoutMillis / 1000) + "s"); - } else { - LOG.warning("FelisLimbo: login flow OFF (missing FELIS_API_BASE_URL/FELIS_SERVICE_TOKEN " - + "or FELIS_ROOT_DOMAIN) — serving readiness only; this gate will NOT authenticate players"); } } @Override public void onDisable() { - ready.set(false); - HttpServer server = this.http; - if (server != null) { - server.stop(0); - this.http = null; + Readiness r = this.readiness; + if (r != null) { + r.stop(); + this.readiness = null; } // cancelTask(plugin) tears down every scheduled task this plugin owns. getServer().getScheduler().cancelTask(this); - pollTasks.clear(); - releasing.clear(); + LoginFlow f = this.flow; + if (f != null) { + f.clear(); + } } - // ---- readiness endpoint (unchanged behavior) ---- - private void startReadiness() { - int port = healthPort(); + int port = GateConfig.healthPort(System.getenv("FELIS_HEALTH_PORT")); + Readiness r; try { - HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); - server.createContext(HEALTH_PATH, this::handleHealth); - server.setExecutor(null); // default executor: this endpoint is trivial - server.start(); - this.http = server; - LOG.info("FelisLimbo: readiness endpoint on :" + port + HEALTH_PATH + " (503 until the first tick)"); + r = Readiness.start(port); } catch (IOException e) { // Fail closed: no health server → never ready → pod stays NotReady. LOG.log(Level.SEVERE, "FelisLimbo: could not start readiness endpoint on :" + port + " — the login gate will stay NotReady", e); return; } + this.readiness = r; + LOG.info("FelisLimbo: readiness endpoint on :" + port + Readiness.PATH + " (503 until the first tick)"); // onEnable runs just before Limbo opens its game socket and no "started" // event exists, so mark ready one tick later: by the time the scheduler runs // the task the server loop is ticking and the socket is open. getServer().getScheduler().runTaskLater(this, () -> { - if (ready.compareAndSet(false, true)) { + if (r.markReady()) { LOG.info("FelisLimbo: server started — readiness now 200"); } }, 1L); } - private void handleHealth(HttpExchange exchange) throws IOException { - boolean up = ready.get(); - byte[] body = (up ? "ok" : "starting").getBytes(StandardCharsets.UTF_8); - int status = up ? 200 : 503; - exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(status, body.length); - try (OutputStream os = exchange.getResponseBody()) { - os.write(body); - } - } - - // ---- login configuration ---- - private void configureLogin() { LinkConfig cfg; try { @@ -228,299 +150,102 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { // Missing/half config: like the other Felis plugins, load un-crippled — // readiness stays up, the login flow just never turns on. LOG.warning("FelisLimbo: " + e.getMessage()); - loginEnabled = false; + logLoginOff(); return; } - - // The console host the player links at. Prefer the resolved FELIS_PANEL_HOSTNAME - // the provisioner bakes in (single source of truth — it honours a custom - // panel_hostname); fall back to console. only for an older operator whose - // env predates it. With neither set there is no link to build, so login stays off. - String panelHost = trimmed(System.getenv("FELIS_PANEL_HOSTNAME")); - String rootDomain = trimmed(System.getenv("FELIS_ROOT_DOMAIN")); - if (panelHost == null && rootDomain != null) { - panelHost = "console." + rootDomain; - } + String panelHost = GateConfig.panelHost(System.getenv("FELIS_PANEL_HOSTNAME"), System.getenv("FELIS_ROOT_DOMAIN")); if (panelHost == null) { LOG.warning("FelisLimbo: neither FELIS_PANEL_HOSTNAME nor FELIS_ROOT_DOMAIN set — cannot build the console login link"); - loginEnabled = false; + logLoginOff(); return; } - - this.consoleUrl = "https://" + panelHost; - String lobby = trimmed(System.getenv("FELIS_LOBBY_SERVER")); - this.lobbyServer = lobby != null ? lobby : "lobby"; - this.timeoutMillis = loginTimeoutSeconds() * 1000L; - this.linkClient = new LinkClient(cfg); - this.apiClient = new FelisApiClient(cfg); - this.loginEnabled = true; + String consoleUrl = "https://" + panelHost; + String lobby = GateConfig.lobby(System.getenv("FELIS_LOBBY_SERVER")); + long timeoutMillis = GateConfig.loginTimeoutSeconds(System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS")) * 1000L; + this.flow = new LoginFlow(new LimboGate(), new FelisApiClient(cfg), new LinkClient(cfg), + consoleUrl, timeoutMillis, System::currentTimeMillis, LOG); + LOG.info("FelisLimbo: login flow ON — console=" + consoleUrl + + ", lobby=" + lobby + ", window=" + (timeoutMillis / 1000) + "s"); } - // ---- login flow ---- + private static void logLoginOff() { + LOG.warning("FelisLimbo: login flow OFF (missing FELIS_API_BASE_URL/FELIS_SERVICE_TOKEN " + + "or FELIS_ROOT_DOMAIN) — serving readiness only; this gate will NOT authenticate players"); + } @EventHandler public void onJoin(PlayerJoinEvent event) { - Player player = event.getPlayer(); - UUID id = player.getUniqueId(); - releasing.remove(id); // a reconnect starts a fresh login - long startedAt = System.currentTimeMillis(); - // Everything below touches the network; run it off the tick thread so a slow - // felis-api never stalls the server loop. The player waits in the limbo world. - getServer().getScheduler().runTaskAsync(this, () -> beginLogin(id, 0, startedAt)); + flow.join(event.getPlayer().getUniqueId()); } - private void beginLogin(UUID id, int attempt, long startedAt) { - if (online(id) == null) { - return; // left while a retry was pending - } - try { - if (apiClient.isBlacklisted(id)) { - disconnectOnMain(id, "该用户名已被回收保护 / This username is under reclaim protection. Contact staff."); - return; - } - // Check registration before minting: an already-linked player needs no - // bind code, so send them straight to the lobby instead of flashing a - // useless code. Only unlinked players get one. The on-demand /link - // command (proxy + lobby) stays the door to a fresh web session. - if (apiClient.linkStatus(id)) { - getServer().getScheduler().runTask(this, () -> startRelease(id)); - return; - } - LinkCode code = linkClient.requestCode(id); - getServer().getScheduler().runTask(this, () -> presentAndPoll(id, code)); - } catch (LinkException e) { - boolean outage = e.statusCode() == 0 || e.statusCode() >= 500; - if (!outage || System.currentTimeMillis() - startedAt > START_RETRY_WINDOW_MILLIS) { - // Fail closed: felis-api refused, or stayed unreachable for the whole - // retry window. Refuse the connection rather than let them idle - // unauthenticated. - LOG.warning("FelisLimbo: login start failed for " + id + " after " + (attempt + 1) - + " attempt(s) — " + e.getMessage()); - disconnectOnMain(id, "登录服务暂不可用,请稍后重连 / Login service unavailable, please reconnect shortly."); - return; - } - LOG.info("FelisLimbo: felis-api unavailable at login for " + id + " (attempt " + (attempt + 1) - + "): " + e.getMessage()); - getServer().getScheduler().runTask(this, () -> { - Player player = online(id); - if (player == null) { - return; - } - if (attempt == 0) { - player.sendMessage("§e[Felis] 登录服务繁忙,正在重试… / The login service is busy — retrying…"); - } - sendRelease(player); - }); - int taskId = getServer().getScheduler().runTaskLaterAsync( - this, () -> beginLogin(id, attempt + 1, startedAt), backoff(attempt)); - track(id, taskId); - } - } - - private void presentAndPoll(UUID id, LinkCode code) { - Player player = getServer().getPlayer(id); - if (player == null || !player.isValid()) { - return; // player left during the async mint + /** LimboGate is the flow's view of this server: its scheduler and its players. */ + private final class LimboGate implements LoginFlow.Gate { + private LimboScheduler scheduler() { + return getServer().getScheduler(); } - // Prefer the panel URL the server minted with the code (it is the same - // single source of truth felis-api holds); the env-built consoleUrl is the - // fallback for an older API that does not emit panel_url yet. - String url = code.panelUrl() != null ? code.panelUrl() : consoleUrl; - try { - player.openBook(loginBook(code, url)); - } catch (RuntimeException e) { - // A client that refuses the book (rare) still gets the chat instructions - // below, so a book failure is not fatal to the flow. - LOG.warning("FelisLimbo: openBook failed for " + id + " — " + e.getMessage() - + "; the code is still sent in chat"); - } - player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code()); - player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + url - + " §e完成登录(勿用微信/QQ内置浏览器)。"); - player.sendMessage("§7Open " + url + " in your system browser (not WeChat/QQ) to finish."); - - long deadline = System.currentTimeMillis() + timeoutMillis; - int taskId = getServer().getScheduler().runTaskTimerAsync( - this, () -> pollOnce(id, deadline), POLL_PERIOD_TICKS, POLL_PERIOD_TICKS); - track(id, taskId); - } - - private void pollOnce(UUID id, long deadline) { - Player player = getServer().getPlayer(id); - if (player == null || !player.isValid()) { - cancelPoll(id); // player left; stop polling their UUID - return; - } - if (System.currentTimeMillis() > deadline) { - cancelPoll(id); - disconnectOnMain(id, "登录超时,请重连 / Login timed out. Please reconnect."); - return; - } - boolean linked; - try { - linked = apiClient.linkStatus(id); - } catch (LinkException e) { - // A transient poll failure is not fatal — keep trying until the deadline. - OutageTracker.Report report = pollOutage.failure(); - if (report == OutageTracker.Report.DOWN) { - LOG.warning("FelisLimbo: link status poll failed for " + id + " — " + e.getMessage() - + "; players keep waiting, and repeats are summarized every " - + POLL_OUTAGE_REPEAT_MILLIS / 60_000L + " min until it recovers"); - } else if (report == OutageTracker.Report.STILL_DOWN) { - LOG.warning("FelisLimbo: link status polls still failing: " + pollOutage.failures() - + " failed polls over " + pollOutage.downForMillis() / 1000 + " s, last — " - + e.getMessage()); - } - return; - } - if (pollOutage.success() == OutageTracker.Report.RECOVERED) { - LOG.info("FelisLimbo: link status polls recovered after " + pollOutage.lastOutageFailures() - + " failed polls over " + pollOutage.lastOutageMillis() / 1000 + " s"); - } - if (linked) { - getServer().getScheduler().runTask(this, () -> startRelease(id)); - } - } - - // startRelease runs on the main thread once the player is known to be linked: stop - // the link poll, say so once, and start asking the proxy for the lobby. - private void startRelease(UUID id) { - Player player = online(id); - if (player == null || !releasing.add(id)) { - return; - } - cancelPoll(id); - player.sendMessage("§a[Felis] 登录成功,正在进入大厅… / Signed in — sending you to the lobby…"); - releaseAttempt(id, 0, System.currentTimeMillis() + RELEASE_WINDOW_MILLIS); - } - - // releaseAttempt sends one LoginRelease and schedules the next. The proxy re-checks - // the link before it moves the player, and a transient failure there only denies - // that one attempt, so the gate keeps asking (with backoff, silently) until the - // player is gone or the window closes. - private void releaseAttempt(UUID id, int attempt, long deadline) { - Player player = online(id); - if (player == null) { - releasing.remove(id); - cancelPoll(id); - return; - } - if (System.currentTimeMillis() > deadline) { - releasing.remove(id); - LOG.warning("FelisLimbo: " + id + " was not released to the lobby within " - + (RELEASE_WINDOW_MILLIS / 1000) + "s"); - disconnectOnMain(id, "进入大厅失败,请重连 / Could not reach the lobby. Please reconnect."); - return; - } - sendRelease(player); - int taskId = getServer().getScheduler().runTaskLater( - this, () -> releaseAttempt(id, attempt + 1, deadline), backoff(attempt)); - track(id, taskId); - } - - private void sendRelease(Player player) { - try { - player.sendPluginMessage(CONTROL_CHANNEL, Control.encode(ControlFrame.loginRelease(player.getName()))); - } catch (IOException | RuntimeException e) { - // The next attempt sends again; the window bounds how long we keep trying. - LOG.warning("FelisLimbo: could not send the lobby release for " + player.getUniqueId() - + " — " + e.getMessage()); - } - } - - private Player online(UUID id) { - Player player = getServer().getPlayer(id); - return player != null && player.isValid() ? player : null; - } - - // track records the player's current task, cancelling the one it replaces. - private void track(UUID id, int taskId) { - Integer previous = pollTasks.put(id, taskId); - if (previous != null && previous != taskId) { - getServer().getScheduler().cancelTask(previous); - } - } - - private static long backoff(int attempt) { - return BACKOFF_TICKS[Math.min(attempt, BACKOFF_TICKS.length - 1)]; - } - - private void cancelPoll(UUID id) { - Integer taskId = pollTasks.remove(id); - if (taskId != null) { - getServer().getScheduler().cancelTask(taskId); - } - } - - private void disconnectOnMain(UUID id, String message) { - getServer().getScheduler().runTask(this, () -> { - cancelPoll(id); + @Override + public LoginFlow.Seat player(UUID id) { Player player = getServer().getPlayer(id); - if (player != null && player.isValid()) { - player.disconnect(message); - } - }); - } + return player != null && player.isValid() ? new LimboSeat(player) : null; + } - // ---- rendering / wire ---- + @Override + public int runSync(Runnable task) { + return scheduler().runTask(FelisLimboPlugin.this, task::run); + } - private Book loginBook(LinkCode code, String url) { - Component page = Component.text("Felis 登录 / Login\n\n") - .append(Component.text("绑定码 / Code:\n")) - .append(Component.text(code.code() + "\n\n").color(NamedTextColor.GOLD)) - .append(Component.text("▶ 点此打开登录页\n▶ Open login page\n") - .color(NamedTextColor.AQUA) - .clickEvent(ClickEvent.openUrl(url))) - .append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).") - .color(NamedTextColor.GRAY)); - return Book.book( - Component.text("Felis Login"), - Component.text("Felis"), - page); - } + @Override + public int runAsync(Runnable task) { + return scheduler().runTaskAsync(FelisLimboPlugin.this, task::run); + } - // ---- env helpers ---- + @Override + public int runLater(Runnable task, long delayTicks) { + return scheduler().runTaskLater(FelisLimboPlugin.this, task::run, delayTicks); + } - private static int healthPort() { - String raw = System.getenv("FELIS_HEALTH_PORT"); - if (raw != null && !raw.isBlank()) { - try { - int p = Integer.parseInt(raw.trim()); - if (p > 0 && p < 65536) { - return p; - } - } catch (NumberFormatException ignored) { - // fall through to the default - } + @Override + public int runLaterAsync(Runnable task, long delayTicks) { + return scheduler().runTaskLaterAsync(FelisLimboPlugin.this, task::run, delayTicks); + } + + @Override + public int runTimerAsync(Runnable task, long delayTicks, long periodTicks) { + return scheduler().runTaskTimerAsync(FelisLimboPlugin.this, task::run, delayTicks, periodTicks); + } + + @Override + public void cancel(int taskId) { + scheduler().cancelTask(taskId); } - return DEFAULT_PORT; } - private static long loginTimeoutSeconds() { - String raw = System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS"); - if (raw != null && !raw.isBlank()) { - try { - long s = Long.parseLong(raw.trim()); - if (s < MIN_TIMEOUT_SECONDS) { - return MIN_TIMEOUT_SECONDS; - } - if (s > MAX_TIMEOUT_SECONDS) { - return MAX_TIMEOUT_SECONDS; - } - return s; - } catch (NumberFormatException ignored) { - // fall through to the default - } + /** LimboSeat is one Limbo player as the flow sees them. */ + private record LimboSeat(Player player) implements LoginFlow.Seat { + @Override + public String name() { + return player.getName(); } - return DEFAULT_TIMEOUT_SECONDS; - } - private static String trimmed(String raw) { - if (raw == null) { - return null; + @Override + public void chat(String line) { + player.sendMessage(line); + } + + @Override + public void showCode(LinkCode code, String url) { + player.openBook(LoginBook.book(code.code(), url)); + } + + @Override + public void sendControl(byte[] frame) throws IOException { + player.sendPluginMessage(CONTROL_CHANNEL, frame); + } + + @Override + public void disconnect(String reason) { + player.disconnect(reason); } - String t = raw.trim(); - return t.isEmpty() ? null : t; } } diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/GateConfig.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/GateConfig.java new file mode 100644 index 0000000..cc8b2ec --- /dev/null +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/GateConfig.java @@ -0,0 +1,79 @@ +package best.lolicon.felis.limbo; + +/** + * GateConfig reads the login gate's deployment inputs from their raw environment + * values. A value that is missing, blank or out of range falls back to its default + * (or its bound), so a typo in the image env never keeps the gate from starting. + */ +final class GateConfig { + + static final int DEFAULT_HEALTH_PORT = 8080; + // The default window (10 min) matches the Bind Code TTL (linkCodeTTL in + // internal/api): no point holding a player past code expiry, and no point cutting + // them off while it is still valid. + static final long DEFAULT_TIMEOUT_SECONDS = 600L; + static final long MIN_TIMEOUT_SECONDS = 30L; + static final long MAX_TIMEOUT_SECONDS = 3600L; + static final String DEFAULT_LOBBY = "lobby"; + + private GateConfig() { + } + + /** healthPort is FELIS_HEALTH_PORT when it is a port number, else 8080. */ + static int healthPort(String raw) { + String t = trimmed(raw); + if (t != null) { + try { + int p = Integer.parseInt(t); + if (p > 0 && p < 65536) { + return p; + } + } catch (NumberFormatException ignored) { + // fall through to the default + } + } + return DEFAULT_HEALTH_PORT; + } + + /** loginTimeoutSeconds is FELIS_LOGIN_TIMEOUT_SECONDS held to [30, 3600], else 600. */ + static long loginTimeoutSeconds(String raw) { + String t = trimmed(raw); + if (t != null) { + try { + return Math.max(MIN_TIMEOUT_SECONDS, Math.min(MAX_TIMEOUT_SECONDS, Long.parseLong(t))); + } catch (NumberFormatException ignored) { + // fall through to the default + } + } + return DEFAULT_TIMEOUT_SECONDS; + } + + /** + * panelHost is the console host the player links at: the resolved + * FELIS_PANEL_HOSTNAME the provisioner bakes in (it honours a custom + * panel_hostname), else console.<FELIS_ROOT_DOMAIN> for an older operator whose + * env predates it, else null, and the login flow stays off. + */ + static String panelHost(String panelHostname, String rootDomain) { + String host = trimmed(panelHostname); + if (host != null) { + return host; + } + String root = trimmed(rootDomain); + return root != null ? "console." + root : null; + } + + /** lobby is FELIS_LOBBY_SERVER, else "lobby". */ + static String lobby(String raw) { + String t = trimmed(raw); + return t != null ? t : DEFAULT_LOBBY; + } + + static String trimmed(String raw) { + if (raw == null) { + return null; + } + String t = raw.trim(); + return t.isEmpty() ? null : t; + } +} diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginBook.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginBook.java new file mode 100644 index 0000000..68cf91f --- /dev/null +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginBook.java @@ -0,0 +1,29 @@ +package best.lolicon.felis.limbo; + +import net.kyori.adventure.inventory.Book; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +/** + * LoginBook is the book the gate opens on join: the bind code, a line that opens the + * login page when clicked, and the reminder to use the system browser, where + * WebAuthn/passkey works (the WeChat/QQ in-app browsers break it). + */ +final class LoginBook { + + private LoginBook() { + } + + static Book book(String code, String url) { + Component page = Component.text("Felis 登录 / Login\n\n") + .append(Component.text("绑定码 / Code:\n")) + .append(Component.text(code + "\n\n").color(NamedTextColor.GOLD)) + .append(Component.text("▶ 点此打开登录页\n▶ Open login page\n") + .color(NamedTextColor.AQUA) + .clickEvent(ClickEvent.openUrl(url))) + .append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).") + .color(NamedTextColor.GRAY)); + return Book.book(Component.text("Felis Login"), Component.text("Felis"), page); + } +} diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java new file mode 100644 index 0000000..1becb14 --- /dev/null +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java @@ -0,0 +1,312 @@ +package best.lolicon.felis.limbo; + +import best.lolicon.felis.link.Control; +import best.lolicon.felis.link.ControlFrame; +import best.lolicon.felis.link.FelisApiClient; +import best.lolicon.felis.link.LinkClient; +import best.lolicon.felis.link.LinkCode; +import best.lolicon.felis.link.LinkException; +import best.lolicon.felis.link.OutageTracker; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; +import java.util.logging.Logger; + +/** + * LoginFlow is the login gate's per-player flow (see {@link FelisLimboPlugin}): the + * blacklist check, the bind code, the link poll and the lobby release, each with its + * retry and its deadline. It holds no Limbo type; the plugin hands it a {@link Gate} + * (the scheduler and the online players) and a {@link Seat} per player, which is what + * lets LoginFlowTest run the whole flow on a virtual clock against a stub felis-api. + * + *

Threading follows Limbo's: every felis-api call runs in an async task, and every + * touch of a player (chat, book, plugin message, disconnect) in a sync one. + */ +final class LoginFlow { + + /** Gate is the Limbo server as the flow sees it. Delays are in ticks (≈50 ms). */ + interface Gate { + /** player is the online player with this UUID, or null once they have left. */ + Seat player(UUID id); + + int runSync(Runnable task); + + int runAsync(Runnable task); + + int runLater(Runnable task, long delayTicks); + + int runLaterAsync(Runnable task, long delayTicks); + + int runTimerAsync(Runnable task, long delayTicks, long periodTicks); + + void cancel(int taskId); + } + + /** Seat is one player waiting in the gate. */ + interface Seat { + String name(); + + /** chat sends one line, with § colour codes. */ + void chat(String line); + + /** showCode opens the login book. It may throw for a client that refuses it. */ + void showCode(LinkCode code, String url); + + void sendControl(byte[] frame) throws IOException; + + void disconnect(String reason); + } + + // Poll cadence: 20 ticks ≈ 1 s is responsive without hammering felis-api. + static final long POLL_PERIOD_TICKS = 20L; + // Backoff between retries, in ticks (≈1s, 2s, 4s, then every 8s): used both for a + // felis-api outage at join and for re-sending the release until the player leaves. + static final long[] BACKOFF_TICKS = {20L, 40L, 80L, 160L}; + // How long a felis-api outage at join is retried before the player is turned away. + static final long START_RETRY_WINDOW_MILLIS = 60_000L; + // How long the release is re-sent after sign-in before giving up with a message. + static final long RELEASE_WINDOW_MILLIS = 120_000L; + // How often a link-status outage is summarized while it lasts. + static final long POLL_OUTAGE_REPEAT_MILLIS = 5 * 60_000L; + + static final String BLACKLISTED = + "该用户名已被回收保护 / This username is under reclaim protection. Contact staff."; + static final String UNAVAILABLE = + "登录服务暂不可用,请稍后重连 / Login service unavailable, please reconnect shortly."; + static final String TIMED_OUT = "登录超时,请重连 / Login timed out. Please reconnect."; + static final String NO_LOBBY = "进入大厅失败,请重连 / Could not reach the lobby. Please reconnect."; + static final String BUSY = "§e[Felis] 登录服务繁忙,正在重试… / The login service is busy — retrying…"; + static final String SIGNED_IN = "§a[Felis] 登录成功,正在进入大厅… / Signed in — sending you to the lobby…"; + + private final Gate gate; + private final FelisApiClient api; + private final LinkClient link; + private final String consoleUrl; + private final long timeoutMillis; + private final LongSupplier clock; + private final Logger log; + + // Per-player task ids (the link poll, a join retry, or the next release), so a + // completed/abandoned login cancels its own timer rather than polling a departed + // UUID forever. + private final ConcurrentHashMap tasks = new ConcurrentHashMap<>(); + // Players whose release loop is running; the async link poll can observe "linked" + // twice before its cancellation lands, and the release must start once. + private final Set releasing = ConcurrentHashMap.newKeySet(); + // One tracker for every player's link poll: the polls share felis-api, so an outage + // is one event, reported when it starts, every few minutes while it lasts, and when + // it ends, rather than once a second per waiting player. + private final OutageTracker pollOutage; + + LoginFlow(Gate gate, FelisApiClient api, LinkClient link, String consoleUrl, long timeoutMillis, + LongSupplier clock, Logger log) { + this.gate = gate; + this.api = api; + this.link = link; + this.consoleUrl = consoleUrl; + this.timeoutMillis = timeoutMillis; + this.clock = clock; + this.log = log; + this.pollOutage = new OutageTracker(POLL_OUTAGE_REPEAT_MILLIS, clock); + } + + /** join starts a fresh login for a player who just connected. */ + void join(UUID id) { + releasing.remove(id); // a reconnect starts a fresh login + long startedAt = clock.getAsLong(); + // Everything below touches the network; run it off the tick thread so a slow + // felis-api never stalls the server loop. The player waits in the limbo world. + gate.runAsync(() -> begin(id, 0, startedAt)); + } + + /** clear forgets every player; the plugin cancels the tasks themselves. */ + void clear() { + tasks.clear(); + releasing.clear(); + } + + private void begin(UUID id, int attempt, long startedAt) { + if (gate.player(id) == null) { + return; // left while a retry was pending + } + try { + if (api.isBlacklisted(id)) { + disconnect(id, BLACKLISTED); + return; + } + // Check registration before minting: an already-linked player needs no + // bind code, so send them straight to the lobby instead of flashing a + // useless code. Only unlinked players get one. The on-demand /link + // command (proxy + lobby) stays the door to a fresh web session. + if (api.linkStatus(id)) { + gate.runSync(() -> startRelease(id)); + return; + } + LinkCode code = link.requestCode(id); + gate.runSync(() -> presentAndPoll(id, code)); + } catch (LinkException e) { + boolean outage = e.statusCode() == 0 || e.statusCode() >= 500; + if (!outage || clock.getAsLong() - startedAt > START_RETRY_WINDOW_MILLIS) { + // Fail closed: felis-api refused, or stayed unreachable for the whole + // retry window. Refuse the connection rather than let them idle + // unauthenticated. + log.warning("FelisLimbo: login start failed for " + id + " after " + (attempt + 1) + + " attempt(s) — " + e.getMessage()); + disconnect(id, UNAVAILABLE); + return; + } + log.info("FelisLimbo: felis-api unavailable at login for " + id + " (attempt " + (attempt + 1) + + "): " + e.getMessage()); + // Each retry also asks for the lobby: the proxy keeps recent link + // confirmations for exactly this case, so a player signed in minutes ago + // gets through a felis-api restart, and anyone else is refused there. + gate.runSync(() -> { + Seat seat = gate.player(id); + if (seat == null) { + return; + } + if (attempt == 0) { + seat.chat(BUSY); + } + sendRelease(id, seat); + }); + track(id, gate.runLaterAsync(() -> begin(id, attempt + 1, startedAt), backoff(attempt))); + } + } + + private void presentAndPoll(UUID id, LinkCode code) { + Seat seat = gate.player(id); + if (seat == null) { + return; // player left during the async mint + } + // Prefer the panel URL the server minted with the code (it is the same + // single source of truth felis-api holds); the env-built consoleUrl is the + // fallback for an older API that does not emit panel_url yet. + String url = code.panelUrl() != null ? code.panelUrl() : consoleUrl; + try { + seat.showCode(code, url); + } catch (RuntimeException e) { + // A client that refuses the book (rare) still gets the chat instructions + // below, so a book failure is not fatal to the flow. + log.warning("FelisLimbo: openBook failed for " + id + " — " + e.getMessage() + + "; the code is still sent in chat"); + } + seat.chat("§e[Felis] 绑定码 / Code: §6" + code.code()); + seat.chat("§e[Felis] 用系统浏览器打开 §b" + url + " §e完成登录(勿用微信/QQ内置浏览器)。"); + seat.chat("§7Open " + url + " in your system browser (not WeChat/QQ) to finish."); + + long deadline = clock.getAsLong() + timeoutMillis; + track(id, gate.runTimerAsync(() -> pollOnce(id, deadline), POLL_PERIOD_TICKS, POLL_PERIOD_TICKS)); + } + + private void pollOnce(UUID id, long deadline) { + if (gate.player(id) == null) { + cancel(id); // player left; stop polling their UUID + return; + } + if (clock.getAsLong() > deadline) { + cancel(id); + disconnect(id, TIMED_OUT); + return; + } + boolean linked; + try { + linked = api.linkStatus(id); + } catch (LinkException e) { + // A transient poll failure is not fatal — keep trying until the deadline. + OutageTracker.Report report = pollOutage.failure(); + if (report == OutageTracker.Report.DOWN) { + log.warning("FelisLimbo: link status poll failed for " + id + " — " + e.getMessage() + + "; players keep waiting, and repeats are summarized every " + + POLL_OUTAGE_REPEAT_MILLIS / 60_000L + " min until it recovers"); + } else if (report == OutageTracker.Report.STILL_DOWN) { + log.warning("FelisLimbo: link status polls still failing: " + pollOutage.failures() + + " failed polls over " + pollOutage.downForMillis() / 1000 + " s, last — " + + e.getMessage()); + } + return; + } + if (pollOutage.success() == OutageTracker.Report.RECOVERED) { + log.info("FelisLimbo: link status polls recovered after " + pollOutage.lastOutageFailures() + + " failed polls over " + pollOutage.lastOutageMillis() / 1000 + " s"); + } + if (linked) { + gate.runSync(() -> startRelease(id)); + } + } + + // startRelease runs on the main thread once the player is known to be linked: stop + // the link poll, say so once, and start asking the proxy for the lobby. + private void startRelease(UUID id) { + Seat seat = gate.player(id); + if (seat == null || !releasing.add(id)) { + return; + } + cancel(id); + seat.chat(SIGNED_IN); + releaseAttempt(id, 0, clock.getAsLong() + RELEASE_WINDOW_MILLIS); + } + + // releaseAttempt sends one LoginRelease and schedules the next. The proxy re-checks + // the link before it moves the player, and a transient failure there only denies + // that one attempt, so the gate keeps asking (with backoff, silently) until the + // player is gone or the window closes. + private void releaseAttempt(UUID id, int attempt, long deadline) { + Seat seat = gate.player(id); + if (seat == null) { + releasing.remove(id); + cancel(id); + return; + } + if (clock.getAsLong() > deadline) { + releasing.remove(id); + log.warning("FelisLimbo: " + id + " was not released to the lobby within " + + (RELEASE_WINDOW_MILLIS / 1000) + "s"); + disconnect(id, NO_LOBBY); + return; + } + sendRelease(id, seat); + track(id, gate.runLater(() -> releaseAttempt(id, attempt + 1, deadline), backoff(attempt))); + } + + private void sendRelease(UUID id, Seat seat) { + try { + seat.sendControl(Control.encode(ControlFrame.loginRelease(seat.name()))); + } catch (IOException | RuntimeException e) { + // The next attempt sends again; the window bounds how long we keep trying. + log.warning("FelisLimbo: could not send the lobby release for " + id + " — " + e.getMessage()); + } + } + + // track records the player's current task, cancelling the one it replaces. + private void track(UUID id, int taskId) { + Integer previous = tasks.put(id, taskId); + if (previous != null && previous != taskId) { + gate.cancel(previous); + } + } + + private static long backoff(int attempt) { + return BACKOFF_TICKS[Math.min(attempt, BACKOFF_TICKS.length - 1)]; + } + + private void cancel(UUID id) { + Integer taskId = tasks.remove(id); + if (taskId != null) { + gate.cancel(taskId); + } + } + + private void disconnect(UUID id, String reason) { + gate.runSync(() -> { + cancel(id); + Seat seat = gate.player(id); + if (seat != null) { + seat.disconnect(reason); + } + }); + } +} diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/Readiness.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/Readiness.java new file mode 100644 index 0000000..d73fef8 --- /dev/null +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/Readiness.java @@ -0,0 +1,66 @@ +package best.lolicon.felis.limbo; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Readiness is the gate's {@code GET /healthz}: 503 "starting" until {@link #markReady}, + * 200 "ok" after. Limbo has no RCON, so this is the signal the MinecraftServer CRD's + * HTTP readinessProbe follows instead of a bare TCP check that would pass the moment + * the socket binds. + */ +final class Readiness { + + static final String PATH = "/healthz"; + + private final HttpServer http; + private final AtomicBoolean ready = new AtomicBoolean(false); + + private Readiness(HttpServer http) { + this.http = http; + } + + /** + * start serves the endpoint on port (0 picks a free one). It throws when the port + * cannot be bound; the caller then leaves the gate NotReady rather than advertising + * an unstarted auth gate. + */ + static Readiness start(int port) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); + Readiness r = new Readiness(server); + server.createContext(PATH, r::handle); + server.setExecutor(null); // default executor: this endpoint is trivial + server.start(); + return r; + } + + int port() { + return http.getAddress().getPort(); + } + + /** markReady flips the endpoint to 200 and reports whether this call did it. */ + boolean markReady() { + return ready.compareAndSet(false, true); + } + + void stop() { + ready.set(false); + http.stop(0); + } + + private void handle(HttpExchange exchange) throws IOException { + boolean up = ready.get(); + byte[] body = (up ? "ok" : "starting").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(up ? 200 : 503, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } +} diff --git a/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java b/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java new file mode 100644 index 0000000..945ddb0 --- /dev/null +++ b/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java @@ -0,0 +1,667 @@ +package best.lolicon.felis.limbo; + +import best.lolicon.felis.link.Control; +import best.lolicon.felis.link.ControlFrame; +import best.lolicon.felis.link.FelisApiClient; +import best.lolicon.felis.link.LinkClient; +import best.lolicon.felis.link.LinkCode; +import best.lolicon.felis.link.LinkConfig; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import net.kyori.adventure.inventory.Book; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +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.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * LoginFlowTest runs the login gate's whole flow on a virtual clock: a fake Limbo + * (scheduler and players) around the real LoginFlow, and the real FelisApiClient and + * LinkClient against a stub felis-api. It checks what a player at the front door + * gets in each case: a linked player goes straight to the lobby, an unlinked one gets + * the code and is released once the web console links them, a barred one is turned + * away, a felis-api outage is retried for its window (asking the proxy for the lobby + * on each try) and a refusal is not, the login and release windows end in the right + * disconnect, a player who leaves stops every timer, and a reconnect starts afresh. + * Then the pieces around it: the readiness endpoint, the env parsing, the book. + * + *

Run: {@code ./gradlew -PlimboVersion= loginTest} in plugins/limbo + * (plugins/test.sh does). + */ +public final class LoginFlowTest { + + private static final String CONSOLE = "https://console.example.test"; + private static final String CODE = "K7Q2-9XPM"; + // The flow logs its retries and refusals; the test's own output is the checks. A + // static field keeps the logger (and so its level) from being collected. + private static final Logger QUIET = Logger.getLogger("LoginFlowTest"); + private static int checks; + + public static void main(String[] args) throws Exception { + QUIET.setLevel(Level.OFF); + Stub stub = new Stub(); + try { + linkedPlayerGoesStraightToTheLobby(stub); + unlinkedPlayerGetsTheCodeAndIsReleasedOnceLinked(stub); + codeWithoutPanelUrlPointsAtTheConsole(stub); + refusedBookStillLeavesTheCodeInChat(stub); + barredPlayerIsTurnedAway(stub); + loginWindowEndsInATimeout(stub); + outageAtJoinIsRetriedThenGivesUp(stub); + outageAtJoinThatRecoversCarriesOn(stub); + refusalAtJoinIsNotRetried(stub); + pollFailuresAreNotFatal(stub); + leavingStopsEveryTimer(stub); + releaseWindowEndsInADisconnect(stub); + reconnectStartsAFreshRelease(stub); + laggingMainThreadReleasesOnce(stub); + reconnectDuringAnOutageRunsOneRetryLoop(stub); + } finally { + stub.stop(); + } + readiness(); + gateConfig(); + loginBook(); + System.out.println("LoginFlowTest OK (" + checks + " checks)"); + } + + // ---- the flow ---- + + private static void linkedPlayerGoesStraightToTheLobby(Stub stub) { + Rig rig = new Rig(stub); + UUID id = rig.stub.player(false, true); + Seat seat = rig.join(id, "Steve"); + assertEq("linked: signed in, no code", List.of(LoginFlow.SIGNED_IN), seat.chat); + assertEq("linked: nothing minted", 0, stub.mints.get()); + assertEq("linked: one release right away", 1, seat.releases.size()); + assertEq("the release names the player", ControlFrame.loginRelease("Steve"), seat.releases.get(0)); + // Re-sent with backoff (1 s, 2 s, 4 s, then every 8 s) while the player is here. + long[] gaps = {20, 40, 80, 160, 160}; + for (int i = 0; i < gaps.length; i++) { + rig.gate.advance(gaps[i] - 1); + assertEq("no release before the " + gaps[i] + "-tick gap", i + 1, seat.releases.size()); + rig.gate.advance(1); + assertEq("release after the " + gaps[i] + "-tick gap", i + 2, seat.releases.size()); + } + rig.gate.leave(id); + rig.gate.advance(400); + assertEq("no release after the player left", 6, seat.releases.size()); + assertEq("no timer left behind", 0, rig.gate.pending()); + assertEq("linked: never disconnected", null, seat.disconnected); + } + + private static void unlinkedPlayerGetsTheCodeAndIsReleasedOnceLinked(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, false); + stub.panelUrl = "https://panel.example.test/link"; + Seat seat = rig.join(id, "Alex"); + assertEq("unlinked: the book carries the code and the minted panel URL", + List.of(CODE + " " + stub.panelUrl), seat.books); + assertTrue("chat carries the code", seat.chat.get(0).contains(CODE)); + assertTrue("chat names the panel URL", seat.chat.get(1).contains(stub.panelUrl) && seat.chat.get(2).contains(stub.panelUrl)); + assertEq("no release before the link", 0, seat.releases.size()); + + int before = stub.hits("/link/status/"); + rig.gate.advance(20 * 5); + assertEq("polled once a second", before + 5, stub.hits("/link/status/")); + + stub.linked.put(id, true); + rig.gate.advance(20); + assertEq("released once linked", 1, seat.releases.size()); + assertEq("told they are through", LoginFlow.SIGNED_IN, seat.chat.get(seat.chat.size() - 1)); + int after = stub.hits("/link/status/"); + rig.gate.advance(20 * 10); + assertEq("the poll stops after the release starts", after, stub.hits("/link/status/")); + assertTrue("the release keeps going instead", seat.releases.size() > 1); + stub.panelUrl = null; + } + + private static void codeWithoutPanelUrlPointsAtTheConsole(Stub stub) { + Rig rig = new Rig(stub); + Seat seat = rig.join(stub.player(false, false), "Alex"); + assertEq("an older felis-api's code: the book falls back to the console", List.of(CODE + " " + CONSOLE), seat.books); + assertTrue("... and so does the chat", seat.chat.get(1).contains(CONSOLE)); + } + + private static void refusedBookStillLeavesTheCodeInChat(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, false); + Seat seat = rig.gate.seat(id, "Alex"); + seat.refusesBook = true; + rig.flow.join(id); + rig.gate.advance(0); + assertTrue("a refused book still leaves the code in chat", seat.chat.get(0).contains(CODE)); + stub.linked.put(id, true); + rig.gate.advance(20); + assertEq("... and the login carries on", 1, seat.releases.size()); + } + + private static void barredPlayerIsTurnedAway(Stub stub) { + Rig rig = new Rig(stub); + int mints = stub.mints.get(); + UUID id = stub.player(true, true); + Seat seat = rig.join(id, "Squatter"); + assertEq("barred: disconnected with the reclaim notice", LoginFlow.BLACKLISTED, seat.disconnected); + assertEq("barred: nothing minted", mints, stub.mints.get()); + assertEq("barred: no release even though linked", 0, seat.releases.size()); + assertEq("barred: no timer left", 0, rig.gate.pending()); + } + + private static void loginWindowEndsInATimeout(Stub stub) { + Rig rig = new Rig(stub, 30_000L); + Seat seat = rig.join(stub.player(false, false), "Slow"); + rig.gate.advance(600); // exactly 30 s after the code + assertEq("still waiting at the end of the window", null, seat.disconnected); + rig.gate.advance(20); + assertEq("timed out right after it", LoginFlow.TIMED_OUT, seat.disconnected); + int polls = stub.hits("/link/status/"); + rig.gate.advance(200); + assertEq("no poll after the timeout", polls, stub.hits("/link/status/")); + assertEq("no timer left", 0, rig.gate.pending()); + } + + private static void outageAtJoinIsRetriedThenGivesUp(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, false); + stub.failWith = 500; + Seat seat = rig.join(id, "Early"); + assertEq("the player hears once that it is retrying", List.of(LoginFlow.BUSY), seat.chat); + assertEq("each try asks the proxy for the lobby", 1, seat.releases.size()); + rig.gate.advance(1180); // 59 s + assertEq("still retrying inside the minute", null, seat.disconnected); + int tries = seat.releases.size(); + assertTrue("retried with backoff, not every tick: " + tries, tries >= 8 && tries <= 12); + assertEq("... and says 'retrying' only once", List.of(LoginFlow.BUSY), seat.chat); + rig.gate.advance(160); + assertEq("turned away once the minute is over", LoginFlow.UNAVAILABLE, seat.disconnected); + rig.gate.advance(400); + assertEq("no retry after giving up", 0, rig.gate.pending()); + stub.failWith = 0; + } + + private static void outageAtJoinThatRecoversCarriesOn(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, false); + stub.failWith = 503; + Seat seat = rig.join(id, "Early"); + rig.gate.advance(20 + 40 - 1); + assertEq("still down: no code yet", List.of(), seat.books); + stub.failWith = 0; + rig.gate.advance(1); + assertEq("felis-api back: the code comes", List.of(CODE + " " + CONSOLE), seat.books); + assertEq("... and nobody was turned away", null, seat.disconnected); + } + + private static void refusalAtJoinIsNotRetried(Stub stub) { + Rig rig = new Rig(stub); + int mints = stub.mints.get(); + stub.mintStatus = 429; + Seat seat = rig.join(stub.player(false, false), "Spammy"); + assertEq("a refused mint turns the player away at once", LoginFlow.UNAVAILABLE, seat.disconnected); + assertEq("... after one try", mints + 1, stub.mints.get()); + assertEq("... without asking for the lobby", 0, seat.releases.size()); + stub.mintStatus = 201; + } + + private static void pollFailuresAreNotFatal(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, false); + Seat seat = rig.join(id, "Patient"); + stub.failWith = 500; + rig.gate.advance(20 * 30); + assertEq("half a minute of failed polls: still waiting", null, seat.disconnected); + stub.failWith = 0; + stub.linked.put(id, true); + rig.gate.advance(20); + assertEq("released once felis-api answers again", 1, seat.releases.size()); + } + + private static void leavingStopsEveryTimer(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, false); + rig.join(id, "Gone"); + rig.gate.leave(id); + rig.gate.advance(20); + int polls = stub.hits("/link/status/"); + rig.gate.advance(20 * 20); + assertEq("no poll for a player who left", polls, stub.hits("/link/status/")); + assertEq("no timer left", 0, rig.gate.pending()); + } + + private static void releaseWindowEndsInADisconnect(Stub stub) { + Rig rig = new Rig(stub); + Seat seat = rig.join(stub.player(false, true), "Stuck"); + rig.gate.advance(2400 - 1); // just short of two minutes + assertEq("still being released inside the window", null, seat.disconnected); + rig.gate.advance(160); + assertEq("told the lobby could not be reached", LoginFlow.NO_LOBBY, seat.disconnected); + assertEq("no timer left", 0, rig.gate.pending()); + } + + private static void reconnectStartsAFreshRelease(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, true); + rig.join(id, "Back"); + rig.gate.leave(id); + Seat again = rig.join(id, "Back"); // back before the release loop noticed + assertEq("the reconnect is told it is through again", List.of(LoginFlow.SIGNED_IN), again.chat); + assertEq("... and released", 1, again.releases.size()); + rig.gate.advance(20); + assertEq("one release loop, not two", 2, again.releases.size()); + } + + private static void laggingMainThreadReleasesOnce(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, false); + Seat seat = rig.join(id, "Busy"); + // The main thread falls two polls behind: the async poll sees "linked" at 20, + // 40 and 60 before the first queued release start runs at 65. + rig.gate.syncLag = 45; + stub.linked.put(id, true); + rig.gate.advance(120); + long signedIn = seat.chat.stream().filter(LoginFlow.SIGNED_IN::equals).count(); + assertEq("a lagging main thread still says 'signed in' once", 1L, signedIn); + assertEq("... and runs one release loop (65, then 85 and 125)", 2, seat.releases.size()); + } + + private static void reconnectDuringAnOutageRunsOneRetryLoop(Stub stub) { + Rig rig = new Rig(stub); + UUID id = stub.player(false, false); + stub.failWith = 500; + rig.join(id, "Flaky"); // first try fails, the next is due at 20 + rig.gate.leave(id); + rig.gate.advance(5); + Seat again = rig.join(id, "Flaky"); // fails at 5, the next is due at 25 + rig.gate.advance(20); + assertEq("the old retry was replaced: tries at 5 and 25 only", 2, again.releases.size()); + stub.failWith = 0; + rig.gate.advance(40); + assertEq("one code once felis-api is back", 1, again.books.size()); + } + + // ---- around the flow ---- + + private static void readiness() throws Exception { + Readiness r = Readiness.start(0); + try { + HttpClient http = HttpClient.newHttpClient(); + URI uri = URI.create("http://127.0.0.1:" + r.port() + Readiness.PATH); + HttpResponse res = http.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString()); + assertEq("readiness before the first tick", "503 starting", res.statusCode() + " " + res.body()); + assertTrue("the first markReady flips it", r.markReady()); + assertTrue("a second one does not", !r.markReady()); + res = http.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString()); + assertEq("readiness after the first tick", "200 ok", res.statusCode() + " " + res.body()); + try { + Readiness.start(r.port()).stop(); + throw new AssertionError("a second endpoint bound a taken port"); + } catch (IOException expected) { + checks++; // the plugin then stays NotReady + } + } finally { + r.stop(); + } + } + + private static void gateConfig() { + Map ports = new HashMap<>(); + ports.put(null, 8080); + ports.put("", 8080); + ports.put("abc", 8080); + ports.put("0", 8080); + ports.put("65536", 8080); + ports.put(" 9090 ", 9090); + ports.forEach((raw, want) -> assertEq("health port " + raw, want, GateConfig.healthPort(raw))); + + Map windows = new HashMap<>(); + windows.put(null, 600L); + windows.put("soon", 600L); + windows.put("5", 30L); + windows.put("99999", 3600L); + windows.put(" 120 ", 120L); + windows.forEach((raw, want) -> assertEq("login window " + raw, want, GateConfig.loginTimeoutSeconds(raw))); + + assertEq("panel host wins", "panel.example.test", GateConfig.panelHost(" panel.example.test ", "example.test")); + assertEq("console. without it", "console.example.test", GateConfig.panelHost(" ", "example.test")); + assertEq("neither: login stays off", null, GateConfig.panelHost(null, "")); + assertEq("lobby default", "lobby", GateConfig.lobby(null)); + assertEq("lobby set", "hub", GateConfig.lobby(" hub ")); + } + + private static void loginBook() { + Book book = LoginBook.book(CODE, CONSOLE + "/link"); + assertEq("book title", "Felis Login", plain(book.title())); + Component page = book.pages().get(0); + assertTrue("the page shows the code", plain(page).contains(CODE)); + List clicks = new ArrayList<>(); + collectClicks(page, clicks); + assertEq("one click, opening the login page", List.of(ClickEvent.openUrl(CONSOLE + "/link")), clicks); + assertTrue("the page says to use the system browser", plain(page).contains("SYSTEM browser")); + } + + // ---- fakes ---- + + /** Rig is one fresh gate and flow over the shared stub. */ + private static final class Rig { + final Stub stub; + final FakeGate gate = new FakeGate(); + final LoginFlow flow; + + Rig(Stub stub) { + this(stub, 600_000L); + } + + Rig(Stub stub, long timeoutMillis) { + this.stub = stub; + LinkConfig cfg = new LinkConfig(stub.base(), "gate-token"); + flow = new LoginFlow(gate, new FelisApiClient(cfg), new LinkClient(cfg), CONSOLE, timeoutMillis, + gate::now, QUIET); + } + + /** join seats a player, fires the join and runs what is due right away. */ + Seat join(UUID id, String name) { + Seat seat = gate.seat(id, name); + flow.join(id); + gate.advance(0); + return seat; + } + } + + /** + * FakeGate is a Limbo scheduler on a virtual clock (a tick is 50 ms): tasks run on + * the test thread in due order when the test advances time, sync and async alike. + */ + private static final class FakeGate implements LoginFlow.Gate { + private static final class Task { + final int id; + final Runnable body; + final long period; + long due; + long seq; + + Task(int id, Runnable body, long due, long period) { + this.id = id; + this.body = body; + this.due = due; + this.period = period; + } + } + + private final Map online = new HashMap<>(); + private final Map tasks = new HashMap<>(); + private int nextId = 1; + private long nextSeq; + private long tick; + // syncLag delays every sync task, as a main thread running behind would. + long syncLag; + + long now() { + return 1_700_000_000_000L + tick * 50L; + } + + Seat seat(UUID id, String name) { + Seat seat = new Seat(name); + online.put(id, seat); + return seat; + } + + void leave(UUID id) { + online.remove(id); + } + + int pending() { + return tasks.size(); + } + + void advance(long ticks) { + long target = tick + ticks; + while (true) { + Task next = null; + for (Task t : tasks.values()) { + if (t.due <= target && (next == null || t.due < next.due || (t.due == next.due && t.seq < next.seq))) { + next = t; + } + } + if (next == null) { + break; + } + tick = Math.max(tick, next.due); + if (next.period > 0) { + next.due = tick + next.period; + next.seq = nextSeq++; + } else { + tasks.remove(next.id); + } + next.body.run(); + } + tick = target; + } + + private int schedule(Runnable body, long delay, long period) { + Task t = new Task(nextId++, body, tick + delay, period); + t.seq = nextSeq++; + tasks.put(t.id, t); + return t.id; + } + + @Override + public LoginFlow.Seat player(UUID id) { + return online.get(id); + } + + @Override + public int runSync(Runnable task) { + return schedule(task, syncLag, 0); + } + + @Override + public int runAsync(Runnable task) { + return schedule(task, 0, 0); + } + + @Override + public int runLater(Runnable task, long delayTicks) { + return schedule(task, delayTicks, 0); + } + + @Override + public int runLaterAsync(Runnable task, long delayTicks) { + return schedule(task, delayTicks, 0); + } + + @Override + public int runTimerAsync(Runnable task, long delayTicks, long periodTicks) { + return schedule(task, delayTicks, periodTicks); + } + + @Override + public void cancel(int taskId) { + tasks.remove(taskId); + } + } + + /** Seat records what the gate did to one player. */ + private static final class Seat implements LoginFlow.Seat { + final String name; + final List chat = new ArrayList<>(); + final List books = new ArrayList<>(); + final List releases = new ArrayList<>(); + String disconnected; + boolean refusesBook; + + Seat(String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public void chat(String line) { + chat.add(line); + } + + @Override + public void showCode(LinkCode code, String url) { + if (refusesBook) { + throw new IllegalStateException("client refused the book"); + } + books.add(code.code() + " " + url); + } + + @Override + public void sendControl(byte[] frame) { + releases.add(Control.decode(frame)); + } + + @Override + public void disconnect(String reason) { + if (disconnected != null) { + throw new AssertionError("disconnected twice: " + disconnected + " then " + reason); + } + disconnected = reason; + } + } + + /** + * Stub is felis-api's internal face as the gate uses it: the blacklist, the link + * status and the code mint, with a switch that fails every call with one status. + */ + private static final class Stub { + final HttpServer http; + final Set barred = ConcurrentHashMap.newKeySet(); + final Map linked = new ConcurrentHashMap<>(); + final Map hits = new ConcurrentHashMap<>(); + final AtomicInteger mints = new AtomicInteger(); + volatile int failWith; + volatile int mintStatus = 201; + volatile String panelUrl; + + Stub() throws IOException { + http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + http.createContext("/", this::handle); + http.start(); + } + + String base() { + return "http://127.0.0.1:" + http.getAddress().getPort(); + } + + UUID player(boolean isBarred, boolean isLinked) { + UUID id = UUID.randomUUID(); + if (isBarred) { + barred.add(id); + } + linked.put(id, isLinked); + return id; + } + + int hits(String part) { + AtomicInteger n = hits.get(part); + return n == null ? 0 : n.get(); + } + + void stop() { + http.stop(0); + } + + private void handle(HttpExchange ex) throws IOException { + String path = ex.getRequestURI().getPath(); + if (!"Bearer gate-token".equals(ex.getRequestHeaders().getFirst("Authorization"))) { + reply(ex, 401, "{\"error\":{\"code\":\"unauthorized\",\"message\":\"unauthorized\"}}"); + return; + } + String kind = path.contains("/link/status/") ? "/link/status/" + : path.contains("/blacklist/") ? "/blacklist/" + : path.endsWith("/link/code") ? "/link/code" : path; + hits.computeIfAbsent(kind, k -> new AtomicInteger()).incrementAndGet(); + if (failWith != 0) { + reply(ex, failWith, "{\"error\":{\"code\":\"unavailable\",\"message\":\"unavailable\"}}"); + return; + } + String tail = path.substring(path.lastIndexOf('/') + 1); + switch (kind) { + case "/blacklist/" -> reply(ex, 200, "{\"blacklisted\":" + barred.contains(UUID.fromString(tail)) + "}"); + case "/link/status/" -> reply(ex, 200, "{\"linked\":" + linked.getOrDefault(UUID.fromString(tail), false) + "}"); + case "/link/code" -> { + mints.incrementAndGet(); + if (mintStatus != 201) { + reply(ex, mintStatus, "{\"error\":{\"code\":\"rate_limited\",\"message\":\"slow down\"}}"); + return; + } + String url = panelUrl; + reply(ex, 201, "{\"code\":\"" + CODE + "\",\"expires_at\":\"2026-09-27T12:10:00Z\"" + + (url != null ? ",\"panel_url\":\"" + url + "\"" : "") + "}"); + } + default -> reply(ex, 404, "{\"error\":{\"code\":\"not_found\",\"message\":\"not_found\"}}"); + } + } + + private static void reply(HttpExchange ex, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "application/json"); + ex.sendResponseHeaders(status, bytes.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + } + } + + // ---- helpers ---- + + private static String plain(Component c) { + StringBuilder sb = new StringBuilder(); + if (c instanceof TextComponent t) { + sb.append(t.content()); + } + for (Component child : c.children()) { + sb.append(plain(child)); + } + return sb.toString(); + } + + private static void collectClicks(Component c, List out) { + if (c.clickEvent() != null) { + out.add(c.clickEvent()); + } + for (Component child : c.children()) { + collectClicks(child, out); + } + } + + private static void assertTrue(String what, boolean ok) { + if (!ok) { + throw new AssertionError(what); + } + checks++; + } + + private static void assertEq(String what, Object want, Object got) { + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + ": got " + got + ", want " + want); + } + checks++; + } +} diff --git a/plugins/neoforge/src/main/java/best/lolicon/felis/neoforge/FelisNeoForgeMod.java b/plugins/neoforge/src/main/java/best/lolicon/felis/neoforge/FelisNeoForgeMod.java index 4753ef7..7b74f1b 100644 --- a/plugins/neoforge/src/main/java/best/lolicon/felis/neoforge/FelisNeoForgeMod.java +++ b/plugins/neoforge/src/main/java/best/lolicon/felis/neoforge/FelisNeoForgeMod.java @@ -1,11 +1,9 @@ package best.lolicon.felis.neoforge; import best.lolicon.felis.link.LinkClient; -import best.lolicon.felis.link.LinkCode; import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfigLoader; -import best.lolicon.felis.link.LinkException; -import best.lolicon.felis.link.ModLinkPolicy; +import best.lolicon.felis.link.ModLink; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.exceptions.CommandSyntaxException; @@ -13,7 +11,6 @@ import com.mojang.logging.LogUtils; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; -import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.SubscribeEvent; @@ -25,16 +22,16 @@ import org.slf4j.Logger; import java.io.IOException; import java.util.UUID; -import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executor; import java.util.concurrent.Executors; /** * FelisNeoForgeMod is the NeoForge server-side leg of the §10 account-link flow. On * {@link RegisterCommandsEvent} it installs a {@code /link} command that mints a - * one-time code from felis-api for the player's already-verified UUID. As on the - * other loaders, the HTTP call runs on a daemon I/O thread and the reply is hopped - * back onto the server thread; failures collapse to a generic chat line with details - * kept to the server log. If config is missing the mod stays loaded but never + * one-time code from felis-api for the player's already-verified UUID. The command + * is the shared {@link ModLink}, as on the other loaders (the HTTP call on a daemon + * I/O thread, the reply back on the server thread, one generic line for any + * failure). If config is missing the mod stays loaded but never * registers the command, so the server runs un-crippled. * *

NeoForge constructs the mod with the mod event bus injected; the command event @@ -44,18 +41,18 @@ import java.util.concurrent.Executors; public final class FelisNeoForgeMod { private static final Logger LOGGER = LogUtils.getLogger(); - private final ExecutorService io = Executors.newSingleThreadExecutor(r -> { + private final Executor io = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "felis-link-io"); t.setDaemon(true); return t; }); - private LinkClient linkClient; + private ModLink link; public FelisNeoForgeMod(IEventBus modEventBus) { try { LinkConfig config = LinkConfigLoader.load( FMLPaths.CONFIGDIR.get().resolve("felis-link.properties")); - this.linkClient = new LinkClient(config); + this.link = new ModLink(new LinkClient(config), io, LOGGER::warn); NeoForge.EVENT_BUS.register(this); LOGGER.info("Felis link ready; /link will be registered."); } catch (IOException e) { @@ -69,44 +66,48 @@ public final class FelisNeoForgeMod { } private void register(CommandDispatcher dispatcher) { - dispatcher.register(Commands.literal("link").executes(ctx -> { - CommandSourceStack source = ctx.getSource(); - ServerPlayer player; - try { - player = source.getPlayerOrException(); - } catch (CommandSyntaxException e) { - source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player.")); - return 0; - } - if (!source.getServer().usesAuthentication()) { - LOGGER.warn(ModLinkPolicy.OFFLINE_LOG); - source.sendFailure(Component.literal(ModLinkPolicy.OFFLINE_REPLY)); - return 0; - } - requestAndReply(source.getServer(), player); - return 1; - })); + dispatcher.register(Commands.literal("link").executes(ctx -> link.run(new LinkSource(ctx.getSource())))); } - private void requestAndReply(MinecraftServer server, ServerPlayer player) { - UUID uuid = player.getUUID(); - player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…")); - io.submit(() -> { + /** LinkSource is a command source as the shared /link sees it. */ + private static final class LinkSource implements ModLink.Source { + private final CommandSourceStack source; + private final ServerPlayer player; // null for the console or a command block + + LinkSource(CommandSourceStack source) { + this.source = source; + ServerPlayer p; try { - LinkCode code = linkClient.requestCode(uuid); - server.execute(() -> { - player.sendSystemMessage(Component.literal( - "绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)")); - player.sendSystemMessage(Component.literal(code.panelUrl() != null - ? "在此完成绑定 / Finish linking at: " + code.panelUrl() - : "在网页控制台 → 账户 中输入 / Enter it on the web console → Account.")); - }); - } catch (LinkException e) { - LOGGER.warn("link code request failed for {} (status={}, code={}): {}", - uuid, e.statusCode(), e.errorCode(), e.getMessage()); - server.execute(() -> player.sendSystemMessage(Component.literal( - "现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment."))); + p = source.getPlayerOrException(); + } catch (CommandSyntaxException e) { + p = null; } - }); + this.player = p; + } + + @Override + public UUID player() { + return player != null ? player.getUUID() : null; + } + + @Override + public boolean onlineMode() { + return source.getServer().usesAuthentication(); + } + + @Override + public void fail(String line) { + source.sendFailure(Component.literal(line)); + } + + @Override + public void tell(String line) { + player.sendSystemMessage(Component.literal(line)); + } + + @Override + public void onServerThread(Runnable task) { + source.getServer().execute(task); + } } } diff --git a/plugins/paper/build.gradle b/plugins/paper/build.gradle index 7c7415b..8714d27 100644 --- a/plugins/paper/build.gradle +++ b/plugins/paper/build.gradle @@ -62,9 +62,10 @@ tasks.withType(JavaCompile).configureEach { } // LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events around -// Proxy-built fakes, and MenuTilesTest checks the menu's tile judgement (plain mains, no -// framework, like the velocity routing tests). Neither is part of `build`, which the -// lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`, which runs both. +// Proxy-built fakes, MenuTilesTest checks the menu's tile judgement, and LobbyMenuTest +// drives the whole menu (frames, clicks, drags, paging) against a fake screen (plain +// mains, no framework, like the velocity routing tests). None is part of `build`, which +// the lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`, which runs all three. sourceSets { lobbyTest { java { @@ -72,6 +73,7 @@ sourceSets { include 'best/lolicon/felis/paper/Fakes.java' include 'best/lolicon/felis/paper/LobbyGuardTest.java' include 'best/lolicon/felis/paper/MenuTilesTest.java' + include 'best/lolicon/felis/paper/LobbyMenuTest.java' } compileClasspath += sourceSets.main.output + configurations.compileClasspath runtimeClasspath += output + compileClasspath @@ -85,10 +87,17 @@ tasks.register('menuTilesTest', JavaExec) { mainClass = 'best.lolicon.felis.paper.MenuTilesTest' } +tasks.register('lobbyMenuTest', JavaExec) { + group = 'verification' + description = 'Runs the LobbyMenuTest self-test main.' + classpath = sourceSets.lobbyTest.runtimeClasspath + mainClass = 'best.lolicon.felis.paper.LobbyMenuTest' +} + tasks.register('lobbyTest', JavaExec) { group = 'verification' - description = 'Runs the LobbyGuardTest and MenuTilesTest self-test mains.' - dependsOn 'menuTilesTest' + description = 'Runs the LobbyGuardTest, MenuTilesTest and LobbyMenuTest self-test mains.' + dependsOn 'menuTilesTest', 'lobbyMenuTest' classpath = sourceSets.lobbyTest.runtimeClasspath mainClass = 'best.lolicon.felis.paper.LobbyGuardTest' } diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java index e77647f..f723246 100644 --- a/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java @@ -11,21 +11,14 @@ import org.bukkit.Material; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.plugin.messaging.PluginMessageListener; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; /** * FelisPaperPlugin is the felis-paper lobby face (spec §12): the {@code /menu} (and @@ -61,18 +54,14 @@ import java.util.concurrent.ConcurrentHashMap; * player here; wake-path refusals (policy gate, capacity) are chat messages the * proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady} * just before the proxy Connects them. + * + *

What the menu does lives in {@link LobbyMenu}, which {@code ./gradlew lobbyTest} + * drives against real paper-api events; this class wires it up and draws its slots + * as items. */ -public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener { +public final class FelisPaperPlugin extends JavaPlugin { - // Bottom-row navigation slots on a paged menu. - private static final int PREV_SLOT = 45; - private static final int PAGE_SLOT = 49; - private static final int NEXT_SLOT = 53; - // How long /menu waits for the proxy's ListUpdate before saying the list is down. - private static final long LIST_TIMEOUT_TICKS = 60L; - - /** Players who ran /menu and are waiting for the proxy's ListUpdate. */ - private final Map pendingOpen = new ConcurrentHashMap<>(); + private LobbyMenu menu; @Override public void onEnable() { @@ -81,11 +70,14 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug getLogger().info("config.yml 'servers' is no longer read: the menu lists what the proxy routes."); } + LobbyMenu menu = new LobbyMenu(PaperScreen::new, + (task, ticks) -> getServer().getScheduler().runTaskLater(this, task, ticks), getLogger()); + this.menu = menu; // Open both ends of felis:control. Outgoing carries Wake/Claim/StatusQuery to // the proxy; incoming receives StatusUpdate/TransferReady/Error back. getServer().getMessenger().registerOutgoingPluginChannel(this, Control.CHANNEL); - getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this); - getServer().getPluginManager().registerEvents(this, this); + getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, menu); + getServer().getPluginManager().registerEvents(menu, this); // The lobby is a hub nobody can hurt or be hurt in (LobbyGuard). Worlds loaded // before this point get the rules here, later ones on their WorldLoadEvent. @@ -105,215 +97,87 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug sender.sendMessage(Component.text("Only a player can open the server menu.", NamedTextColor.RED)); return true; } - openMenu((Player) sender); + menu.request((Player) sender); return true; } - private void openMenu(Player player) { - UUID id = player.getUniqueId(); - pendingOpen.put(id, Boolean.TRUE); - sendUpstream(player, ControlFrame.listRequest()); - getServer().getScheduler().runTaskLater(this, () -> { - if (pendingOpen.remove(id) != null && player.isOnline()) { - boolean zh = zh(player); - player.sendMessage(Component.text( - zh ? "暂时拿不到服务器列表,请稍后再试。" - : "The server list isn't available right now — please try again shortly.", - NamedTextColor.YELLOW)); - } - }, LIST_TIMEOUT_TICKS); + /** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */ + static boolean zh(Player player) { + return "zh".equalsIgnoreCase(player.locale().getLanguage()); } - private void openPage(Player player, List all, Map access, int page) { - boolean zh = zh(player); - if (all.isEmpty()) { - player.sendMessage(Component.text( - zh ? "还没有可加入的服务器——在网页控制台创建一个吧。" - : "There are no servers to join yet — create one on the web console.", - NamedTextColor.YELLOW)); - return; - } - int pages = MenuHolder.pageCount(all.size()); - int p = Math.max(0, Math.min(page, pages - 1)); - MenuHolder holder = new MenuHolder(all, access, p); - List view = holder.servers(); - int size = pages > 1 ? 54 : invSize(view.size()); - Inventory inv = Bukkit.createInventory(holder, size, menuTitle(zh, p, pages)); - holder.setInventory(inv); - for (int i = 0; i < view.size(); i++) { - inv.setItem(i, loadingTile(view.get(i), zh)); - } - if (pages > 1) { - if (p > 0) { - inv.setItem(PREV_SLOT, navItem(Material.ARROW, zh ? "上一页" : "Previous page")); - } - inv.setItem(PAGE_SLOT, navItem(Material.PAPER, (p + 1) + " / " + pages)); - if (p < pages - 1) { - inv.setItem(NEXT_SLOT, navItem(Material.ARROW, zh ? "下一页" : "Next page")); - } - } - player.openInventory(inv); - // Ask the proxy for live status of every tile on this page; answers repaint them. - for (String server : view) { - sendUpstream(player, ControlFrame.statusQuery(server)); - } - } + // ---- rendering: PaperScreen draws the menu's slots as items ---- - // ---- click: a tile is a button, never an item to pick up ---- + private final class PaperScreen implements LobbyMenu.Screen { + private final Player player; - @EventHandler - public void onInventoryClick(InventoryClickEvent event) { - Inventory top = event.getView().getTopInventory(); - if (!(top.getHolder() instanceof MenuHolder)) { - return; // not our GUI + PaperScreen(Player player) { + this.player = player; } - // Every slot in our GUI is a button: cancel unconditionally so nothing can be - // taken out, even on clicks in empty slots or the player's own inventory. - event.setCancelled(true); - if (event.getClickedInventory() != top) { - return; // click landed in the player's inventory, not a tile - } - if (!(event.getWhoClicked() instanceof Player)) { - return; - } - Player player = (Player) event.getWhoClicked(); - MenuHolder holder = (MenuHolder) top.getHolder(); - int slot = event.getSlot(); - if (holder.pages() > 1 && (slot == PREV_SLOT || slot == NEXT_SLOT)) { - int target = holder.page() + (slot == PREV_SLOT ? -1 : 1); - if (target >= 0 && target < holder.pages()) { - openPage(player, holder.all(), holder.access(), target); - } - return; - } - if (slot < 0 || slot >= holder.servers().size()) { - return; // padding slot - } - String server = holder.servers().get(slot); - ControlFrame state = holder.latest(server); - if (state == null) { - return; // still loading — no status yet, so we don't know which frame to send - } - boolean zh = zh(player); - MenuTiles.Tile tile = MenuTiles.tile(state, holder.verdict(server), zh); - ControlFrame click = MenuTiles.click(tile, player.getName(), server); - if (click == null) { - // A start felis-api would refuse: say why and leave the menu open, so the - // player can pick another server. - player.sendMessage(Component.text("⚠ " + server + ": " + MenuTiles.chatReason(tile, zh), - NamedTextColor.YELLOW)); - return; - } - sendUpstream(player, click); - player.closeInventory(); - } - @EventHandler - public void onInventoryDrag(InventoryDragEvent event) { - // A drag can deposit into or sweep across our tiles without ever firing a - // single InventoryClickEvent on them, so the click guard alone is not enough: - // cancel any drag that touches our GUI so a tile can never be grabbed or smeared. - if (event.getView().getTopInventory().getHolder() instanceof MenuHolder) { - event.setCancelled(true); + @Override + public String name() { + return player.getName(); } - } - // ---- downstream: felis:control frames from the proxy ---- + @Override + public boolean zh() { + return FelisPaperPlugin.zh(player); + } - @Override - public void onPluginMessageReceived(String channel, Player player, byte[] message) { - if (!Control.CHANNEL.equals(channel)) { - return; + @Override + public boolean online() { + return player.isOnline(); } - ControlFrame frame; - try { - frame = Control.decode(message); - } catch (IllegalArgumentException e) { - getLogger().fine("Dropping malformed felis:control frame: " + e.getMessage()); - return; + + @Override + public void send(ControlFrame frame) { + player.sendPluginMessage(FelisPaperPlugin.this, Control.CHANNEL, Control.encode(frame)); } - switch (frame.type()) { - case ControlFrame.LIST_UPDATE: - // Only a /menu that is still waiting opens; a late answer after the - // timeout message is dropped rather than popping a menu up unasked. - if (pendingOpen.remove(player.getUniqueId()) != null) { - openPage(player, frame.servers(), MenuTiles.accessByName(frame), 0); + + @Override + public void chat(String text, LobbyMenu.Tone tone) { + player.sendMessage(Component.text(text, tone == LobbyMenu.Tone.ERROR ? NamedTextColor.RED : NamedTextColor.YELLOW)); + } + + @Override + public void open(MenuHolder holder, String title, int size, LobbyMenu.Slot[] slots) { + Inventory inv = Bukkit.createInventory(holder, size, + Component.text(title, NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false)); + holder.setInventory(inv); + boolean zh = zh(); + for (int i = 0; i < slots.length; i++) { + if (slots[i] != null) { + inv.setItem(i, item(slots[i], zh)); } - break; - case ControlFrame.STATUS_UPDATE: - applyStatus(player, frame); - break; - case ControlFrame.ERROR: - if (frame.server() != null && markUnavailable(player, frame)) { - break; // a tile's status query failed: shown on the tile itself - } - // The proxy already sanitizes transport faults; this is the only place - // a claim/quota/policy refusal becomes visible to the player. - player.sendMessage(Component.text("⚠ " + errorText(frame, zh(player)), NamedTextColor.RED)); - break; - case ControlFrame.TRANSFER_READY: - // The proxy performs the actual Connect; just make sure a stale menu is - // not left open over the join. - closeIfMenu(player); - break; - default: - // Upstream-only types (Wake/Claim/StatusQuery) are never expected back. + } + player.openInventory(inv); } - } - private void applyStatus(Player player, ControlFrame frame) { - Inventory top = player.getOpenInventory().getTopInventory(); - if (!(top.getHolder() instanceof MenuHolder)) { - return; // the player closed the menu before the answer arrived + @Override + public void paint(MenuHolder holder, int slot, LobbyMenu.Slot content) { + holder.getInventory().setItem(slot, item(content, zh())); } - MenuHolder holder = (MenuHolder) top.getHolder(); - int slot = holder.servers().indexOf(frame.server()); - if (slot < 0) { - return; // a server we are not showing - } - holder.put(frame.server(), frame); - top.setItem(slot, tile(frame, holder.verdict(frame.server()), zh(player))); - } - // markUnavailable repaints a still-loading tile whose status query was refused, so - // it stops saying "loading" forever. Returns false when there is no such tile (the - // error belongs to a click, and goes to chat). - private boolean markUnavailable(Player player, ControlFrame error) { - Inventory top = player.getOpenInventory().getTopInventory(); - if (!(top.getHolder() instanceof MenuHolder)) { - return false; + @Override + public MenuHolder shown() { + InventoryHolder holder = player.getOpenInventory().getTopInventory().getHolder(); + return holder instanceof MenuHolder menuHolder ? menuHolder : null; } - MenuHolder holder = (MenuHolder) top.getHolder(); - int slot = holder.servers().indexOf(error.server()); - if (slot < 0 || holder.latest(error.server()) != null) { - return false; - } - boolean zh = zh(player); - ItemStack item = new ItemStack(Material.BARRIER); - ItemMeta meta = item.getItemMeta(); - meta.displayName(Component.text(error.server(), NamedTextColor.DARK_GRAY) - .decoration(TextDecoration.ITALIC, false)); - meta.lore(List.of(Component.text(errorText(error, zh), NamedTextColor.GRAY) - .decoration(TextDecoration.ITALIC, false))); - item.setItemMeta(meta); - top.setItem(slot, item); - return true; - } - private void closeIfMenu(Player player) { - if (player.getOpenInventory().getTopInventory().getHolder() instanceof MenuHolder) { + @Override + public void close() { player.closeInventory(); } } - // ---- rendering ---- - - private static Component menuTitle(boolean zh, int page, int pages) { - String title = zh ? "Felis 服务器" : "Felis Servers"; - if (pages > 1) { - title += " (" + (page + 1) + "/" + pages + ")"; - } - return Component.text(title, NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false); + private static ItemStack item(LobbyMenu.Slot slot, boolean zh) { + return switch (slot) { + case LobbyMenu.Loading l -> loadingTile(l.server(), zh); + case LobbyMenu.Status s -> tile(s.status(), s.tile(), zh); + case LobbyMenu.Unavailable u -> unavailableTile(u.server(), u.reason()); + case LobbyMenu.Nav n -> navItem(n.arrow() ? Material.ARROW : Material.PAPER, n.label()); + }; } private static ItemStack navItem(Material material, String label) { @@ -324,8 +188,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug return item; } - private ItemStack tile(ControlFrame f, String verdict, boolean zh) { - MenuTiles.Tile t = MenuTiles.tile(f, verdict, zh); + private static ItemStack tile(ControlFrame f, MenuTiles.Tile t, boolean zh) { Material material; NamedTextColor color; switch (t.kind()) { @@ -365,7 +228,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug return item; } - private ItemStack loadingTile(String server, boolean zh) { + private static ItemStack loadingTile(String server, boolean zh) { ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); ItemMeta meta = item.getItemMeta(); meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); @@ -375,71 +238,18 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug return item; } + private static ItemStack unavailableTile(String server, String reason) { + ItemStack item = new ItemStack(Material.BARRIER); + ItemMeta meta = item.getItemMeta(); + meta.displayName(Component.text(server, NamedTextColor.DARK_GRAY).decoration(TextDecoration.ITALIC, false)); + meta.lore(List.of(Component.text(reason, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false))); + item.setItemMeta(meta); + return item; + } + private static Component line(String key, String value) { return Component.text(key + ": ", NamedTextColor.GRAY) .append(Component.text(value, NamedTextColor.WHITE)) .decoration(TextDecoration.ITALIC, false); } - - private static String errorText(ControlFrame f, boolean zh) { - String code = f.code(); - if (code != null) { - switch (code) { - case "not_linked": - return zh ? "请先绑定账号——运行 /link,然后在网页控制台完成绑定。" - : "Link your account first — run /link, then finish on the web console."; - case "quota_exceeded": - return zh ? "你已达到服务器配额上限。" - : "You've reached your server quota."; - case "already_claimed": - return zh ? "该服务器已被认领。" - : "That server is already claimed."; - case "at_capacity": - return zh ? "集群当前已满,请稍后再试。" - : "The cluster is full right now — please try again later."; - case "cooldown": - return zh ? "这台服务器刚被唤醒过,请稍候再试。" - : "That server was just woken — try again in a moment."; - case "forbidden": - return zh ? "你没有权限这样做。" - : "You're not allowed to do that."; - case "not_found": - case "bad_name": - case "invalid_server_name": - return zh ? "这台服务器已不存在。" - : "That server no longer exists."; - case "busy": - return zh ? "Felis 现在很忙,请过一会儿再试。" - : "Felis is busy right now — try again in a moment."; - case "transport_error": - case "interrupted": - return zh ? "Felis 暂时不可用,请稍后再试。" - : "Felis is temporarily unavailable — please try again."; - default: - break; - } - } - // An unmapped code carries felis-api's own English message; a Chinese client - // gets a generic line rather than untranslated text. - if (!zh && f.message() != null && !f.message().isEmpty()) { - return f.message(); - } - return zh ? "请求失败,请重试。" : "Request failed — please try again."; - } - - // ---- helpers ---- - - /** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */ - static boolean zh(Player player) { - return "zh".equalsIgnoreCase(player.locale().getLanguage()); - } - - private void sendUpstream(Player player, ControlFrame frame) { - player.sendPluginMessage(this, Control.CHANNEL, Control.encode(frame)); - } - - private static int invSize(int count) { - int rows = Math.max(1, (count + 8) / 9); - return Math.min(rows, 6) * 9; - } } diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/LobbyMenu.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/LobbyMenu.java new file mode 100644 index 0000000..6f42398 --- /dev/null +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/LobbyMenu.java @@ -0,0 +1,347 @@ +package best.lolicon.felis.paper; + +import best.lolicon.felis.link.Control; +import best.lolicon.felis.link.ControlFrame; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.plugin.messaging.PluginMessageListener; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.logging.Logger; + +/** + * LobbyMenu is what the {@code /menu} chest GUI does (see {@link FelisPaperPlugin} for + * the flow): it asks the proxy for the list, opens a page of tiles, asks for each + * tile's status and repaints it, turns a click into the one frame it stands for, and + * shows what the proxy refuses. It decides what every slot holds ({@link Slot}); the + * plugin's {@link Screen} draws that as items, which is what lets LobbyMenuTest drive + * the whole menu against real paper-api events without a running server. + */ +final class LobbyMenu implements Listener, PluginMessageListener { + + // Bottom-row navigation slots on a paged menu. + static final int PREV_SLOT = 45; + static final int PAGE_SLOT = 49; + static final int NEXT_SLOT = 53; + // How long /menu waits for the proxy's ListUpdate before saying the list is down. + static final long LIST_TIMEOUT_TICKS = 60L; + + /** Screen is one player's client as the menu sees it. */ + interface Screen { + String name(); + + /** zh mirrors the Velocity rule: Chinese when the client locale is zh-*. */ + boolean zh(); + + boolean online(); + + void send(ControlFrame frame); + + void chat(String text, Tone tone); + + /** open shows a new menu page; slots[i] is slot i's content, null for none. */ + void open(MenuHolder holder, String title, int size, Slot[] slots); + + /** paint redraws one slot of a menu page this screen shows. */ + void paint(MenuHolder holder, int slot, Slot content); + + /** shown is the menu page in front of the player, or null for none. */ + MenuHolder shown(); + + void close(); + } + + /** Tone is a chat line's colour: a notice (yellow) or a refusal (red). */ + enum Tone { NOTICE, ERROR } + + /** Slot is what one menu slot holds. */ + sealed interface Slot permits Loading, Status, Unavailable, Nav { + } + + /** Loading is a tile whose status has not come back yet. */ + record Loading(String server) implements Slot { + } + + /** Status is a tile painted from the server's latest status. */ + record Status(ControlFrame status, MenuTiles.Tile tile) implements Slot { + } + + /** Unavailable is a tile whose status query the proxy refused, with the reason. */ + record Unavailable(String server, String reason) implements Slot { + } + + /** Nav is a bottom-row page control: an arrow, or the page number. */ + record Nav(boolean arrow, String label) implements Slot { + } + + /** Later runs a task on the main thread after a number of ticks. */ + interface Later { + void run(Runnable task, long ticks); + } + + private final Function screens; + private final Later later; + private final Logger log; + + // Players who ran /menu and are waiting for the proxy's ListUpdate, each with the + // token of their latest request, so an earlier request's timeout cannot cut a + // later one's wait short. + private final Map pendingOpen = new ConcurrentHashMap<>(); + + LobbyMenu(Function screens, Later later, Logger log) { + this.screens = screens; + this.later = later; + this.log = log; + } + + /** request is /menu: ask the proxy for the list, and give up after a few seconds. */ + void request(Player player) { + UUID id = player.getUniqueId(); + Screen screen = screens.apply(player); + Object token = new Object(); + pendingOpen.put(id, token); + screen.send(ControlFrame.listRequest()); + later.run(() -> { + if (pendingOpen.remove(id, token) && screen.online()) { + screen.chat(screen.zh() ? "暂时拿不到服务器列表,请稍后再试。" + : "The server list isn't available right now — please try again shortly.", Tone.NOTICE); + } + }, LIST_TIMEOUT_TICKS); + } + + // ---- downstream: felis:control frames from the proxy ---- + + @Override + public void onPluginMessageReceived(String channel, Player player, byte[] message) { + if (!Control.CHANNEL.equals(channel)) { + return; + } + ControlFrame frame; + try { + frame = Control.decode(message); + } catch (IllegalArgumentException e) { + log.fine("Dropping malformed felis:control frame: " + e.getMessage()); + return; + } + Screen screen = screens.apply(player); + switch (frame.type()) { + case ControlFrame.LIST_UPDATE: + // Only a /menu that is still waiting opens; a late answer after the + // timeout message is dropped rather than popping a menu up unasked. + if (pendingOpen.remove(player.getUniqueId()) != null) { + openPage(screen, frame.servers(), MenuTiles.accessByName(frame), 0); + } + break; + case ControlFrame.STATUS_UPDATE: + applyStatus(screen, frame); + break; + case ControlFrame.ERROR: + if (frame.server() != null && markUnavailable(screen, frame)) { + break; // a tile's status query failed: shown on the tile itself + } + // The proxy already sanitizes transport faults; this is the only place + // a claim/quota/policy refusal becomes visible to the player. + screen.chat("⚠ " + errorText(frame, screen.zh()), Tone.ERROR); + break; + case ControlFrame.TRANSFER_READY: + // The proxy performs the actual Connect; just make sure a stale menu is + // not left open over the join. + if (screen.shown() != null) { + screen.close(); + } + break; + default: + // Upstream-only types (Wake/Claim/StatusQuery) are never expected back. + } + } + + private void openPage(Screen screen, List all, Map access, int page) { + boolean zh = screen.zh(); + if (all.isEmpty()) { + screen.chat(zh ? "还没有可加入的服务器——在网页控制台创建一个吧。" + : "There are no servers to join yet — create one on the web console.", Tone.NOTICE); + return; + } + int pages = MenuHolder.pageCount(all.size()); + int p = Math.max(0, Math.min(page, pages - 1)); + MenuHolder holder = new MenuHolder(all, access, p); + List view = holder.servers(); + int size = pages > 1 ? 54 : invSize(view.size()); + Slot[] slots = new Slot[size]; + for (int i = 0; i < view.size(); i++) { + slots[i] = new Loading(view.get(i)); + } + if (pages > 1) { + if (p > 0) { + slots[PREV_SLOT] = new Nav(true, zh ? "上一页" : "Previous page"); + } + slots[PAGE_SLOT] = new Nav(false, (p + 1) + " / " + pages); + if (p < pages - 1) { + slots[NEXT_SLOT] = new Nav(true, zh ? "下一页" : "Next page"); + } + } + screen.open(holder, title(zh, p, pages), size, slots); + // Ask the proxy for live status of every tile on this page; answers repaint them. + for (String server : view) { + screen.send(ControlFrame.statusQuery(server)); + } + } + + private void applyStatus(Screen screen, ControlFrame frame) { + MenuHolder holder = screen.shown(); + if (holder == null) { + return; // the player closed the menu before the answer arrived + } + int slot = holder.servers().indexOf(frame.server()); + if (slot < 0) { + return; // a server we are not showing + } + holder.put(frame.server(), frame); + screen.paint(holder, slot, new Status(frame, MenuTiles.tile(frame, holder.verdict(frame.server()), screen.zh()))); + } + + // markUnavailable repaints a still-loading tile whose status query was refused, so + // it stops saying "loading" forever. Returns false when there is no such tile (the + // error belongs to a click, and goes to chat). + private boolean markUnavailable(Screen screen, ControlFrame error) { + MenuHolder holder = screen.shown(); + if (holder == null) { + return false; + } + int slot = holder.servers().indexOf(error.server()); + if (slot < 0 || holder.latest(error.server()) != null) { + return false; + } + screen.paint(holder, slot, new Unavailable(error.server(), errorText(error, screen.zh()))); + return true; + } + + // ---- clicks: a tile is a button, never an item to pick up ---- + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + Inventory top = event.getView().getTopInventory(); + if (!(top.getHolder() instanceof MenuHolder holder)) { + return; // not our GUI + } + // Every slot in our GUI is a button: cancel unconditionally so nothing can be + // taken out, even on clicks in empty slots or the player's own inventory. + event.setCancelled(true); + if (event.getClickedInventory() != top) { + return; // click landed in the player's inventory, not a tile + } + if (event.getWhoClicked() instanceof Player player) { + click(screens.apply(player), holder, event.getSlot()); + } + } + + @EventHandler + public void onInventoryDrag(InventoryDragEvent event) { + // A drag can deposit into or sweep across our tiles without ever firing a + // single InventoryClickEvent on them, so the click guard alone is not enough: + // cancel any drag that touches our GUI so a tile can never be grabbed or smeared. + if (event.getView().getTopInventory().getHolder() instanceof MenuHolder) { + event.setCancelled(true); + } + } + + private void click(Screen screen, MenuHolder holder, int slot) { + if (holder.pages() > 1 && (slot == PREV_SLOT || slot == NEXT_SLOT)) { + int target = holder.page() + (slot == PREV_SLOT ? -1 : 1); + if (target >= 0 && target < holder.pages()) { + openPage(screen, holder.all(), holder.access(), target); + } + return; + } + if (slot < 0 || slot >= holder.servers().size()) { + return; // padding slot + } + String server = holder.servers().get(slot); + ControlFrame state = holder.latest(server); + if (state == null) { + return; // still loading — no status yet, so we don't know which frame to send + } + boolean zh = screen.zh(); + MenuTiles.Tile tile = MenuTiles.tile(state, holder.verdict(server), zh); + ControlFrame click = MenuTiles.click(tile, screen.name(), server); + if (click == null) { + // A start felis-api would refuse: say why and leave the menu open, so the + // player can pick another server. + screen.chat("⚠ " + server + ": " + MenuTiles.chatReason(tile, zh), Tone.NOTICE); + return; + } + screen.send(click); + screen.close(); + } + + // ---- text ---- + + static String title(boolean zh, int page, int pages) { + String title = zh ? "Felis 服务器" : "Felis Servers"; + if (pages > 1) { + title += " (" + (page + 1) + "/" + pages + ")"; + } + return title; + } + + static String errorText(ControlFrame f, boolean zh) { + String code = f.code(); + if (code != null) { + switch (code) { + case "not_linked": + return zh ? "请先绑定账号——运行 /link,然后在网页控制台完成绑定。" + : "Link your account first — run /link, then finish on the web console."; + case "quota_exceeded": + return zh ? "你已达到服务器配额上限。" + : "You've reached your server quota."; + case "already_claimed": + return zh ? "该服务器已被认领。" + : "That server is already claimed."; + case "at_capacity": + return zh ? "集群当前已满,请稍后再试。" + : "The cluster is full right now — please try again later."; + case "cooldown": + return zh ? "这台服务器刚被唤醒过,请稍候再试。" + : "That server was just woken — try again in a moment."; + case "forbidden": + return zh ? "你没有权限这样做。" + : "You're not allowed to do that."; + case "not_found": + case "bad_name": + case "invalid_server_name": + return zh ? "这台服务器已不存在。" + : "That server no longer exists."; + case "busy": + return zh ? "Felis 现在很忙,请过一会儿再试。" + : "Felis is busy right now — try again in a moment."; + case "transport_error": + case "interrupted": + return zh ? "Felis 暂时不可用,请稍后再试。" + : "Felis is temporarily unavailable — please try again."; + default: + break; + } + } + // An unmapped code carries felis-api's own English message; a Chinese client + // gets a generic line rather than untranslated text. + if (!zh && f.message() != null && !f.message().isEmpty()) { + return f.message(); + } + return zh ? "请求失败,请重试。" : "Request failed — please try again."; + } + + // invSize is the smallest chest (one to six rows) that holds count tiles. + static int invSize(int count) { + int rows = Math.max(1, (count + 8) / 9); + return Math.min(rows, 6) * 9; + } +} diff --git a/plugins/paper/test/best/lolicon/felis/paper/LobbyMenuTest.java b/plugins/paper/test/best/lolicon/felis/paper/LobbyMenuTest.java new file mode 100644 index 0000000..6da27a4 --- /dev/null +++ b/plugins/paper/test/best/lolicon/felis/paper/LobbyMenuTest.java @@ -0,0 +1,547 @@ +package best.lolicon.felis.paper; + +import best.lolicon.felis.link.Control; +import best.lolicon.felis.link.ControlFrame; + +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static best.lolicon.felis.paper.Fakes.UNANSWERED; +import static best.lolicon.felis.paper.Fakes.fake; + +/** + * LobbyMenuTest drives the real {@link LobbyMenu} the way Paper does: felis:control + * frames through its plugin-message listener, and real paper-api click and drag events + * over fake inventory views, with a fake screen per player recording what the menu + * showed, painted, sent and said. It checks the menu a player actually gets: /menu + * opens only on a list it is still waiting for, each tile starts as loading and is + * repainted by its own status, a click sends the one frame its tile stands for (a grey + * tile sends nothing and says why), paging walks the whole list with the verdicts + * intact, a refused status query marks its tile while other refusals reach chat in the + * player's language, and nothing in the menu can be picked up or dragged. + * + *

Run: {@code ./gradlew lobbyTest} in plugins/paper (plugins/test.sh does). + */ +public final class LobbyMenuTest { + + private static final Logger QUIET = Logger.getLogger("LobbyMenuTest"); + private static int checks; + + public static void main(String[] args) { + QUIET.setLevel(Level.OFF); + listOpensAsLoadingTiles(); + statusRepaintsItsOwnTile(); + clicksSendWhatTheTileSays(); + nothingCanBeTakenOrDragged(); + listWaitTimesOut(); + eachRequestGetsItsOwnWait(); + emptyListSaysSo(); + pagingWalksTheWholeList(); + refusalsReachThePlayer(); + errorTextInEachLanguage(); + transferReadyClosesOnlyTheMenu(); + strayFramesAreDropped(); + System.out.println("LobbyMenuTest OK (" + checks + " checks)"); + } + + private static void listOpensAsLoadingTiles() { + Rig rig = new Rig(); + FakeScreen s = rig.screen("Steve", true); + rig.menu.request(s.player); + assertEq("/menu asks the proxy for the list", List.of(ControlFrame.listRequest()), s.sent); + assertEq("... and opens nothing yet", 0, s.opened.size()); + + rig.frame(s, ControlFrame.listUpdate(List.of("mine", "open", "locked"), List.of("owner", "wake", "owner_only"))); + assertEq("one page opens", 1, s.opened.size()); + Opened page = s.opened.get(0); + assertEq("title in the player's language", "Felis 服务器", page.title); + assertEq("one row for three servers", 9, page.size); + assertEq("each tile loading, in list order", + Arrays.asList(new LobbyMenu.Loading("mine"), new LobbyMenu.Loading("open"), new LobbyMenu.Loading("locked"), + null, null, null, null, null, null), + Arrays.asList(page.slots)); + assertEq("a status query per tile, in order", + List.of(ControlFrame.listRequest(), ControlFrame.statusQuery("mine"), ControlFrame.statusQuery("open"), + ControlFrame.statusQuery("locked")), + s.sent); + assertEq("the page is what the player sees", page.holder, s.shown); + + FakeScreen other = rig.screen("Alex", false); + rig.frame(other, ControlFrame.listUpdate(List.of("mine"))); + assertEq("a list nobody asked for opens nothing", 0, other.opened.size()); + } + + private static void statusRepaintsItsOwnTile() { + Rig rig = new Rig(); + FakeScreen s = rig.openMenu("Steve", false, List.of("mine", "open", "locked", "free"), + List.of("owner", "wake", "owner_only", "wake")); + rig.frame(s, up("open")); + rig.frame(s, stopped("locked")); + rig.frame(s, stopped("mine")); + rig.frame(s, ownerless("free")); + assertEq("an up server: Join", MenuTiles.Kind.JOIN, kind(s, 1)); + assertEq("owner_only and stopped: grey", MenuTiles.Kind.LOCKED, kind(s, 2)); + assertEq("the player's own stopped server: Start", MenuTiles.Kind.WAKE, kind(s, 0)); + assertTrue("... marked as theirs", ((LobbyMenu.Status) s.slots[0]).tile().mine()); + assertEq("ownerless: Claim", MenuTiles.Kind.CLAIM, kind(s, 3)); + assertEq("the tile keeps the frame it was painted from", up("open"), ((LobbyMenu.Status) s.slots[1]).status()); + + int paints = s.paints; + rig.frame(s, up("elsewhere")); + assertEq("a server not on the page repaints nothing", paints, s.paints); + s.close(); + rig.frame(s, up("open")); + assertEq("a closed menu repaints nothing", paints, s.paints); + } + + private static void clicksSendWhatTheTileSays() { + Rig rig = new Rig(); + List names = List.of("open", "locked", "free", "pending"); + FakeScreen s = rig.openMenu("Steve", true, names, List.of("wake", "owner_only", "wake", "wake")); + rig.frame(s, up("open")); + rig.frame(s, stopped("locked")); + rig.frame(s, ownerless("free")); + int sent = s.sent.size(); + + rig.click(s, 3); + assertEq("a loading tile sends nothing", sent, s.sent.size()); + rig.click(s, 7); + assertEq("an empty slot sends nothing", sent, s.sent.size()); + + rig.click(s, 1); + assertEq("a grey tile sends nothing", sent, s.sent.size()); + assertEq("... says why in chat", "NOTICE ⚠ locked: 只有主人能启动这台服务器,它运行时任何人都能加入。", last(s.chat)); + assertTrue("... and leaves the menu open", s.shown != null); + + rig.click(s, 0); + assertEq("Join sends a wake for this player", ControlFrame.wakeRequest("Steve", "open"), last(s.sent)); + assertEq("... and closes the menu", null, s.shown); + + FakeScreen c = rig.openMenu("Alex", false, names, List.of()); + rig.frame(c, ownerless("free")); + rig.click(c, 2); + assertEq("Claim sends a claim", ControlFrame.claimRequest("Alex", "free"), last(c.sent)); + assertEq("... and closes the menu", null, c.shown); + } + + private static void nothingCanBeTakenOrDragged() { + Rig rig = new Rig(); + FakeScreen s = rig.openMenu("Steve", false, List.of("open"), List.of()); + rig.frame(s, up("open")); + int sent = s.sent.size(); + + assertTrue("a click on a tile is cancelled", rig.click(s, 0).isCancelled()); + s.shown = s.opened.get(0).holder; // the click closed it; look again + assertTrue("a click on an empty slot is cancelled", rig.click(s, 5).isCancelled()); + // Raw slot 9 is the player's own first slot, which Paper also numbers 0: the same + // index as the Join tile above it. + InventoryClickEvent own = rig.click(s, 9); + assertTrue("a click in the player's own inventory is cancelled too", own.isCancelled()); + assertEq("... and acts on no tile", sent + 1, s.sent.size()); + assertTrue("a drag over the menu is cancelled", rig.drag(s.shown, 9).isCancelled()); + + InventoryHolder chest = fake(InventoryHolder.class, (m, a) -> UNANSWERED); + assertTrue("a click in some other chest is left alone", !rig.clickIn(s, chest, 27, 0).isCancelled()); + assertTrue("a drag in some other chest is left alone", !rig.drag(chest, 27).isCancelled()); + assertEq("... and sends nothing", sent + 1, s.sent.size()); + } + + private static void listWaitTimesOut() { + Rig rig = new Rig(); + FakeScreen s = rig.screen("Steve", false); + rig.menu.request(s.player); + rig.ticks.advance(LobbyMenu.LIST_TIMEOUT_TICKS - 1); + assertEq("no message while the list may still come", List.of(), s.chat); + rig.ticks.advance(1); + assertEq("the player hears the list is down", + List.of("NOTICE The server list isn't available right now — please try again shortly."), s.chat); + rig.frame(s, ControlFrame.listUpdate(List.of("open"))); + assertEq("a list after that opens nothing", 0, s.opened.size()); + + FakeScreen gone = rig.screen("Alex", true); + rig.menu.request(gone.player); + gone.online = false; + rig.ticks.advance(LobbyMenu.LIST_TIMEOUT_TICKS); + assertEq("a player who left hears nothing", List.of(), gone.chat); + + FakeScreen answered = rig.screen("Kai", true); + rig.menu.request(answered.player); + rig.frame(answered, ControlFrame.listUpdate(List.of("open"))); + rig.ticks.advance(LobbyMenu.LIST_TIMEOUT_TICKS); + assertEq("an answered /menu never says the list is down", List.of(), answered.chat); + } + + private static void eachRequestGetsItsOwnWait() { + Rig rig = new Rig(); + FakeScreen s = rig.screen("Steve", false); + rig.menu.request(s.player); + rig.ticks.advance(50); + rig.menu.request(s.player); // /menu again before the first gave up + rig.ticks.advance(20); // past the first request's deadline + assertEq("the first timeout does not cut the second wait short", List.of(), s.chat); + rig.frame(s, ControlFrame.listUpdate(List.of("open"))); + assertEq("... so its list still opens", 1, s.opened.size()); + rig.ticks.advance(LobbyMenu.LIST_TIMEOUT_TICKS); + assertEq("... and no timeout follows", List.of(), s.chat); + } + + private static void emptyListSaysSo() { + Rig rig = new Rig(); + FakeScreen s = rig.screen("Steve", true); + rig.menu.request(s.player); + rig.frame(s, ControlFrame.listUpdate(List.of())); + assertEq("no servers: no menu", 0, s.opened.size()); + assertEq("... and a pointer to the console", List.of("NOTICE 还没有可加入的服务器——在网页控制台创建一个吧。"), s.chat); + assertEq("a chest just big enough: 1, 9, 10, 45 tiles", List.of(9, 9, 18, 45), + List.of(LobbyMenu.invSize(1), LobbyMenu.invSize(9), LobbyMenu.invSize(10), LobbyMenu.invSize(45))); + } + + private static void pagingWalksTheWholeList() { + Rig rig = new Rig(); + List names = new ArrayList<>(); + List access = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + names.add(String.format("s%03d", i)); + access.add(i == 50 ? "allowlist" : "wake"); + } + FakeScreen s = rig.openMenu("Steve", false, names, access); + Opened p1 = s.opened.get(0); + assertEq("a long list opens a full chest", 54, p1.size); + assertEq("title counts the pages", "Felis Servers (1/3)", p1.title); + assertEq("45 tiles on the first page", new LobbyMenu.Loading("s044"), p1.slots[44]); + assertEq("no previous arrow on the first page", null, p1.slots[LobbyMenu.PREV_SLOT]); + assertEq("the page number", new LobbyMenu.Nav(false, "1 / 3"), p1.slots[LobbyMenu.PAGE_SLOT]); + assertEq("a next arrow", new LobbyMenu.Nav(true, "Next page"), p1.slots[LobbyMenu.NEXT_SLOT]); + assertEq("status asked for this page only", 1 + 45, s.sent.size()); + + rig.click(s, LobbyMenu.PREV_SLOT); + assertEq("the empty previous slot on page one does nothing", 1, s.opened.size()); + rig.click(s, LobbyMenu.NEXT_SLOT); + Opened p2 = s.opened.get(1); + assertEq("next opens page two", "Felis Servers (2/3)", p2.title); + assertEq("... starting where page one ended", new LobbyMenu.Loading("s045"), p2.slots[0]); + assertEq("... with a previous arrow", new LobbyMenu.Nav(true, "Previous page"), p2.slots[LobbyMenu.PREV_SLOT]); + assertEq("... and asks for its own tiles", ControlFrame.statusQuery("s089"), last(s.sent)); + rig.frame(s, stopped("s050")); + assertEq("the list's verdicts survive the paging", MenuTiles.Kind.LOCKED, kind(s, 5)); + + rig.click(s, LobbyMenu.NEXT_SLOT); + Opened p3 = s.opened.get(2); + assertEq("the last page holds the rest", new LobbyMenu.Loading("s099"), p3.slots[9]); + assertEq("... and no more", null, p3.slots[10]); + assertEq("no next arrow on the last page", null, p3.slots[LobbyMenu.NEXT_SLOT]); + int sent = s.sent.size(); + rig.click(s, LobbyMenu.NEXT_SLOT); + rig.click(s, 20); + assertEq("next and padding on the last page do nothing", List.of(3, sent), List.of(s.opened.size(), s.sent.size())); + rig.click(s, LobbyMenu.PREV_SLOT); + assertEq("previous goes back", "Felis Servers (2/3)", s.opened.get(3).title); + } + + private static void refusalsReachThePlayer() { + Rig rig = new Rig(); + FakeScreen s = rig.openMenu("Steve", true, List.of("gone", "open"), List.of()); + rig.frame(s, ControlFrame.error("not_found", "server not found", "gone")); + assertEq("a refused status query marks its tile", new LobbyMenu.Unavailable("gone", "这台服务器已不存在。"), s.slots[0]); + assertEq("... instead of chat", List.of(), s.chat); + + rig.frame(s, up("open")); + rig.frame(s, ControlFrame.error("cooldown", "woken recently", "open")); + assertEq("a refused click on a painted tile goes to chat", "ERROR ⚠ 这台服务器刚被唤醒过,请稍候再试。", last(s.chat)); + assertEq("... and keeps the tile", MenuTiles.Kind.JOIN, kind(s, 1)); + + rig.frame(s, ControlFrame.error("quota_exceeded", "quota", null)); + assertEq("a refusal naming no server goes to chat", "ERROR ⚠ 你已达到服务器配额上限。", last(s.chat)); + rig.frame(s, ControlFrame.error("already_claimed", "claimed", "elsewhere")); + assertEq("a refusal for a server not on the page goes to chat", "ERROR ⚠ 该服务器已被认领。", last(s.chat)); + s.close(); + rig.frame(s, ControlFrame.error("at_capacity", "full", "gone")); + assertEq("with the menu closed it goes to chat", "ERROR ⚠ 集群当前已满,请稍后再试。", last(s.chat)); + } + + private static void errorTextInEachLanguage() { + String[][] same = {{"not_found", "bad_name"}, {"not_found", "invalid_server_name"}, {"transport_error", "interrupted"}}; + for (String[] pair : same) { + for (boolean zh : new boolean[]{true, false}) { + assertEq(pair[1] + " reads as " + pair[0], text(pair[0], "x", zh), text(pair[1], "y", zh)); + } + } + String[] codes = {"not_linked", "quota_exceeded", "already_claimed", "at_capacity", "cooldown", "forbidden", + "not_found", "busy", "transport_error"}; + Map seen = new HashMap<>(); + for (String code : codes) { + for (boolean zh : new boolean[]{true, false}) { + String t = text(code, "felis-api's words", zh); + assertTrue(code + " has its own line", seen.put(t, code) == null); + assertTrue(code + " is in the player's language: " + t, zh == t.codePoints().anyMatch(c -> c >= 0x4E00)); + assertTrue(code + " hides felis-api's words", !t.contains("felis-api's words")); + } + } + assertTrue("not_linked names /link", text("not_linked", null, true).contains("/link") && text("not_linked", null, false).contains("/link")); + assertEq("an unknown code shows felis-api's message in English", "The world is frozen.", text("new_code", "The world is frozen.", false)); + assertEq("... and a generic line in Chinese", "请求失败,请重试。", text("new_code", "The world is frozen.", true)); + assertEq("no code, no message: generic", "Request failed — please try again.", text(null, "", false)); + assertEq("no code, a message: the message", "Server is retiring.", text(null, "Server is retiring.", false)); + } + + private static void transferReadyClosesOnlyTheMenu() { + Rig rig = new Rig(); + FakeScreen s = rig.openMenu("Steve", false, List.of("open"), List.of()); + rig.frame(s, ControlFrame.transferReady("Steve", "open")); + assertEq("the menu closes before the move", List.of(1, "none"), List.of(s.closes, s.shown == null ? "none" : "open")); + rig.frame(s, ControlFrame.transferReady("Steve", "open")); + assertEq("with no menu open, nothing else is closed", 1, s.closes); + } + + private static void strayFramesAreDropped() { + Rig rig = new Rig(); + FakeScreen s = rig.screen("Steve", false); + rig.menu.request(s.player); + rig.menu.onPluginMessageReceived(Control.CHANNEL, s.player, new byte[]{1, 2, 3}); + rig.menu.onPluginMessageReceived("minecraft:brand", s.player, Control.encode(ControlFrame.listUpdate(List.of("open")))); + assertEq("a malformed frame or another channel opens nothing", 0, s.opened.size()); + rig.frame(s, ControlFrame.wakeRequest("Steve", "open")); + assertEq("an upstream-only frame does nothing", List.of(0, 0), List.of(s.opened.size(), s.chat.size())); + rig.frame(s, ControlFrame.listUpdate(List.of("open"))); + assertEq("... and the /menu still opens on its list", 1, s.opened.size()); + } + + // ---- rig ---- + + private static final class Rig { + final Ticks ticks = new Ticks(); + final Map screens = new HashMap<>(); + final LobbyMenu menu = new LobbyMenu(screens::get, ticks, QUIET); + + FakeScreen screen(String name, boolean zh) { + FakeScreen s = new FakeScreen(name, zh); + screens.put(s.player, s); + return s; + } + + /** openMenu runs /menu for a new player and answers it with a list. */ + FakeScreen openMenu(String name, boolean zh, List servers, List access) { + FakeScreen s = screen(name, zh); + menu.request(s.player); + frame(s, access.isEmpty() ? ControlFrame.listUpdate(servers) : ControlFrame.listUpdate(servers, access)); + return s; + } + + void frame(FakeScreen s, ControlFrame f) { + menu.onPluginMessageReceived(Control.CHANNEL, s.player, Control.encode(f)); + } + + /** click clicks a raw slot of the menu the player has open. */ + InventoryClickEvent click(FakeScreen s, int rawSlot) { + return clickIn(s, s.shown, s.shown == null ? 9 : s.shownSize(), rawSlot); + } + + InventoryClickEvent clickIn(FakeScreen s, InventoryHolder holder, int size, int rawSlot) { + InventoryClickEvent e = new InventoryClickEvent(view(holder, size, s.player), InventoryType.SlotType.CONTAINER, + rawSlot, ClickType.LEFT, InventoryAction.PICKUP_ALL); + menu.onInventoryClick(e); + return e; + } + + InventoryDragEvent drag(InventoryHolder holder, int size) { + Map slots = new HashMap<>(); + slots.put(0, null); + slots.put(1, null); + InventoryDragEvent e = new InventoryDragEvent(view(holder, size, null), null, new ItemStack() { + }, false, slots); + menu.onInventoryDrag(e); + return e; + } + + // view is a chest of size slots above a 36-slot player inventory, as Paper lays + // out an open container: raw slots count the chest first. + private static InventoryView view(InventoryHolder holder, int size, Player player) { + Inventory top = fake(Inventory.class, (m, a) -> switch (m) { + case "getHolder" -> holder; + case "getSize" -> size; + default -> UNANSWERED; + }); + Inventory bottom = fake(Inventory.class, (m, a) -> m.equals("getSize") ? 36 : UNANSWERED); + return fake(InventoryView.class, (m, a) -> switch (m) { + case "getTopInventory" -> top; + case "getBottomInventory" -> bottom; + case "getPlayer" -> player; + case "convertSlot" -> (int) a[0] < size ? a[0] : (int) a[0] - size; + case "getInventory" -> (int) a[0] < 0 ? null : (int) a[0] < size ? top : bottom; + default -> UNANSWERED; + }); + } + } + + /** Ticks runs the menu's delayed tasks on a virtual main thread. */ + private static final class Ticks implements LobbyMenu.Later { + private final List tasks = new ArrayList<>(); + private long now; + + @Override + public void run(Runnable task, long ticks) { + tasks.add(new Object[]{now + ticks, task}); + } + + void advance(long ticks) { + now += ticks; + List due = new ArrayList<>(); + tasks.removeIf(t -> (long) t[0] <= now && due.add(t)); + due.sort((x, y) -> Long.compare((long) x[0], (long) y[0])); + due.forEach(t -> ((Runnable) t[1]).run()); + } + } + + private record Opened(MenuHolder holder, String title, int size, LobbyMenu.Slot[] slots) { + } + + /** FakeScreen is one player's client: what the menu showed, painted, sent and said. */ + private static final class FakeScreen implements LobbyMenu.Screen { + final String name; + final boolean zh; + final Player player; + final List sent = new ArrayList<>(); + final List chat = new ArrayList<>(); + final List opened = new ArrayList<>(); + boolean online = true; + MenuHolder shown; + LobbyMenu.Slot[] slots; + int paints; + int closes; + + FakeScreen(String name, boolean zh) { + this.name = name; + this.zh = zh; + UUID id = UUID.randomUUID(); + this.player = fake(Player.class, (m, a) -> switch (m) { + case "getUniqueId" -> id; + case "getName" -> name; + default -> UNANSWERED; + }); + } + + int shownSize() { + for (Opened o : opened) { + if (o.holder == shown) { + return o.size; + } + } + throw new AssertionError("no open page"); + } + + @Override + public String name() { + return name; + } + + @Override + public boolean zh() { + return zh; + } + + @Override + public boolean online() { + return online; + } + + @Override + public void send(ControlFrame frame) { + sent.add(frame); + } + + @Override + public void chat(String text, LobbyMenu.Tone tone) { + chat.add(tone + " " + text); + } + + @Override + public void open(MenuHolder holder, String title, int size, LobbyMenu.Slot[] content) { + if (content.length != size) { + throw new AssertionError("slots for " + content.length + " in a chest of " + size); + } + opened.add(new Opened(holder, title, size, content.clone())); + shown = holder; + slots = content.clone(); + } + + @Override + public void paint(MenuHolder holder, int slot, LobbyMenu.Slot content) { + if (holder != shown) { + throw new AssertionError("painted a page that is not showing"); + } + slots[slot] = content; + paints++; + } + + @Override + public MenuHolder shown() { + return shown; + } + + @Override + public void close() { + shown = null; + closes++; + } + } + + // ---- helpers ---- + + private static ControlFrame up(String server) { + return ControlFrame.statusUpdate(server, "Running", true, 3, 20, false); + } + + private static ControlFrame stopped(String server) { + return ControlFrame.statusUpdate(server, "Stopped", false, 0, 20, false); + } + + private static ControlFrame ownerless(String server) { + return ControlFrame.statusUpdate(server, "Stopped", false, 0, 20, true); + } + + private static MenuTiles.Kind kind(FakeScreen s, int slot) { + if (!(s.slots[slot] instanceof LobbyMenu.Status st)) { + throw new AssertionError("slot " + slot + " holds " + s.slots[slot] + ", want a painted tile"); + } + return st.tile().kind(); + } + + private static String text(String code, String message, boolean zh) { + return LobbyMenu.errorText(ControlFrame.error(code, message, null), zh); + } + + private static T last(List list) { + return list.isEmpty() ? null : list.get(list.size() - 1); + } + + private static void assertTrue(String what, boolean ok) { + if (!ok) { + throw new AssertionError(what); + } + checks++; + } + + private static void assertEq(String what, Object want, Object got) { + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + ": got " + got + ", want " + want); + } + checks++; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/ModLink.java b/plugins/shared/src/main/java/best/lolicon/felis/link/ModLink.java new file mode 100644 index 0000000..092be53 --- /dev/null +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/ModLink.java @@ -0,0 +1,110 @@ +package best.lolicon.felis.link; + +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** + * ModLink is the {@code /link} command the Fabric, Forge and NeoForge mods share (the + * §10 account-link flow): it takes the player's server-verified UUID, asks felis-api + * for a one-time code off the server thread, and shows the code and where to redeem + * it back on the server thread, so a slow felis-api never stalls the tick loop. A + * failure reaches the player as one generic line, with the details in the server log. + * Each mod only adapts its command source to {@link Source}; ModLinkTest drives this + * class against a stub felis-api. + * + *

A mod mints a link code for the UUID its server reports, and only an online-mode + * server has checked that UUID with Mojang. Behind a proxy the backend runs + * offline-mode and the proxy's own {@code /link} already serves every backend; on a + * cracked server the UUID is whatever the client claims, so a code minted there would + * let anyone link someone else's Minecraft account. The mods therefore link only on a + * standalone online-mode server. + */ +public final class ModLink { + /** PLAYERS_ONLY answers /link from the console or a command block. */ + public static final String PLAYERS_ONLY = "/link 只能由玩家执行 / /link can only be run by a player."; + + /** OFFLINE_REPLY is the chat line a player gets on an offline-mode server. */ + public static final String OFFLINE_REPLY = + "此服务器未开启正版验证,不能在这里绑定 / This server runs with online-mode off, so /link is unavailable here."; + + /** OFFLINE_LOG is the server-log line for the same refusal. */ + public static final String OFFLINE_LOG = + "Felis link: refused /link because online-mode is off. The mod links only on a standalone " + + "online-mode server; behind the Felis proxy, the proxy serves /link."; + + /** REQUESTING is said at once, while the code is being fetched. */ + public static final String REQUESTING = "正在获取绑定码… / Requesting a link code…"; + + /** FAILED is the one line a player gets for any failed fetch. */ + public static final String FAILED = + "现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment."; + + /** CONSOLE_HINT says where to redeem a code that came without a panel URL. */ + public static final String CONSOLE_HINT = "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."; + + /** Source is the command's caller as a mod sees it. */ + public interface Source { + /** player is the UUID of the player running /link, or null for the console or a command block. */ + UUID player(); + + /** onlineMode reports whether this server checks players with Mojang. */ + boolean onlineMode(); + + /** fail answers the command with a failure line. */ + void fail(String line); + + /** tell sends the player a chat line; it is called on the server thread. */ + void tell(String line); + + /** onServerThread runs a task on the server thread. */ + void onServerThread(Runnable task); + } + + private final LinkClient client; + private final Executor io; + private final Consumer warn; + + /** + * @param io runs the felis-api call; the mods pass one daemon thread + * @param warn writes a warning to the server log + */ + public ModLink(LinkClient client, Executor io, Consumer warn) { + this.client = client; + this.io = io; + this.warn = warn; + } + + /** run executes /link for one caller and returns the command result: 1 ran, 0 refused. */ + public int run(Source source) { + UUID id = source.player(); + if (id == null) { + source.fail(PLAYERS_ONLY); + return 0; + } + if (!source.onlineMode()) { + warn.accept(OFFLINE_LOG); + source.fail(OFFLINE_REPLY); + return 0; + } + source.tell(REQUESTING); + io.execute(() -> { + try { + LinkCode code = client.requestCode(id); + source.onServerThread(() -> { + source.tell(codeLine(code)); + source.tell(code.panelUrl() != null ? "在此完成绑定 / Finish linking at: " + code.panelUrl() : CONSOLE_HINT); + }); + } catch (LinkException e) { + warn.accept("Felis link: link code request failed for " + id + " (status=" + e.statusCode() + + ", code=" + e.errorCode() + "): " + e.getMessage()); + source.onServerThread(() -> source.tell(FAILED)); + } + }); + return 1; + } + + static String codeLine(LinkCode code) { + return "绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"; + } +} diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/ModLinkPolicy.java b/plugins/shared/src/main/java/best/lolicon/felis/link/ModLinkPolicy.java deleted file mode 100644 index 0c19a33..0000000 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/ModLinkPolicy.java +++ /dev/null @@ -1,24 +0,0 @@ -package best.lolicon.felis.link; - -/** - * ModLinkPolicy is what the Fabric, Forge and NeoForge mods say when they refuse - * {@code /link}. A mod mints a link code for the UUID its server reports, and only an - * online-mode server has checked that UUID with Mojang. Behind a proxy the backend runs - * offline-mode and the proxy's own {@code /link} already serves every backend; on a - * cracked server the UUID is whatever the client claims, so a code minted there would - * let anyone link someone else's Minecraft account. The mods therefore link only on a - * standalone online-mode server. - */ -public final class ModLinkPolicy { - /** OFFLINE_REPLY is the chat line a player gets on an offline-mode server. */ - public static final String OFFLINE_REPLY = - "此服务器未开启正版验证,不能在这里绑定 / This server runs with online-mode off, so /link is unavailable here."; - - /** OFFLINE_LOG is the server-log line for the same refusal. */ - public static final String OFFLINE_LOG = - "Felis link: refused /link because online-mode is off. The mod links only on a standalone " - + "online-mode server; behind the Felis proxy, the proxy serves /link."; - - private ModLinkPolicy() { - } -} diff --git a/plugins/shared/test/best/lolicon/felis/link/ModLinkTest.java b/plugins/shared/test/best/lolicon/felis/link/ModLinkTest.java new file mode 100644 index 0000000..934667f --- /dev/null +++ b/plugins/shared/test/best/lolicon/felis/link/ModLinkTest.java @@ -0,0 +1,248 @@ +package best.lolicon.felis.link; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * ModLinkTest runs the mods' shared {@code /link} ({@link ModLink}) the way a mod + * does, with a fake command source, a hand-cranked I/O thread and server thread, and + * the real LinkClient against a stub felis-api. It checks what the caller gets: the + * console and an offline-mode server are refused before anything is minted, a player + * hears "requesting" at once while the code is fetched off the server thread, the + * code and where to redeem it (the minted panel URL, else the console) arrive back on + * the server thread, and any failure is one generic line with the details in the log. + * + *

Run: {@code javac -d shared/src/main/java/best/lolicon/felis/link/*.java + * shared/test/best/lolicon/felis/link/ModLinkTest.java && java -cp + * best.lolicon.felis.link.ModLinkTest} (plugins/test.sh does). + */ +public final class ModLinkTest { + + private static final String CODE = "K7Q2-9XPM"; + private static int checks; + + // The stub's knobs and what it saw. + private static volatile int status = 201; + private static volatile String panelUrl; + private static final List bodies = new ArrayList<>(); + + public static void main(String[] args) throws Exception { + HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + stub.createContext("/", ModLinkTest::handle); + stub.start(); + String base = "http://127.0.0.1:" + stub.getAddress().getPort(); + try { + LinkClient client = new LinkClient(new LinkConfig(base, "mod-token")); + consoleIsRefused(client); + offlineModeIsRefused(client); + playerGetsTheCodeAndThePanelUrl(client); + codeWithoutPanelUrlPointsAtTheConsole(client); + refusalIsOneGenericLine(client); + } finally { + stub.stop(0); + } + felisApiDownIsOneGenericLine(new LinkClient(new LinkConfig(base, "mod-token"))); + System.out.println("ModLinkTest OK (" + checks + " checks)"); + } + + private static void consoleIsRefused(LinkClient client) { + Rig rig = new Rig(client); + Caller console = new Caller(null, true); + assertEq("the console's /link is refused", 0, rig.link.run(console)); + assertEq("... as players-only", List.of(ModLink.PLAYERS_ONLY), console.failures); + assertEq("... and nothing is fetched", 0, rig.io.size()); + } + + private static void offlineModeIsRefused(LinkClient client) { + Rig rig = new Rig(client); + Caller player = new Caller(UUID.randomUUID(), false); + assertEq("/link on an offline-mode server is refused", 0, rig.link.run(player)); + assertEq("... with the reason", List.of(ModLink.OFFLINE_REPLY), player.failures); + assertEq("... logged", List.of(ModLink.OFFLINE_LOG), rig.warnings); + assertEq("... and no code minted for a UUID nobody checked", 0, rig.io.size()); + assertEq("... nor a line told", List.of(), player.told); + } + + private static void playerGetsTheCodeAndThePanelUrl(LinkClient client) { + Rig rig = new Rig(client); + panelUrl = "https://panel.example.test/link"; + UUID id = UUID.randomUUID(); + Caller player = new Caller(id, true); + int before = bodies.size(); + assertEq("/link runs", 1, rig.link.run(player)); + assertEq("the player hears at once that the code is coming", List.of(ModLink.REQUESTING), player.told); + assertEq("... and felis-api is not called on the server thread", before, bodies.size()); + rig.runIo(player); + assertEq("the I/O thread mints one code", before + 1, bodies.size()); + assertTrue("... for this player's UUID", bodies.get(before).contains(id.toString())); + assertEq("... and tells nothing itself", List.of(ModLink.REQUESTING), player.told); + player.drainServerThread(); + assertEq("back on the server thread: the code, then the panel URL", + List.of(ModLink.REQUESTING, ModLink.codeLine(new LinkCode(CODE, null, panelUrl)), + "在此完成绑定 / Finish linking at: " + panelUrl), + player.told); + assertTrue("the code line shows the code", player.told.get(1).contains(CODE)); + assertEq("nothing logged", List.of(), rig.warnings); + panelUrl = null; + } + + private static void codeWithoutPanelUrlPointsAtTheConsole(LinkClient client) { + Rig rig = new Rig(client); + Caller player = new Caller(UUID.randomUUID(), true); + rig.link.run(player); + rig.runIo(player); + player.drainServerThread(); + assertEq("an older felis-api's code points at the console", ModLink.CONSOLE_HINT, last(player.told)); + } + + private static void refusalIsOneGenericLine(LinkClient client) { + Rig rig = new Rig(client); + status = 429; + UUID id = UUID.randomUUID(); + Caller player = new Caller(id, true); + rig.link.run(player); + rig.runIo(player); + player.drainServerThread(); + assertEq("a refusal: requesting, then one generic line", List.of(ModLink.REQUESTING, ModLink.FAILED), player.told); + assertEq("... logged once", 1, rig.warnings.size()); + String log = rig.warnings.get(0); + assertTrue("... naming the player, the status and felis-api's code: " + log, + log.contains(id.toString()) && log.contains("status=429") && log.contains("code=rate_limited")); + assertTrue("... while the player never sees felis-api's words", !String.join(" ", player.told).contains("slow down")); + status = 201; + } + + private static void felisApiDownIsOneGenericLine(LinkClient client) { + Rig rig = new Rig(client); + Caller player = new Caller(UUID.randomUUID(), true); + rig.link.run(player); + rig.runIo(player); + player.drainServerThread(); + assertEq("felis-api unreachable: the same generic line", ModLink.FAILED, last(player.told)); + assertTrue("... logged as a transport failure", rig.warnings.get(0).contains("status=0")); + } + + // ---- rig ---- + + private static final class Rig { + final List io = new ArrayList<>(); + final List warnings = new ArrayList<>(); + final ModLink link; + + Rig(LinkClient client) { + link = new ModLink(client, io::add, warnings::add); + } + + /** runIo runs the queued felis-api calls, off the caller's server thread. */ + void runIo(Caller caller) { + caller.onServerThread = false; + List queued = new ArrayList<>(io); + io.clear(); + queued.forEach(Runnable::run); + caller.onServerThread = true; + } + } + + /** Caller is a command source: a player (or the console), and what it was told. */ + private static final class Caller implements ModLink.Source { + final UUID id; + final boolean online; + final List failures = new ArrayList<>(); + final List told = new ArrayList<>(); + final List serverThread = new ArrayList<>(); + boolean onServerThread = true; + + Caller(UUID id, boolean online) { + this.id = id; + this.online = online; + } + + void drainServerThread() { + List queued = new ArrayList<>(serverThread); + serverThread.clear(); + queued.forEach(Runnable::run); + } + + @Override + public UUID player() { + return id; + } + + @Override + public boolean onlineMode() { + return online; + } + + @Override + public void fail(String line) { + failures.add(line); + } + + @Override + public void tell(String line) { + if (!onServerThread) { + throw new AssertionError("told the player off the server thread: " + line); + } + told.add(line); + } + + @Override + public void onServerThread(Runnable task) { + serverThread.add(task); + } + } + + private static void handle(HttpExchange ex) throws IOException { + String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + if (!"Bearer mod-token".equals(ex.getRequestHeaders().getFirst("Authorization")) + || !ex.getRequestURI().getPath().endsWith("/account/link/code")) { + reply(ex, 404, "{\"error\":{\"code\":\"not_found\",\"message\":\"not_found\"}}"); + return; + } + synchronized (bodies) { + bodies.add(body); + } + if (status != 201) { + reply(ex, status, "{\"error\":{\"code\":\"rate_limited\",\"message\":\"slow down\"}}"); + return; + } + String url = panelUrl; + reply(ex, 201, "{\"code\":\"" + CODE + "\",\"expires_at\":\"2026-09-27T12:10:00Z\"" + + (url != null ? ",\"panel_url\":\"" + url + "\"" : "") + "}"); + } + + private static void reply(HttpExchange ex, int code, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "application/json"); + ex.sendResponseHeaders(code, bytes.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + } + + private static T last(List list) { + return list.isEmpty() ? null : list.get(list.size() - 1); + } + + private static void assertTrue(String what, boolean ok) { + if (!ok) { + throw new AssertionError(what); + } + checks++; + } + + private static void assertEq(String what, Object want, Object got) { + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + ": got " + got + ", want " + want); + } + checks++; + } +} diff --git a/plugins/test.sh b/plugins/test.sh index a1d69eb..cdd6f40 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -3,7 +3,7 @@ # # bash plugins/test.sh # -# Three gates, all runnable on any machine with a JDK 25 (Gradle comes from each +# Five gates, all runnable on any machine with a JDK 25 (Gradle comes from each # module's wrapper, sha256-pinned): # # 1. The hand-written, framework-free test mains under shared/test and @@ -23,8 +23,11 @@ # succeeds), a CR marked forwarding=legacy joins the fork's legacy-forwarding # list above the installer's floor, /invite prompts cannot double-fire # or outlive their TTL, the invite card really is a green/red clickable -# prompt, and the op-login approval card names the account and leaves its -# name for the admin to type. InviteCardTest and OpApprovalCardTest need the +# prompt, the op-login approval card names the account and leaves its +# name for the admin to type, and the loader mods' shared /link refuses the +# console and an offline-mode server before minting, fetches the code off the +# server thread and answers on it, and turns any failure into one line. +# InviteCardTest and OpApprovalCardTest need the # adventure jars the velocity plugin compiles # against; they are fetched from Maven Central below, pinned by version and # checked by digest (a test run against silently-substituted bytes is not a @@ -61,6 +64,17 @@ # Claim when ownerless, a grey tile with the reason (and no frame) when the # player may not start it, the phase in the player's language. # +# 5. The login gate self-test (`./gradlew loginTest` in plugins/limbo): the whole +# login flow on a virtual clock, a fake scheduler and players around the real +# LoginFlow and the real felis-api clients against a stub. A linked player goes +# straight to the lobby with the release re-sent on its backoff, an unlinked one +# gets the code (the minted panel URL, else the console) and is released once +# linked, a barred UUID is turned away without a code, an outage at join is +# retried for a minute (asking the proxy for the lobby each time) and a refusal +# is not, the login and release windows end in their own disconnect, a player +# who leaves stops every timer, and a reconnect or a lagging main thread still +# runs one loop. Also the readiness endpoint, the env parsing and the book. +# # No test framework: the mains are the same javac one-liners their javadocs document, # so a local run and CI run the same bytes. set -euo pipefail @@ -120,6 +134,12 @@ javac -d "$work/shared-classes" \ plugins/shared/test/best/lolicon/felis/link/OutageTrackerTest.java java -cp "$work/shared-classes" best.lolicon.felis.link.OutageTrackerTest +echo "==> ModLinkTest (the loader mods' shared /link, shared)" +javac -d "$work/shared-classes" \ + plugins/shared/src/main/java/best/lolicon/felis/link/*.java \ + plugins/shared/test/best/lolicon/felis/link/ModLinkTest.java +java -cp "$work/shared-classes" best.lolicon.felis.link.ModLinkTest + echo "==> ControlPolicyTest (who may send what on felis:control, velocity)" mkdir -p "$work/policy-classes" javac -d "$work/policy-classes" \ @@ -208,5 +228,5 @@ echo "==> plugins/paper: ./gradlew --no-daemon lobbyTest" limbo_version="$(sed -n 's/^LIMBO_VERSION=//p' deploy/game-stack.lock)" [ -n "$limbo_version" ] || { echo "deploy/game-stack.lock sets no LIMBO_VERSION" >&2; exit 1; } -echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build" -( cd plugins/limbo && ./gradlew --no-daemon -PlimboVersion="$limbo_version" build ) +echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build loginTest" +( cd plugins/limbo && ./gradlew --no-daemon -PlimboVersion="$limbo_version" build loginTest )