test(plugins): 登录闸门、大厅菜单和 mod /link 抽成可测类并补行为测试

This commit is contained in:
Lemon-miaow committed 2026-09-27 05:55:58 +08:00
1 parent 6d5aca79fb
commit 9a238780e5
19 files changed
+2796 -820

No files matched your search

+7 -2
View File
@@ -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 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, 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 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 `plugins/velocity/gradlew -p plugins/velocity routingTest`. It also runs the lobby menu
loader mods, via the wrappers above). 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=<lock's LIMBO_VERSION> 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 ### Dependency verification
@@ -1,11 +1,9 @@
package best.lolicon.felis.fabric; package best.lolicon.felis.fabric;
import best.lolicon.felis.link.LinkClient; import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.LinkConfigLoader; import best.lolicon.felis.link.LinkConfigLoader;
import best.lolicon.felis.link.LinkException; import best.lolicon.felis.link.ModLink;
import best.lolicon.felis.link.ModLinkPolicy;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException; 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.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import org.slf4j.Logger; import org.slf4j.Logger;
import java.io.IOException; import java.io.IOException;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executor;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
/** /**
* FelisFabricMod is the Fabric (dedicated-server) leg of the §10 account-link * 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 * 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 * 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 * redeems it on the web console. The command itself is the shared {@link ModLink}
* and the reply is hopped back onto the server thread, so a slow felis-api never * (the HTTP call on a daemon I/O thread, the reply back on the server thread, one
* stalls the tick loop. Failures collapse to a generic chat line with details * generic line for any failure); this class registers it and adapts the command
* confined to the server log. * source.
*/ */
public final class FelisFabricMod implements DedicatedServerModInitializer { public final class FelisFabricMod implements DedicatedServerModInitializer {
private static final Logger LOGGER = LogUtils.getLogger(); 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"); Thread t = new Thread(r, "felis-link-io");
t.setDaemon(true); t.setDaemon(true);
return t; return t;
}); });
private LinkClient linkClient; private ModLink link;
@Override @Override
public void onInitializeServer() { public void onInitializeServer() {
try { try {
LinkConfig config = LinkConfigLoader.load( LinkConfig config = LinkConfigLoader.load(
FabricLoader.getInstance().getConfigDir().resolve("felis-link.properties")); 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) { } catch (IOException e) {
LOGGER.error("Felis link disabled: {}", e.getMessage()); LOGGER.error("Felis link disabled: {}", e.getMessage());
return; return;
@@ -60,44 +57,48 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
} }
private void register(CommandDispatcher<CommandSourceStack> dispatcher) { private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("link").executes(ctx -> { dispatcher.register(Commands.literal("link").executes(ctx -> link.run(new LinkSource(ctx.getSource()))));
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;
}));
} }
private void requestAndReply(MinecraftServer server, ServerPlayer player) { /** LinkSource is a command source as the shared /link sees it. */
UUID uuid = player.getUUID(); private static final class LinkSource implements ModLink.Source {
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…")); private final CommandSourceStack source;
io.submit(() -> { private final ServerPlayer player; // null for the console or a command block
LinkSource(CommandSourceStack source) {
this.source = source;
ServerPlayer p;
try { try {
LinkCode code = linkClient.requestCode(uuid); p = source.getPlayerOrException();
server.execute(() -> { } catch (CommandSyntaxException e) {
player.sendSystemMessage(Component.literal( p = null;
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)")); }
player.sendSystemMessage(Component.literal(code.panelUrl() != null this.player = p;
? "在此完成绑定 / Finish linking at: " + code.panelUrl() }
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
}); @Override
} catch (LinkException e) { public UUID player() {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}", return player != null ? player.getUUID() : null;
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."))); @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);
} }
});
} }
} }
@@ -1,11 +1,9 @@
package best.lolicon.felis.forge; package best.lolicon.felis.forge;
import best.lolicon.felis.link.LinkClient; import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.LinkConfigLoader; import best.lolicon.felis.link.LinkConfigLoader;
import best.lolicon.felis.link.LinkException; import best.lolicon.felis.link.ModLink;
import best.lolicon.felis.link.ModLinkPolicy;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.CommandSyntaxException;
@@ -13,7 +11,6 @@ import com.mojang.logging.LogUtils;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.RegisterCommandsEvent; import net.minecraftforge.event.RegisterCommandsEvent;
@@ -24,34 +21,34 @@ import org.slf4j.Logger;
import java.io.IOException; import java.io.IOException;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executor;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
/** /**
* FelisForgeMod is the Forge server-side leg of the §10 account-link flow. On * 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 * {@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 * one-time code from felis-api for the player's already-verified UUID. The command
* other loaders, the HTTP call runs on a daemon I/O thread and the reply is hopped * is the shared {@link ModLink}, as on the other loaders (the HTTP call on a daemon
* back onto the server thread; failures collapse to a generic chat line with * I/O thread, the reply back on the server thread, one generic line for any
* details kept to the server log. If config is missing the mod stays loaded but * failure). If config is missing the mod stays loaded but
* never registers the command, so the proxy/server runs un-crippled. * never registers the command, so the proxy/server runs un-crippled.
*/ */
@Mod("felis_link") @Mod("felis_link")
public final class FelisForgeMod { public final class FelisForgeMod {
private static final Logger LOGGER = LogUtils.getLogger(); 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"); Thread t = new Thread(r, "felis-link-io");
t.setDaemon(true); t.setDaemon(true);
return t; return t;
}); });
private LinkClient linkClient; private ModLink link;
public FelisForgeMod() { public FelisForgeMod() {
try { try {
LinkConfig config = LinkConfigLoader.load( LinkConfig config = LinkConfigLoader.load(
FMLPaths.CONFIGDIR.get().resolve("felis-link.properties")); 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); MinecraftForge.EVENT_BUS.register(this);
LOGGER.info("Felis link ready; /link will be registered."); LOGGER.info("Felis link ready; /link will be registered.");
} catch (IOException e) { } catch (IOException e) {
@@ -65,44 +62,48 @@ public final class FelisForgeMod {
} }
private void register(CommandDispatcher<CommandSourceStack> dispatcher) { private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("link").executes(ctx -> { dispatcher.register(Commands.literal("link").executes(ctx -> link.run(new LinkSource(ctx.getSource()))));
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;
}));
} }
private void requestAndReply(MinecraftServer server, ServerPlayer player) { /** LinkSource is a command source as the shared /link sees it. */
UUID uuid = player.getUUID(); private static final class LinkSource implements ModLink.Source {
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…")); private final CommandSourceStack source;
io.submit(() -> { private final ServerPlayer player; // null for the console or a command block
LinkSource(CommandSourceStack source) {
this.source = source;
ServerPlayer p;
try { try {
LinkCode code = linkClient.requestCode(uuid); p = source.getPlayerOrException();
server.execute(() -> { } catch (CommandSyntaxException e) {
player.sendSystemMessage(Component.literal( p = null;
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)")); }
player.sendSystemMessage(Component.literal(code.panelUrl() != null this.player = p;
? "在此完成绑定 / Finish linking at: " + code.panelUrl() }
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
}); @Override
} catch (LinkException e) { public UUID player() {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}", return player != null ? player.getUUID() : null;
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."))); @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);
} }
});
} }
} }
+23
View File
@@ -58,3 +58,26 @@ tasks.withType(JavaCompile).configureEach {
// bytecode so the plugin loads on any Limbo running Java 17+. // bytecode so the plugin loads on any Limbo running Java 17+.
options.release = 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'
}
@@ -1,39 +1,24 @@
package best.lolicon.felis.limbo; package best.lolicon.felis.limbo;
import best.lolicon.felis.link.Control; import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import best.lolicon.felis.link.FelisApiClient; import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkClient; import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode; import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.LinkConfigLoader; 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.EventHandler;
import com.loohp.limbo.events.Listener; import com.loohp.limbo.events.Listener;
import com.loohp.limbo.events.player.PlayerJoinEvent; import com.loohp.limbo.events.player.PlayerJoinEvent;
import com.loohp.limbo.player.Player; import com.loohp.limbo.player.Player;
import com.loohp.limbo.plugins.LimboPlugin; 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.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.File;
import java.io.IOException; 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.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; 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 * 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 * 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 * {@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 * first server tick ({@link Readiness}) — the MinecraftServer CRD's HTTP
* true signal. Fail-closed: if the endpoint cannot bind, readiness never turns green * readinessProbe follows that true signal. Fail-closed: if the endpoint cannot bind,
* and the operator keeps the gate in Starting rather than advertising an unstarted * readiness never turns green and the operator keeps the gate in Starting rather than
* auth gate. * advertising an unstarted auth gate.
* *
* <p><b>2. In-game login.</b> A player who reaches the limbo has been UUID-verified * <p><b>2. In-game login.</b> 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 * 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:
* <ol> * <ol>
* <li>checks the username-collision blacklist and disconnects a barred squatter * <li>checks the username-collision blacklist and disconnects a barred squatter
* UUID (the genuine Mojang player, different UUID, passes);</li> * UUID (the genuine Mojang player, different UUID, passes);</li>
@@ -72,9 +57,9 @@ import java.util.logging.Logger;
* </ol> * </ol>
* *
* <p>A felis-api outage at join (transport error or 5xx) is retried with backoff for * <p>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 * {@link LoginFlow#START_RETRY_WINDOW_MILLIS} before the player is turned away. Each
* also sends a {@code LoginRelease}: the proxy keeps recent link confirmations for * retry also sends a {@code LoginRelease}: the proxy keeps recent link confirmations
* exactly this case, so a player who was signed in minutes ago can still get * 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. * through a felis-api restart, and anyone else is refused there.
* *
* <p>The release used to be a BungeeCord {@code Connect} on {@code bungeecord:main}. * <p>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 * (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 * the shared {@link LinkConfigLoader}. {@code FELIS_ROOT_DOMAIN} builds the console
* link; {@code FELIS_LOBBY_SERVER} (default {@code lobby}) is the transfer target; * 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 * {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 600) bounds the login window
* link config or the root domain is absent the login flow stays OFF and the plugin * ({@link GateConfig}). If the link config or the root domain is absent the login
* runs readiness-only — the same "load un-crippled" fail-safe the other Felis plugins * flow stays OFF and the plugin runs readiness-only — the same "load un-crippled"
* use — so a bare image still boots and serves readiness; production must supply the * fail-safe the other Felis plugins use — so a bare image still boots and serves
* config for the gate to authenticate. * readiness; production must supply the config for the gate to authenticate.
* *
* <p>CODE-ONLY in the Go repo: it compiles against the Limbo API + the shared link * <p>This class is the Limbo glue only. The flow, the readiness endpoint, the env
* core and is bundled into the login image (deploy/limbo), not built by the Go CI. * 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 { public final class FelisLimboPlugin extends LimboPlugin implements Listener {
private static final Logger LOG = Logger.getLogger("FelisLimbo"); 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(), // Limbo deprecated the String channel overload; its Key overload sends key.toString(),
// which for "felis:control" is the same channel string. // which for "felis:control" is the same channel string.
private static final Key CONTROL_CHANNEL = Key.key(Control.CHANNEL); 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 private volatile Readiness readiness;
// second is responsive without hammering felis-api. The default window (10 min) private volatile LoginFlow flow;
// 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<UUID, Integer> 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<UUID> 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);
@Override @Override
public void onEnable() { public void onEnable() {
startReadiness(); startReadiness();
configureLogin(); configureLogin();
if (loginEnabled) { if (flow != null) {
getServer().getEventsManager().registerEvents(this, this); 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 @Override
public void onDisable() { public void onDisable() {
ready.set(false); Readiness r = this.readiness;
HttpServer server = this.http; if (r != null) {
if (server != null) { r.stop();
server.stop(0); this.readiness = null;
this.http = null;
} }
// cancelTask(plugin) tears down every scheduled task this plugin owns. // cancelTask(plugin) tears down every scheduled task this plugin owns.
getServer().getScheduler().cancelTask(this); getServer().getScheduler().cancelTask(this);
pollTasks.clear(); LoginFlow f = this.flow;
releasing.clear(); if (f != null) {
f.clear();
}
} }
// ---- readiness endpoint (unchanged behavior) ----
private void startReadiness() { private void startReadiness() {
int port = healthPort(); int port = GateConfig.healthPort(System.getenv("FELIS_HEALTH_PORT"));
Readiness r;
try { try {
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); r = Readiness.start(port);
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)");
} catch (IOException e) { } catch (IOException e) {
// Fail closed: no health server → never ready → pod stays NotReady. // Fail closed: no health server → never ready → pod stays NotReady.
LOG.log(Level.SEVERE, "FelisLimbo: could not start readiness endpoint on :" + port LOG.log(Level.SEVERE, "FelisLimbo: could not start readiness endpoint on :" + port
+ " — the login gate will stay NotReady", e); + " — the login gate will stay NotReady", e);
return; 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" // 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 // 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. // the task the server loop is ticking and the socket is open.
getServer().getScheduler().runTaskLater(this, () -> { getServer().getScheduler().runTaskLater(this, () -> {
if (ready.compareAndSet(false, true)) { if (r.markReady()) {
LOG.info("FelisLimbo: server started — readiness now 200"); LOG.info("FelisLimbo: server started — readiness now 200");
} }
}, 1L); }, 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() { private void configureLogin() {
LinkConfig cfg; LinkConfig cfg;
try { try {
@@ -228,299 +150,102 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
// Missing/half config: like the other Felis plugins, load un-crippled — // Missing/half config: like the other Felis plugins, load un-crippled —
// readiness stays up, the login flow just never turns on. // readiness stays up, the login flow just never turns on.
LOG.warning("FelisLimbo: " + e.getMessage()); LOG.warning("FelisLimbo: " + e.getMessage());
loginEnabled = false; logLoginOff();
return; return;
} }
String panelHost = GateConfig.panelHost(System.getenv("FELIS_PANEL_HOSTNAME"), System.getenv("FELIS_ROOT_DOMAIN"));
// 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.<root> 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;
}
if (panelHost == null) { if (panelHost == null) {
LOG.warning("FelisLimbo: neither FELIS_PANEL_HOSTNAME nor FELIS_ROOT_DOMAIN set — cannot build the console login link"); LOG.warning("FelisLimbo: neither FELIS_PANEL_HOSTNAME nor FELIS_ROOT_DOMAIN set — cannot build the console login link");
loginEnabled = false; logLoginOff();
return; return;
} }
String consoleUrl = "https://" + panelHost;
this.consoleUrl = "https://" + panelHost; String lobby = GateConfig.lobby(System.getenv("FELIS_LOBBY_SERVER"));
String lobby = trimmed(System.getenv("FELIS_LOBBY_SERVER")); long timeoutMillis = GateConfig.loginTimeoutSeconds(System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS")) * 1000L;
this.lobbyServer = lobby != null ? lobby : "lobby"; this.flow = new LoginFlow(new LimboGate(), new FelisApiClient(cfg), new LinkClient(cfg),
this.timeoutMillis = loginTimeoutSeconds() * 1000L; consoleUrl, timeoutMillis, System::currentTimeMillis, LOG);
this.linkClient = new LinkClient(cfg); LOG.info("FelisLimbo: login flow ON — console=" + consoleUrl
this.apiClient = new FelisApiClient(cfg); + ", lobby=" + lobby + ", window=" + (timeoutMillis / 1000) + "s");
this.loginEnabled = true;
} }
// ---- 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 @EventHandler
public void onJoin(PlayerJoinEvent event) { public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer(); flow.join(event.getPlayer().getUniqueId());
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));
} }
private void beginLogin(UUID id, int attempt, long startedAt) { /** LimboGate is the flow's view of this server: its scheduler and its players. */
if (online(id) == null) { private final class LimboGate implements LoginFlow.Gate {
return; // left while a retry was pending private LimboScheduler scheduler() {
} return getServer().getScheduler();
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) { @Override
public LoginFlow.Seat player(UUID id) {
Player player = getServer().getPlayer(id); Player player = getServer().getPlayer(id);
if (player == null || !player.isValid()) { return player != null && player.isValid() ? new LimboSeat(player) : null;
return; // player left during the async mint
} }
// Prefer the panel URL the server minted with the code (it is the same @Override
// single source of truth felis-api holds); the env-built consoleUrl is the public int runSync(Runnable task) {
// fallback for an older API that does not emit panel_url yet. return scheduler().runTask(FelisLimboPlugin.this, task::run);
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) { @Override
Player player = getServer().getPlayer(id); public int runAsync(Runnable task) {
if (player == null || !player.isValid()) { return scheduler().runTaskAsync(FelisLimboPlugin.this, task::run);
cancelPoll(id); // player left; stop polling their UUID
return;
} }
if (System.currentTimeMillis() > deadline) {
cancelPoll(id); @Override
disconnectOnMain(id, "登录超时,请重连 / Login timed out. Please reconnect."); public int runLater(Runnable task, long delayTicks) {
return; return scheduler().runTaskLater(FelisLimboPlugin.this, task::run, delayTicks);
} }
boolean linked;
try { @Override
linked = apiClient.linkStatus(id); public int runLaterAsync(Runnable task, long delayTicks) {
} catch (LinkException e) { return scheduler().runTaskLaterAsync(FelisLimboPlugin.this, task::run, delayTicks);
// 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;
@Override
public int runTimerAsync(Runnable task, long delayTicks, long periodTicks) {
return scheduler().runTaskTimerAsync(FelisLimboPlugin.this, task::run, delayTicks, periodTicks);
} }
if (pollOutage.success() == OutageTracker.Report.RECOVERED) {
LOG.info("FelisLimbo: link status polls recovered after " + pollOutage.lastOutageFailures() @Override
+ " failed polls over " + pollOutage.lastOutageMillis() / 1000 + " s"); public void cancel(int taskId) {
} scheduler().cancelTask(taskId);
if (linked) {
getServer().getScheduler().runTask(this, () -> startRelease(id));
} }
} }
// startRelease runs on the main thread once the player is known to be linked: stop /** LimboSeat is one Limbo player as the flow sees them. */
// the link poll, say so once, and start asking the proxy for the lobby. private record LimboSeat(Player player) implements LoginFlow.Seat {
private void startRelease(UUID id) { @Override
Player player = online(id); public String name() {
if (player == null || !releasing.add(id)) { return player.getName();
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 @Override
// the link before it moves the player, and a transient failure there only denies public void chat(String line) {
// that one attempt, so the gate keeps asking (with backoff, silently) until the player.sendMessage(line);
// 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) { @Override
try { public void showCode(LinkCode code, String url) {
player.sendPluginMessage(CONTROL_CHANNEL, Control.encode(ControlFrame.loginRelease(player.getName()))); player.openBook(LoginBook.book(code.code(), url));
} 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) { @Override
Player player = getServer().getPlayer(id); public void sendControl(byte[] frame) throws IOException {
return player != null && player.isValid() ? player : null; player.sendPluginMessage(CONTROL_CHANNEL, frame);
} }
// track records the player's current task, cancelling the one it replaces. @Override
private void track(UUID id, int taskId) { public void disconnect(String reason) {
Integer previous = pollTasks.put(id, taskId); player.disconnect(reason);
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);
Player player = getServer().getPlayer(id);
if (player != null && player.isValid()) {
player.disconnect(message);
}
});
}
// ---- rendering / wire ----
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);
}
// ---- env helpers ----
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
}
}
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
}
}
return DEFAULT_TIMEOUT_SECONDS;
}
private static String trimmed(String raw) {
if (raw == null) {
return null;
}
String t = raw.trim();
return t.isEmpty() ? null : t;
}
} }
@@ -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.&lt;FELIS_ROOT_DOMAIN&gt; 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;
}
}
@@ -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);
}
}
@@ -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.
*
* <p>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<UUID, Integer> 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<UUID> 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);
}
});
}
}
@@ -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);
}
}
}
@@ -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.
*
* <p>Run: {@code ./gradlew -PlimboVersion=<LIMBO_VERSION> 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<String> 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<String, Integer> 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<String, Long> 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.<root> 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<ClickEvent> 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<UUID, Seat> online = new HashMap<>();
private final Map<Integer, Task> 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<String> chat = new ArrayList<>();
final List<String> books = new ArrayList<>();
final List<ControlFrame> 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<UUID> barred = ConcurrentHashMap.newKeySet();
final Map<UUID, Boolean> linked = new ConcurrentHashMap<>();
final Map<String, AtomicInteger> 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<ClickEvent> 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++;
}
}
@@ -1,11 +1,9 @@
package best.lolicon.felis.neoforge; package best.lolicon.felis.neoforge;
import best.lolicon.felis.link.LinkClient; import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkConfig; import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.LinkConfigLoader; import best.lolicon.felis.link.LinkConfigLoader;
import best.lolicon.felis.link.LinkException; import best.lolicon.felis.link.ModLink;
import best.lolicon.felis.link.ModLinkPolicy;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.CommandSyntaxException;
@@ -13,7 +11,6 @@ import com.mojang.logging.LogUtils;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.bus.api.SubscribeEvent;
@@ -25,16 +22,16 @@ import org.slf4j.Logger;
import java.io.IOException; import java.io.IOException;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executor;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
/** /**
* FelisNeoForgeMod is the NeoForge server-side leg of the §10 account-link flow. On * 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 * {@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 * one-time code from felis-api for the player's already-verified UUID. The command
* other loaders, the HTTP call runs on a daemon I/O thread and the reply is hopped * is the shared {@link ModLink}, as on the other loaders (the HTTP call on a daemon
* back onto the server thread; failures collapse to a generic chat line with details * I/O thread, the reply back on the server thread, one generic line for any
* kept to the server log. If config is missing the mod stays loaded but never * failure). If config is missing the mod stays loaded but never
* registers the command, so the server runs un-crippled. * registers the command, so the server runs un-crippled.
* *
* <p>NeoForge constructs the mod with the mod event bus injected; the command event * <p>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 { public final class FelisNeoForgeMod {
private static final Logger LOGGER = LogUtils.getLogger(); 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"); Thread t = new Thread(r, "felis-link-io");
t.setDaemon(true); t.setDaemon(true);
return t; return t;
}); });
private LinkClient linkClient; private ModLink link;
public FelisNeoForgeMod(IEventBus modEventBus) { public FelisNeoForgeMod(IEventBus modEventBus) {
try { try {
LinkConfig config = LinkConfigLoader.load( LinkConfig config = LinkConfigLoader.load(
FMLPaths.CONFIGDIR.get().resolve("felis-link.properties")); 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); NeoForge.EVENT_BUS.register(this);
LOGGER.info("Felis link ready; /link will be registered."); LOGGER.info("Felis link ready; /link will be registered.");
} catch (IOException e) { } catch (IOException e) {
@@ -69,44 +66,48 @@ public final class FelisNeoForgeMod {
} }
private void register(CommandDispatcher<CommandSourceStack> dispatcher) { private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("link").executes(ctx -> { dispatcher.register(Commands.literal("link").executes(ctx -> link.run(new LinkSource(ctx.getSource()))));
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;
}));
} }
private void requestAndReply(MinecraftServer server, ServerPlayer player) { /** LinkSource is a command source as the shared /link sees it. */
UUID uuid = player.getUUID(); private static final class LinkSource implements ModLink.Source {
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…")); private final CommandSourceStack source;
io.submit(() -> { private final ServerPlayer player; // null for the console or a command block
LinkSource(CommandSourceStack source) {
this.source = source;
ServerPlayer p;
try { try {
LinkCode code = linkClient.requestCode(uuid); p = source.getPlayerOrException();
server.execute(() -> { } catch (CommandSyntaxException e) {
player.sendSystemMessage(Component.literal( p = null;
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)")); }
player.sendSystemMessage(Component.literal(code.panelUrl() != null this.player = p;
? "在此完成绑定 / Finish linking at: " + code.panelUrl() }
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
}); @Override
} catch (LinkException e) { public UUID player() {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}", return player != null ? player.getUUID() : null;
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."))); @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);
} }
});
} }
} }
+14 -5
View File
@@ -62,9 +62,10 @@ tasks.withType(JavaCompile).configureEach {
} }
// LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events around // 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 // Proxy-built fakes, MenuTilesTest checks the menu's tile judgement, and LobbyMenuTest
// framework, like the velocity routing tests). Neither is part of `build`, which the // drives the whole menu (frames, clicks, drags, paging) against a fake screen (plain
// lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`, which runs both. // 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 { sourceSets {
lobbyTest { lobbyTest {
java { java {
@@ -72,6 +73,7 @@ sourceSets {
include 'best/lolicon/felis/paper/Fakes.java' include 'best/lolicon/felis/paper/Fakes.java'
include 'best/lolicon/felis/paper/LobbyGuardTest.java' include 'best/lolicon/felis/paper/LobbyGuardTest.java'
include 'best/lolicon/felis/paper/MenuTilesTest.java' include 'best/lolicon/felis/paper/MenuTilesTest.java'
include 'best/lolicon/felis/paper/LobbyMenuTest.java'
} }
compileClasspath += sourceSets.main.output + configurations.compileClasspath compileClasspath += sourceSets.main.output + configurations.compileClasspath
runtimeClasspath += output + compileClasspath runtimeClasspath += output + compileClasspath
@@ -85,10 +87,17 @@ tasks.register('menuTilesTest', JavaExec) {
mainClass = 'best.lolicon.felis.paper.MenuTilesTest' 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) { tasks.register('lobbyTest', JavaExec) {
group = 'verification' group = 'verification'
description = 'Runs the LobbyGuardTest and MenuTilesTest self-test mains.' description = 'Runs the LobbyGuardTest, MenuTilesTest and LobbyMenuTest self-test mains.'
dependsOn 'menuTilesTest' dependsOn 'menuTilesTest', 'lobbyMenuTest'
classpath = sourceSets.lobbyTest.runtimeClasspath classpath = sourceSets.lobbyTest.runtimeClasspath
mainClass = 'best.lolicon.felis.paper.LobbyGuardTest' mainClass = 'best.lolicon.felis.paper.LobbyGuardTest'
} }
@@ -11,21 +11,14 @@ import org.bukkit.Material;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; 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.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.plugin.messaging.PluginMessageListener;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; 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 * 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 * player here; wake-path refusals (policy gate, capacity) are chat messages the
* proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady} * proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady}
* just before the proxy Connects them. * just before the proxy Connects them.
*
* <p>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 LobbyMenu 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<UUID, Boolean> pendingOpen = new ConcurrentHashMap<>();
@Override @Override
public void onEnable() { 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."); 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 // Open both ends of felis:control. Outgoing carries Wake/Claim/StatusQuery to
// the proxy; incoming receives StatusUpdate/TransferReady/Error back. // the proxy; incoming receives StatusUpdate/TransferReady/Error back.
getServer().getMessenger().registerOutgoingPluginChannel(this, Control.CHANNEL); getServer().getMessenger().registerOutgoingPluginChannel(this, Control.CHANNEL);
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this); getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, menu);
getServer().getPluginManager().registerEvents(this, this); getServer().getPluginManager().registerEvents(menu, this);
// The lobby is a hub nobody can hurt or be hurt in (LobbyGuard). Worlds loaded // 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. // 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)); sender.sendMessage(Component.text("Only a player can open the server menu.", NamedTextColor.RED));
return true; return true;
} }
openMenu((Player) sender); menu.request((Player) sender);
return true; return true;
} }
private void openMenu(Player player) { /** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
UUID id = player.getUniqueId(); static boolean zh(Player player) {
pendingOpen.put(id, Boolean.TRUE); return "zh".equalsIgnoreCase(player.locale().getLanguage());
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);
} }
private void openPage(Player player, List<String> all, Map<String, String> access, int page) { // ---- rendering: PaperScreen draws the menu's slots as items ----
boolean zh = zh(player);
if (all.isEmpty()) { private final class PaperScreen implements LobbyMenu.Screen {
player.sendMessage(Component.text( private final Player player;
zh ? "还没有可加入的服务器——在网页控制台创建一个吧。"
: "There are no servers to join yet — create one on the web console.", PaperScreen(Player player) {
NamedTextColor.YELLOW)); this.player = player;
return;
} }
int pages = MenuHolder.pageCount(all.size());
int p = Math.max(0, Math.min(page, pages - 1)); @Override
MenuHolder holder = new MenuHolder(all, access, p); public String name() {
List<String> view = holder.servers(); return player.getName();
int size = pages > 1 ? 54 : invSize(view.size()); }
Inventory inv = Bukkit.createInventory(holder, size, menuTitle(zh, p, pages));
@Override
public boolean zh() {
return FelisPaperPlugin.zh(player);
}
@Override
public boolean online() {
return player.isOnline();
}
@Override
public void send(ControlFrame frame) {
player.sendPluginMessage(FelisPaperPlugin.this, Control.CHANNEL, Control.encode(frame));
}
@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); holder.setInventory(inv);
for (int i = 0; i < view.size(); i++) { boolean zh = zh();
inv.setItem(i, loadingTile(view.get(i), zh)); for (int i = 0; i < slots.length; i++) {
} if (slots[i] != null) {
if (pages > 1) { inv.setItem(i, item(slots[i], zh));
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); 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));
} }
}
// ---- click: 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)) {
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)) {
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);
}
}
// ---- downstream: felis:control frames from the proxy ----
@Override @Override
public void onPluginMessageReceived(String channel, Player player, byte[] message) { public void paint(MenuHolder holder, int slot, LobbyMenu.Slot content) {
if (!Control.CHANNEL.equals(channel)) { holder.getInventory().setItem(slot, item(content, zh()));
return;
}
ControlFrame frame;
try {
frame = Control.decode(message);
} catch (IllegalArgumentException e) {
getLogger().fine("Dropping malformed felis:control frame: " + e.getMessage());
return;
}
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);
}
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.
}
} }
private void applyStatus(Player player, ControlFrame frame) { @Override
Inventory top = player.getOpenInventory().getTopInventory(); public MenuHolder shown() {
if (!(top.getHolder() instanceof MenuHolder)) { InventoryHolder holder = player.getOpenInventory().getTopInventory().getHolder();
return; // the player closed the menu before the answer arrived return holder instanceof MenuHolder menuHolder ? menuHolder : null;
}
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 @Override
// it stops saying "loading" forever. Returns false when there is no such tile (the public void close() {
// 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;
}
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) {
player.closeInventory(); player.closeInventory();
} }
} }
// ---- rendering ---- private static ItemStack item(LobbyMenu.Slot slot, boolean zh) {
return switch (slot) {
private static Component menuTitle(boolean zh, int page, int pages) { case LobbyMenu.Loading l -> loadingTile(l.server(), zh);
String title = zh ? "Felis 服务器" : "Felis Servers"; case LobbyMenu.Status s -> tile(s.status(), s.tile(), zh);
if (pages > 1) { case LobbyMenu.Unavailable u -> unavailableTile(u.server(), u.reason());
title += " (" + (page + 1) + "/" + pages + ")"; case LobbyMenu.Nav n -> navItem(n.arrow() ? Material.ARROW : Material.PAPER, n.label());
} };
return Component.text(title, NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
} }
private static ItemStack navItem(Material material, String label) { private static ItemStack navItem(Material material, String label) {
@@ -324,8 +188,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return item; return item;
} }
private ItemStack tile(ControlFrame f, String verdict, boolean zh) { private static ItemStack tile(ControlFrame f, MenuTiles.Tile t, boolean zh) {
MenuTiles.Tile t = MenuTiles.tile(f, verdict, zh);
Material material; Material material;
NamedTextColor color; NamedTextColor color;
switch (t.kind()) { switch (t.kind()) {
@@ -365,7 +228,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return item; 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); ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = item.getItemMeta(); ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)); 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; 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) { private static Component line(String key, String value) {
return Component.text(key + ": ", NamedTextColor.GRAY) return Component.text(key + ": ", NamedTextColor.GRAY)
.append(Component.text(value, NamedTextColor.WHITE)) .append(Component.text(value, NamedTextColor.WHITE))
.decoration(TextDecoration.ITALIC, false); .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;
}
} }
@@ -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<Player, Screen> 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<UUID, Object> pendingOpen = new ConcurrentHashMap<>();
LobbyMenu(Function<Player, Screen> 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<String> all, Map<String, String> 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<String> 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;
}
}
@@ -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.
*
* <p>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<String> 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<String> names = new ArrayList<>();
List<String> 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<String, String> 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<Player, FakeScreen> 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<String> servers, List<String> 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<Integer, ItemStack> 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<Object[]> 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<Object[]> 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<ControlFrame> sent = new ArrayList<>();
final List<String> chat = new ArrayList<>();
final List<Opened> 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> T last(List<T> 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++;
}
}
@@ -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.
*
* <p>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<String> 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<String> 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)";
}
}
@@ -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() {
}
}
@@ -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.
*
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
* shared/test/best/lolicon/felis/link/ModLinkTest.java && java -cp <out>
* 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<String> 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<Runnable> io = new ArrayList<>();
final List<String> 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<Runnable> 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<String> failures = new ArrayList<>();
final List<String> told = new ArrayList<>();
final List<Runnable> serverThread = new ArrayList<>();
boolean onServerThread = true;
Caller(UUID id, boolean online) {
this.id = id;
this.online = online;
}
void drainServerThread() {
List<Runnable> 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> T last(List<T> 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++;
}
}
+25 -5
View File
@@ -3,7 +3,7 @@
# #
# bash plugins/test.sh # 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): # module's wrapper, sha256-pinned):
# #
# 1. The hand-written, framework-free test mains under shared/test and # 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 # succeeds), a CR marked forwarding=legacy joins the fork's legacy-forwarding
# list above the installer's floor, /invite prompts cannot double-fire # list above the installer's floor, /invite prompts cannot double-fire
# or outlive their TTL, the invite card really is a green/red clickable # 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 # prompt, the op-login approval card names the account and leaves its
# name for the admin to type. InviteCardTest and OpApprovalCardTest need the # 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 # adventure jars the velocity plugin compiles
# against; they are fetched from Maven Central below, pinned by version and # 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 # 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 # 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. # 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, # No test framework: the mains are the same javac one-liners their javadocs document,
# so a local run and CI run the same bytes. # so a local run and CI run the same bytes.
set -euo pipefail set -euo pipefail
@@ -120,6 +134,12 @@ javac -d "$work/shared-classes" \
plugins/shared/test/best/lolicon/felis/link/OutageTrackerTest.java plugins/shared/test/best/lolicon/felis/link/OutageTrackerTest.java
java -cp "$work/shared-classes" best.lolicon.felis.link.OutageTrackerTest 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)" echo "==> ControlPolicyTest (who may send what on felis:control, velocity)"
mkdir -p "$work/policy-classes" mkdir -p "$work/policy-classes"
javac -d "$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)" 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; } [ -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" echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build loginTest"
( cd plugins/limbo && ./gradlew --no-daemon -PlimboVersion="$limbo_version" build ) ( cd plugins/limbo && ./gradlew --no-daemon -PlimboVersion="$limbo_version" build loginTest )