test(plugins): 登录闸门、大厅菜单和 mod /link 抽成可测类并补行为测试
This commit is contained in:
19 files changed
+2796
-820
No files matched your search
+7
-2
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.<FELIS_ROOT_DOMAIN> for an older operator whose
|
||||||
|
* env predates it, else null, and the login flow stays off.
|
||||||
|
*/
|
||||||
|
static String panelHost(String panelHostname, String rootDomain) {
|
||||||
|
String host = trimmed(panelHostname);
|
||||||
|
if (host != null) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
String root = trimmed(rootDomain);
|
||||||
|
return root != null ? "console." + root : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** lobby is FELIS_LOBBY_SERVER, else "lobby". */
|
||||||
|
static String lobby(String raw) {
|
||||||
|
String t = trimmed(raw);
|
||||||
|
return t != null ? t : DEFAULT_LOBBY;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String trimmed(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String t = raw.trim();
|
||||||
|
return t.isEmpty() ? null : t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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 )
|
||||||
Reference in new issue
Block a user