feat(auth)!: go fully passwordless and fix cross-check review findings
Remove password authentication everywhere; the only session doors are passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and op-login vouching. Remediates the 33-finding cross-check review across backend, CLI, panel, plugins, and docs. Backend/CLI: - Drop password routes and fields from account/user/onboard/auth handlers; align tests (new account subtests, naming reserves "console", op-login/onboard/qr-login test updates). - Add migrations 0016_op_login.sql and 0017_drop_password.sql. - Thread panel/admin hostnames from hostcfg through api.go, setup_panel.go, tui_root.go and tui_preflight.go instead of hardcoding; bootstrap.sh writes panel-hostname/admin-hostname into felis.toml. - Reword breakglass and TUI copy for passwordless flows. Panel: - Delete the ChangePassword page and all password UI; align login/auth/api/types with the passwordless contract; add the migration and op-login approval flows. - i18n: convert ImageBuildPage durations/status badges and ServerLuckPerms strings to translation keys; drop 72 orphan keys per locale; unify the title as "Felis - Console". Plugins (all six rebuilt): - Velocity waiting router returns 503 at_capacity during wake; MOTD/control-channel copy and config comments. - Paper zh menu title; Limbo bind-code TTL 600s with panel_url preference; unified /link lines in fabric/forge/neoforge; shared link-client javadoc contract fixes. Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and plugins/README.md aligned with the implementation. BREAKING CHANGE: migration 0017 irreversibly drops users.password_hash and users.must_change_password; password login cannot be restored after migrating.
This commit is contained in:
97 files changed
+1923
-1444
No files matched your search
+1
-1
@@ -4,7 +4,7 @@ These are the in-cluster and edge plugins for Felis. Every module **except the
|
||||
lobby** ships the in-game first leg of the §10 account-link flow: a player who is already online
|
||||
(so Mojang has verified their UUID) runs `/link`; the plugin asks felis-api to
|
||||
mint a one-time code for that UUID and shows it in chat. The player then enters
|
||||
the code on the web panel → **Account** page (the second leg), which binds the
|
||||
the code on the web console → **Account** page (the second leg), which binds the
|
||||
code to their logged-in account. The web side is already built.
|
||||
|
||||
The **Velocity** module additionally carries the §11 domain-autostart routing
|
||||
|
||||
@@ -28,7 +28,7 @@ import java.util.concurrent.Executors;
|
||||
* FelisFabricMod is the Fabric (dedicated-server) leg of the §10 account-link
|
||||
* flow. A server-side {@code /link} command takes the player's already-verified
|
||||
* UUID, asks felis-api for a one-time code, and shows it in chat; the player then
|
||||
* redeems it on the web panel. The HTTP call is pushed onto a daemon I/O thread
|
||||
* redeems it on the web console. The HTTP call is pushed onto a daemon I/O thread
|
||||
* and the reply is hopped back onto the server thread, so a slow felis-api never
|
||||
* stalls the tick loop. Failures collapse to a generic chat line with details
|
||||
* confined to the server log.
|
||||
@@ -65,7 +65,7 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
|
||||
try {
|
||||
player = source.getPlayerOrException();
|
||||
} catch (CommandSyntaxException e) {
|
||||
source.sendFailure(Component.literal("/link can only be run by a player."));
|
||||
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
|
||||
return 0;
|
||||
}
|
||||
requestAndReply(source.getServer(), player);
|
||||
@@ -75,18 +75,22 @@ public final class FelisFabricMod implements DedicatedServerModInitializer {
|
||||
|
||||
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||
UUID uuid = player.getUUID();
|
||||
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
||||
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
|
||||
io.submit(() -> {
|
||||
try {
|
||||
LinkCode code = linkClient.requestCode(uuid);
|
||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||
"Your link code: " + code.code()
|
||||
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
||||
server.execute(() -> {
|
||||
player.sendSystemMessage(Component.literal(
|
||||
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
|
||||
player.sendSystemMessage(Component.literal(code.panelUrl() != null
|
||||
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
|
||||
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
|
||||
});
|
||||
} catch (LinkException e) {
|
||||
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||
"Couldn't get a link code right now. Please try again in a moment.")));
|
||||
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ public final class FelisForgeMod {
|
||||
try {
|
||||
player = source.getPlayerOrException();
|
||||
} catch (CommandSyntaxException e) {
|
||||
source.sendFailure(Component.literal("/link can only be run by a player."));
|
||||
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
|
||||
return 0;
|
||||
}
|
||||
requestAndReply(source.getServer(), player);
|
||||
@@ -80,18 +80,22 @@ public final class FelisForgeMod {
|
||||
|
||||
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||
UUID uuid = player.getUUID();
|
||||
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
||||
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
|
||||
io.submit(() -> {
|
||||
try {
|
||||
LinkCode code = linkClient.requestCode(uuid);
|
||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||
"Your link code: " + code.code()
|
||||
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
||||
server.execute(() -> {
|
||||
player.sendSystemMessage(Component.literal(
|
||||
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
|
||||
player.sendSystemMessage(Component.literal(code.panelUrl() != null
|
||||
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
|
||||
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
|
||||
});
|
||||
} catch (LinkException e) {
|
||||
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||
"Couldn't get a link code right now. Please try again in a moment.")));
|
||||
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ import java.util.logging.Logger;
|
||||
* (env wins, else a {@code felis-link.properties} template in the plugin data dir) via
|
||||
* the shared {@link LinkConfigLoader}. {@code FELIS_ROOT_DOMAIN} builds the console
|
||||
* link; {@code FELIS_LOBBY_SERVER} (default {@code lobby}) is the transfer target;
|
||||
* {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 300) bounds the login window. If the
|
||||
* {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 600) bounds the login window. If the
|
||||
* link config or the root domain is absent the login flow stays OFF and the plugin
|
||||
* runs readiness-only — the same "load un-crippled" fail-safe the other Felis plugins
|
||||
* use — so a bare image still boots and serves readiness; production must supply the
|
||||
@@ -88,10 +88,11 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
||||
private static final int DEFAULT_PORT = 8080;
|
||||
|
||||
// Poll cadence and window. 20 ticks ≈ 1s at Limbo's tick rate; polling once a
|
||||
// second is responsive without hammering felis-api. The default window (5 min)
|
||||
// matches the Bind Code TTL — no point holding a player past code expiry.
|
||||
// second is responsive without hammering felis-api. The default window (10 min)
|
||||
// matches the Bind Code TTL (linkCodeTTL in internal/api) — no point holding a
|
||||
// player past code expiry, and no point cutting them off while it is still valid.
|
||||
private static final long POLL_PERIOD_TICKS = 20L;
|
||||
private static final long DEFAULT_TIMEOUT_SECONDS = 300L;
|
||||
private static final long DEFAULT_TIMEOUT_SECONDS = 600L;
|
||||
private static final long MIN_TIMEOUT_SECONDS = 30L;
|
||||
private static final long MAX_TIMEOUT_SECONDS = 3600L;
|
||||
|
||||
@@ -261,17 +262,21 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
||||
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 {
|
||||
player.openBook(loginBook(code));
|
||||
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.fine("FelisLimbo: openBook failed for " + id + " — " + e.getMessage());
|
||||
}
|
||||
player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code());
|
||||
player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + consoleUrl
|
||||
player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + url
|
||||
+ " §e完成登录(勿用微信/QQ内置浏览器)。");
|
||||
player.sendMessage("§7Open " + consoleUrl + " in your system browser (not WeChat/QQ) to finish.");
|
||||
player.sendMessage("§7Open " + url + " in your system browser (not WeChat/QQ) to finish.");
|
||||
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
int taskId = getServer().getScheduler().runTaskTimerAsync(
|
||||
@@ -341,13 +346,13 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
||||
|
||||
// ---- rendering / wire ----
|
||||
|
||||
private Book loginBook(LinkCode code) {
|
||||
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(consoleUrl)))
|
||||
.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(
|
||||
|
||||
@@ -74,7 +74,7 @@ public final class FelisNeoForgeMod {
|
||||
try {
|
||||
player = source.getPlayerOrException();
|
||||
} catch (CommandSyntaxException e) {
|
||||
source.sendFailure(Component.literal("/link can only be run by a player."));
|
||||
source.sendFailure(Component.literal("/link 只能由玩家执行 / /link can only be run by a player."));
|
||||
return 0;
|
||||
}
|
||||
requestAndReply(source.getServer(), player);
|
||||
@@ -84,18 +84,22 @@ public final class FelisNeoForgeMod {
|
||||
|
||||
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
|
||||
UUID uuid = player.getUUID();
|
||||
player.sendSystemMessage(Component.literal("Requesting a link code…"));
|
||||
player.sendSystemMessage(Component.literal("正在获取绑定码… / Requesting a link code…"));
|
||||
io.submit(() -> {
|
||||
try {
|
||||
LinkCode code = linkClient.requestCode(uuid);
|
||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||
"Your link code: " + code.code()
|
||||
+ " — enter it on the web panel → Account (valid a few minutes).")));
|
||||
server.execute(() -> {
|
||||
player.sendSystemMessage(Component.literal(
|
||||
"绑定码 / Link code: " + code.code() + "(几分钟内有效 / valid a few minutes)"));
|
||||
player.sendSystemMessage(Component.literal(code.panelUrl() != null
|
||||
? "在此完成绑定 / Finish linking at: " + code.panelUrl()
|
||||
: "在网页控制台 → 账户 中输入 / Enter it on the web console → Account."));
|
||||
});
|
||||
} catch (LinkException e) {
|
||||
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
|
||||
uuid, e.statusCode(), e.errorCode(), e.getMessage());
|
||||
server.execute(() -> player.sendSystemMessage(Component.literal(
|
||||
"Couldn't get a link code right now. Please try again in a moment.")));
|
||||
"现在无法获取绑定码,请稍后再试 / Couldn't get a link code right now. Please try again in a moment.")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,14 +48,13 @@ import java.util.List;
|
||||
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &
|
||||
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join"
|
||||
* of a running owned server and the "Wake" of a stopped owned one), then closes the
|
||||
* menu. A refusal comes back as an {@code Error} frame and is shown to the player —
|
||||
* the only place claim/quota/policy failures surface — and readiness arrives as
|
||||
* {@code TransferReady} just before the proxy Connects them.
|
||||
* menu. A claim refusal comes back as an {@code Error} frame and is shown to the
|
||||
* player here; wake-path refusals (policy gate, capacity) are chat messages the
|
||||
* proxy's waiting queue sends directly. Readiness arrives as {@code TransferReady}
|
||||
* just before the proxy Connects them.
|
||||
*/
|
||||
public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener {
|
||||
|
||||
private static final Component MENU_TITLE =
|
||||
Component.text("Felis Servers", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
|
||||
private static final int MAX_TILES = 54; // a double chest, the GUI ceiling
|
||||
|
||||
/** Server names to show as tiles, in display order; loaded from config. */
|
||||
@@ -90,19 +89,21 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
||||
}
|
||||
|
||||
private void openMenu(Player player) {
|
||||
boolean zh = zh(player);
|
||||
if (servers.isEmpty()) {
|
||||
player.sendMessage(Component.text(
|
||||
"No servers are configured yet — ask an operator to set up felis-paper.",
|
||||
zh ? "还没有配置任何服务器——请管理员先配置 felis-paper。"
|
||||
: "No servers are configured yet — ask an operator to set up felis-paper.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
int shown = Math.min(servers.size(), MAX_TILES);
|
||||
List<String> view = new ArrayList<>(servers.subList(0, shown));
|
||||
MenuHolder holder = new MenuHolder(view);
|
||||
Inventory inv = Bukkit.createInventory(holder, invSize(shown), MENU_TITLE);
|
||||
Inventory inv = Bukkit.createInventory(holder, invSize(shown), menuTitle(zh));
|
||||
holder.setInventory(inv);
|
||||
for (int i = 0; i < shown; i++) {
|
||||
inv.setItem(i, loadingTile(view.get(i)));
|
||||
inv.setItem(i, loadingTile(view.get(i), zh));
|
||||
}
|
||||
player.openInventory(inv);
|
||||
// Ask the proxy for live status of every tile; answers repaint them.
|
||||
@@ -180,7 +181,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
||||
case ControlFrame.ERROR:
|
||||
// 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), NamedTextColor.RED));
|
||||
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
|
||||
@@ -203,7 +204,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
||||
return; // a server we are not showing
|
||||
}
|
||||
holder.put(frame.server(), frame);
|
||||
top.setItem(slot, tile(frame));
|
||||
top.setItem(slot, tile(frame, zh(player)));
|
||||
}
|
||||
|
||||
private void closeIfMenu(Player player) {
|
||||
@@ -214,21 +215,26 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
private ItemStack tile(ControlFrame f) {
|
||||
private static Component menuTitle(boolean zh) {
|
||||
return Component.text(zh ? "Felis 服务器" : "Felis Servers", NamedTextColor.AQUA)
|
||||
.decoration(TextDecoration.ITALIC, false);
|
||||
}
|
||||
|
||||
private ItemStack tile(ControlFrame f, boolean zh) {
|
||||
Material material;
|
||||
String action;
|
||||
NamedTextColor color;
|
||||
if (f.claimable()) {
|
||||
material = Material.GOLD_BLOCK;
|
||||
action = "Claim & Start";
|
||||
action = zh ? "认领并启动" : "Claim & Start";
|
||||
color = NamedTextColor.GOLD;
|
||||
} else if (f.ready()) {
|
||||
material = Material.LIME_CONCRETE;
|
||||
action = "Join";
|
||||
action = zh ? "加入" : "Join";
|
||||
color = NamedTextColor.GREEN;
|
||||
} else {
|
||||
material = Material.RED_CONCRETE;
|
||||
action = "Wake";
|
||||
action = zh ? "唤醒" : "Wake";
|
||||
color = NamedTextColor.RED;
|
||||
}
|
||||
ItemStack item = new ItemStack(material);
|
||||
@@ -236,18 +242,18 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
||||
meta.displayName(Component.text(action + " · " + f.server(), color)
|
||||
.decoration(TextDecoration.ITALIC, false));
|
||||
List<Component> lore = new ArrayList<>();
|
||||
lore.add(line("Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
|
||||
lore.add(line("Players", f.playersOnline() + "/" + f.playersMax()));
|
||||
lore.add(line(zh ? "状态" : "Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
|
||||
lore.add(line(zh ? "在线" : "Players", f.playersOnline() + "/" + f.playersMax()));
|
||||
meta.lore(lore);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private ItemStack loadingTile(String server) {
|
||||
private ItemStack loadingTile(String server, boolean zh) {
|
||||
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
|
||||
meta.lore(List.of(Component.text("Loading…", NamedTextColor.DARK_GRAY)
|
||||
meta.lore(List.of(Component.text(zh ? "加载中…" : "Loading…", NamedTextColor.DARK_GRAY)
|
||||
.decoration(TextDecoration.ITALIC, false)));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
@@ -259,27 +265,35 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
||||
.decoration(TextDecoration.ITALIC, false);
|
||||
}
|
||||
|
||||
private static String errorText(ControlFrame f) {
|
||||
private static String errorText(ControlFrame f, boolean zh) {
|
||||
String code = f.code();
|
||||
if (code != null) {
|
||||
switch (code) {
|
||||
case "not_linked":
|
||||
return "Link your account first — run /link, then finish on the web panel.";
|
||||
return zh ? "请先绑定账号——运行 /link,然后在网页控制台完成绑定。"
|
||||
: "Link your account first — run /link, then finish on the web console.";
|
||||
case "quota_exceeded":
|
||||
return "You've reached your server quota.";
|
||||
return zh ? "你已达到服务器配额上限。"
|
||||
: "You've reached your server quota.";
|
||||
case "already_claimed":
|
||||
return "That server was just claimed by someone else.";
|
||||
return zh ? "该服务器已被认领。"
|
||||
: "That server is already claimed.";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return f.message() != null && !f.message().isEmpty()
|
||||
? f.message()
|
||||
: (code != null ? code : "Request failed — please try again.");
|
||||
: (code != null ? code : (zh ? "请求失败,请重试。" : "Request failed — please try again."));
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
|
||||
private 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));
|
||||
}
|
||||
|
||||
@@ -14,22 +14,19 @@ import java.util.UUID;
|
||||
/**
|
||||
* FelisApiClient is the proxy's read/drive client for the felis-api internal face
|
||||
* (spec §7, §9). Where {@link LinkClient} mints account-link codes, this client
|
||||
* drives domain-autostart routing: it lists the registrable servers, resolves a
|
||||
* connecting virtual host to its server, polls a server's lifecycle status, pulls
|
||||
* the wake lever, and reports real player joins. It shares the {@link LinkConfig}
|
||||
* drives domain-autostart routing: it lists the registrable servers, polls a
|
||||
* server's lifecycle status, pulls the wake lever, and reports real player
|
||||
* joins. It shares the {@link LinkConfig}
|
||||
* (same internal base URL + service token) and the same zero-dependency JDK HTTP
|
||||
* stack, so it compiles straight into each loader jar with nothing to shade.
|
||||
*
|
||||
* <p>Every call authenticates with {@code Authorization: Bearer <serviceToken>}
|
||||
* and surfaces a non-success status as a {@link LinkException} carrying the HTTP
|
||||
* status, so the proxy can branch on it without parsing human text. The two that
|
||||
* matter for routing:
|
||||
* <ul>
|
||||
* <li>{@code wake} → 403 means the autostartPolicy gate refused this UUID (do
|
||||
* not enqueue the player); 429 means a wake is already cooling down
|
||||
* ("already waking, keep waiting"), not a failure.</li>
|
||||
* <li>{@code serverByHost} → 404 means the host maps to no server.</li>
|
||||
* </ul>
|
||||
* status, so the proxy can branch on it without parsing human text. The one that
|
||||
* matters most for routing: {@code wake} → 403 means the autostartPolicy gate
|
||||
* refused this UUID (do not enqueue the player); 429 means a wake is already
|
||||
* cooling down ("already waking, keep waiting"); 503 means the cluster is at
|
||||
* capacity (tell the player to try later — nothing is coming up).
|
||||
*/
|
||||
public final class FelisApiClient {
|
||||
private final LinkConfig config;
|
||||
@@ -57,16 +54,6 @@ public final class FelisApiClient {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* serverByHost resolves {@code subdomain.<root_domain>} to its server view
|
||||
* (GET /servers/by-host/{host}). A 404 surfaces as a LinkException with
|
||||
* statusCode 404 so the caller can distinguish "unknown host" from a transport
|
||||
* fault.
|
||||
*/
|
||||
public ServerView serverByHost(String host) throws LinkException {
|
||||
return ServerView.fromJson(getObject("/api/v1/servers/by-host/" + Objects.requireNonNull(host, "host"), 200));
|
||||
}
|
||||
|
||||
/** serverStatus reads one server's current lifecycle view (internal status). */
|
||||
public ServerView serverStatus(String name) throws LinkException {
|
||||
return ServerView.fromJson(getObject("/api/v1/internal/servers/" + Objects.requireNonNull(name, "name") + "/status", 200));
|
||||
@@ -75,8 +62,9 @@ public final class FelisApiClient {
|
||||
/**
|
||||
* wake pulls the domain-autostart lever for {@code name} on behalf of the
|
||||
* joining player (spec §9.1, §14). The reply (202) carries the current phase
|
||||
* and ready flag so the caller can decide whether to wait. A 403 (policy gate)
|
||||
* or 429 (cooldown) arrives as a LinkException the caller branches on.
|
||||
* and ready flag so the caller can decide whether to wait. A 403 (policy gate),
|
||||
* 429 (cooldown), or 503 {@code at_capacity} (running cap) arrives as a
|
||||
* LinkException the caller branches on.
|
||||
*/
|
||||
public ServerView wake(String name, UUID mcUuid) throws LinkException {
|
||||
Objects.requireNonNull(name, "name");
|
||||
@@ -142,7 +130,7 @@ public final class FelisApiClient {
|
||||
* completion leg of the in-game login flow (spec §B3). After the player redeems
|
||||
* the Bind Code on {@code console.<root_domain>} the login limbo polls this until
|
||||
* it flips true, then admits/transfers the player. {@code GET
|
||||
* /api/v1/internal/account/link/status/{mc_uuid}} → {@code {"linked":bool,...}};
|
||||
* /api/v1/internal/account/link/status/{mc_uuid}} → {@code {"linked":bool}};
|
||||
* read-only and keyed by the verified UUID, so it consumes nothing and is safe to
|
||||
* poll repeatedly. Anything but {@code linked:true} (including a missing field) is
|
||||
* reported as not-yet-linked — the caller keeps waiting rather than admitting on
|
||||
|
||||
@@ -22,7 +22,8 @@ import java.util.UUID;
|
||||
* <li>header {@code Authorization: Bearer <serviceToken>} (constant-time
|
||||
* compared server-side; an empty token fails closed)</li>
|
||||
* <li>request body {@code {"mc_uuid":"<uuid>"}}</li>
|
||||
* <li>success: HTTP 201 with {@code {"code","expires_at"}}</li>
|
||||
* <li>success: HTTP 201 with {@code {"code","expires_at","panel_url"?}}
|
||||
* ({@code panel_url} present only when a panel hostname is configured)</li>
|
||||
* <li>failure: the {@code {"error":{"code","message"}}} envelope</li>
|
||||
* </ul>
|
||||
*
|
||||
@@ -94,7 +95,11 @@ public final class LinkClient {
|
||||
"success body missing 'code'");
|
||||
}
|
||||
Object exp = obj.get("expires_at");
|
||||
return new LinkCode((String) code, exp instanceof String ? (String) exp : null);
|
||||
Object panelUrl = obj.get("panel_url");
|
||||
return new LinkCode((String) code,
|
||||
exp instanceof String ? (String) exp : null,
|
||||
panelUrl instanceof String && !((String) panelUrl).isEmpty()
|
||||
? (String) panelUrl : null);
|
||||
}
|
||||
|
||||
private LinkException parseError(int status, String text) {
|
||||
|
||||
@@ -13,10 +13,12 @@ import java.util.Objects;
|
||||
public final class LinkCode {
|
||||
private final String code;
|
||||
private final String expiresAt;
|
||||
private final String panelUrl;
|
||||
|
||||
public LinkCode(String code, String expiresAt) {
|
||||
public LinkCode(String code, String expiresAt, String panelUrl) {
|
||||
this.code = Objects.requireNonNull(code, "code");
|
||||
this.expiresAt = expiresAt;
|
||||
this.panelUrl = panelUrl;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
@@ -27,4 +29,12 @@ public final class LinkCode {
|
||||
public String expiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* panelUrl is the ready-to-open web-panel URL the server minted alongside the
|
||||
* code, or null when no panel hostname is configured server-side.
|
||||
*/
|
||||
public String panelUrl() {
|
||||
return panelUrl;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import java.util.Map;
|
||||
/**
|
||||
* ServerView is the proxy-side mirror of the felis-api lifecycle view of one
|
||||
* MinecraftServer (the {@code ServerInfo} the internal face emits for
|
||||
* {@code GET /servers}, {@code GET /servers/by-host/{host}} and the internal
|
||||
* status/wake replies). It is an immutable, dependency-free value object so the
|
||||
* {@code GET /servers} and the internal status/wake replies). It is an immutable, dependency-free value object so the
|
||||
* shared link core stays zero-dependency and source-shareable across all four
|
||||
* loaders.
|
||||
*
|
||||
|
||||
@@ -193,10 +193,12 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
|
||||
// errors carry user-safe text (the same {code,message} the external API returns),
|
||||
// but a transport failure (statusCode 0) carries internal IO detail — host names,
|
||||
// refused ports — that must not reach a player's screen, so it is generalized.
|
||||
// The lobby localizes known codes itself; this fallback message may reach the
|
||||
// screen raw, so it carries both languages in one line (the no-locale pattern).
|
||||
private static ControlFrame errorFrame(LinkException e, String server) {
|
||||
String code = e.errorCode() != null ? e.errorCode() : "error";
|
||||
String message = e.statusCode() == 0
|
||||
? "felis is temporarily unavailable — please try again."
|
||||
? "Felis 暂时不可用,请稍后再试 / Felis is temporarily unavailable — please try again."
|
||||
: e.getMessage();
|
||||
return ControlFrame.error(code, message, server);
|
||||
}
|
||||
|
||||
+39
-3
@@ -19,7 +19,8 @@ import java.util.Properties;
|
||||
* URL + service token (and its first-run template), so {@code /link} keeps working
|
||||
* exactly as before; these extra keys are read from the same properties file (or
|
||||
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOGIN_SERVER} /
|
||||
* {@code FELIS_LOBBY_SERVER}).
|
||||
* {@code FELIS_LOBBY_SERVER} / {@code FELIS_PANEL_HOSTNAME} /
|
||||
* {@code FELIS_ADMIN_HOSTNAME}).
|
||||
*
|
||||
* <p>The routing extras are optional at load time and the routing layer degrades rather
|
||||
* than crashing: a missing {@code root-domain} disables routing (with a clear log
|
||||
@@ -33,9 +34,13 @@ final class FelisVelocityConfig {
|
||||
static final String ENV_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN";
|
||||
static final String ENV_LOGIN = "FELIS_LOGIN_SERVER";
|
||||
static final String ENV_LOBBY = "FELIS_LOBBY_SERVER";
|
||||
static final String ENV_PANEL_HOSTNAME = "FELIS_PANEL_HOSTNAME";
|
||||
static final String ENV_ADMIN_HOSTNAME = "FELIS_ADMIN_HOSTNAME";
|
||||
private static final String KEY_ROOT_DOMAIN = "root-domain";
|
||||
private static final String KEY_LOGIN = "login-server";
|
||||
private static final String KEY_LOBBY = "lobby-server";
|
||||
private static final String KEY_PANEL_HOSTNAME = "panel-hostname";
|
||||
private static final String KEY_ADMIN_HOSTNAME = "admin-hostname";
|
||||
private static final String DEFAULT_LOGIN = "login";
|
||||
private static final String DEFAULT_LOBBY = "lobby";
|
||||
|
||||
@@ -43,13 +48,18 @@ final class FelisVelocityConfig {
|
||||
private final String rootDomain; // null → routing disabled
|
||||
private final String loginServer;
|
||||
private final String lobbyServer;
|
||||
private final String panelHostname; // null → fall back to console.<root>
|
||||
private final String adminHostname; // null → fall back to op.console.<root>
|
||||
|
||||
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain,
|
||||
String loginServer, String lobbyServer) {
|
||||
String loginServer, String lobbyServer,
|
||||
String panelHostname, String adminHostname) {
|
||||
this.linkConfig = linkConfig;
|
||||
this.rootDomain = rootDomain;
|
||||
this.loginServer = loginServer;
|
||||
this.lobbyServer = lobbyServer;
|
||||
this.panelHostname = panelHostname;
|
||||
this.adminHostname = adminHostname;
|
||||
}
|
||||
|
||||
static FelisVelocityConfig load(Path file) throws IOException {
|
||||
@@ -63,13 +73,17 @@ final class FelisVelocityConfig {
|
||||
String root = trimToNull(firstNonBlank(System.getenv(ENV_ROOT_DOMAIN), props.getProperty(KEY_ROOT_DOMAIN)));
|
||||
String login = trimToNull(firstNonBlank(System.getenv(ENV_LOGIN), props.getProperty(KEY_LOGIN)));
|
||||
String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY)));
|
||||
String panel = trimToNull(firstNonBlank(System.getenv(ENV_PANEL_HOSTNAME), props.getProperty(KEY_PANEL_HOSTNAME)));
|
||||
String admin = trimToNull(firstNonBlank(System.getenv(ENV_ADMIN_HOSTNAME), props.getProperty(KEY_ADMIN_HOSTNAME)));
|
||||
login = login == null ? DEFAULT_LOGIN : login;
|
||||
lobby = lobby == null ? DEFAULT_LOBBY : lobby;
|
||||
if (login.equalsIgnoreCase(lobby)) {
|
||||
throw new IOException("login-server and lobby-server must be different");
|
||||
}
|
||||
return new FelisVelocityConfig(
|
||||
link, root == null ? null : root.toLowerCase(Locale.ROOT), login, lobby);
|
||||
link, root == null ? null : root.toLowerCase(Locale.ROOT), login, lobby,
|
||||
panel == null ? null : panel.toLowerCase(Locale.ROOT),
|
||||
admin == null ? null : admin.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
LinkConfig linkConfig() {
|
||||
@@ -95,6 +109,28 @@ final class FelisVelocityConfig {
|
||||
return lobbyServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* panelHostname is the player web-panel host, falling back to
|
||||
* {@code console.<root-domain>}; null when neither is configured.
|
||||
*/
|
||||
String panelHostname() {
|
||||
if (panelHostname != null) {
|
||||
return panelHostname;
|
||||
}
|
||||
return rootDomain == null ? null : "console." + rootDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* adminHostname is the staff op.console host, falling back to
|
||||
* {@code op.console.<root-domain>}; null when neither is configured.
|
||||
*/
|
||||
String adminHostname() {
|
||||
if (adminHostname != null) {
|
||||
return adminHostname;
|
||||
}
|
||||
return rootDomain == null ? null : "op.console." + rootDomain;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
if (a != null && !a.trim().isEmpty()) {
|
||||
return a;
|
||||
|
||||
+176
-73
@@ -28,6 +28,7 @@ import org.slf4j.Logger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -180,20 +181,34 @@ public final class FelisVelocityPlugin {
|
||||
}
|
||||
|
||||
private void requestAndReply(Player player) {
|
||||
player.sendMessage(Component.text("Requesting a link code…", NamedTextColor.GRAY));
|
||||
boolean zh = zh(player);
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "正在获取绑定码……" : "Requesting a link code…", NamedTextColor.GRAY));
|
||||
async(() -> {
|
||||
try {
|
||||
LinkCode code = linkClient.requestCode(player.getUniqueId());
|
||||
player.sendMessage(Component.text("Your link code: ", NamedTextColor.GREEN)
|
||||
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
|
||||
player.sendMessage(Component.text(
|
||||
"Enter it on the web panel → Account to finish linking (valid a few minutes).",
|
||||
NamedTextColor.GRAY));
|
||||
zh ? "你的绑定码:" : "Your link code: ", NamedTextColor.GREEN)
|
||||
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
|
||||
String panelUrl = code.panelUrl();
|
||||
if (panelUrl != null) {
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "在这里输入它完成绑定(几分钟内有效):"
|
||||
: "Enter it here to finish linking (valid a few minutes):",
|
||||
NamedTextColor.GRAY));
|
||||
player.sendMessage(Component.text(" " + panelUrl, NamedTextColor.WHITE));
|
||||
} else {
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "在网页控制台 → 账户 中输入它完成绑定(几分钟内有效)。"
|
||||
: "Enter it on the web console → Account to finish linking (valid a few minutes).",
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
} catch (LinkException e) {
|
||||
logger.warn("link code request failed for {} (status={}, code={}): {}",
|
||||
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
|
||||
player.sendMessage(Component.text(
|
||||
"Couldn't get a link code right now. Please try again in a moment.",
|
||||
zh ? "现在无法获取绑定码,请稍后再试。"
|
||||
: "Couldn't get a link code right now. Please try again in a moment.",
|
||||
NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
@@ -210,6 +225,7 @@ public final class FelisVelocityPlugin {
|
||||
// /felis server the felis servers this proxy knows
|
||||
// /felis go <server> wake a server and move me in when it's ready
|
||||
// /felis claim take ownership of the server I'm on
|
||||
// /felis migrate open a migration of my servers to another account
|
||||
// /felis web where the web consoles live
|
||||
// /felis web op approve <code> vouch for a pending op.console staff login (§B)
|
||||
//
|
||||
@@ -301,15 +317,18 @@ public final class FelisVelocityPlugin {
|
||||
// login limbo (or not yet on any backend): they have not passed the front door.
|
||||
// Fails closed on an unknown position.
|
||||
private boolean ensureOutOfLimbo(Player player) {
|
||||
boolean zh = zh(player);
|
||||
Optional<ServerConnection> current = player.getCurrentServer();
|
||||
if (current.isEmpty()) {
|
||||
player.sendMessage(Component.text(
|
||||
"Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW));
|
||||
zh ? "请稍候——完成连接后再使用 /felis。"
|
||||
: "Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW));
|
||||
return false;
|
||||
}
|
||||
if (config.loginServer().equalsIgnoreCase(current.get().getServerInfo().getName())) {
|
||||
player.sendMessage(Component.text(
|
||||
"Finish signing in first — /felis isn't available from the login area.",
|
||||
zh ? "请先完成登录——登录区内无法使用 /felis。"
|
||||
: "Finish signing in first — /felis isn't available from the login area.",
|
||||
NamedTextColor.YELLOW));
|
||||
return false;
|
||||
}
|
||||
@@ -328,53 +347,69 @@ public final class FelisVelocityPlugin {
|
||||
if (!gateInfo(source)) {
|
||||
return;
|
||||
}
|
||||
boolean zh = zh(source);
|
||||
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
|
||||
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
|
||||
if (!routingActive) {
|
||||
source.sendMessage(Component.text(
|
||||
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
|
||||
zh ? " routing: 已禁用" + (onlineMode ? "(未设置 root-domain)" : "(离线模式)")
|
||||
: " routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
|
||||
NamedTextColor.YELLOW));
|
||||
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
|
||||
source.sendMessage(Component.text(
|
||||
zh ? " /felis help 查看命令" : " /felis help for commands", NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
source.sendMessage(field("root-domain", config.rootDomain()));
|
||||
source.sendMessage(field("login", config.loginServer()));
|
||||
source.sendMessage(field("lobby", config.lobbyServer()));
|
||||
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
|
||||
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
|
||||
source.sendMessage(Component.text(
|
||||
zh ? " /felis help 查看命令" : " /felis help for commands", NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
private void sendHelp(CommandSource source) {
|
||||
source.sendMessage(Component.text("Felis commands", NamedTextColor.AQUA));
|
||||
helpLine(source, "/felis", "proxy and routing status");
|
||||
helpLine(source, "/felis server", "the felis servers this proxy knows");
|
||||
helpLine(source, "/felis go <server>", "start a server and move you in when it's ready");
|
||||
helpLine(source, "/felis claim", "take ownership of the server you're on");
|
||||
helpLine(source, "/felis migrate", "move your servers to another account");
|
||||
helpLine(source, "/felis web", "where the web consoles live");
|
||||
helpLine(source, "/felis web op approve <code>", "approve a pending operator sign-in");
|
||||
boolean zh = zh(source);
|
||||
source.sendMessage(Component.text(zh ? "Felis 命令" : "Felis commands", NamedTextColor.AQUA));
|
||||
helpLine(source, "/felis",
|
||||
zh ? "代理与路由状态" : "proxy and routing status");
|
||||
helpLine(source, "/felis server",
|
||||
zh ? "此代理已知的 felis 服务器" : "the felis servers this proxy knows");
|
||||
helpLine(source, "/felis go <server>",
|
||||
zh ? "启动服务器并在就绪后把你传送过去" : "start a server and move you in when it's ready");
|
||||
helpLine(source, "/felis claim",
|
||||
zh ? "认领你所在的服务器" : "take ownership of the server you're on");
|
||||
helpLine(source, "/felis migrate",
|
||||
zh ? "把你的服务器迁移到另一个账户" : "move your servers to another account");
|
||||
helpLine(source, "/felis web",
|
||||
zh ? "网页控制台地址" : "where the web consoles live");
|
||||
helpLine(source, "/felis web op approve <code>",
|
||||
zh ? "批准待处理的管理员登录" : "approve a pending operator sign-in");
|
||||
}
|
||||
|
||||
private void sendServerList(CommandSource source) {
|
||||
if (!gateInfo(source)) {
|
||||
return;
|
||||
}
|
||||
boolean zh = zh(source);
|
||||
if (!routingActive) {
|
||||
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
|
||||
source.sendMessage(Component.text(
|
||||
zh ? "Felis 路由已禁用。" : "Felis routing is disabled.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
List<ServerView> servers = registry.all().stream()
|
||||
.filter(v -> !isSystemServer(v.name()))
|
||||
.toList();
|
||||
if (servers.isEmpty()) {
|
||||
source.sendMessage(Component.text("No felis servers known yet.", NamedTextColor.GRAY));
|
||||
source.sendMessage(Component.text(
|
||||
zh ? "暂无已知的 felis 服务器。" : "No felis servers known yet.", NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
source.sendMessage(Component.text("Felis servers:", NamedTextColor.AQUA));
|
||||
source.sendMessage(Component.text(zh ? "Felis 服务器:" : "Felis servers:", NamedTextColor.AQUA));
|
||||
for (ServerView v : servers) {
|
||||
String phase = v.phase() == null ? "?" : v.phase();
|
||||
String ready = v.ready() ? (zh ? ",就绪" : ", ready") : "";
|
||||
source.sendMessage(Component.text(" " + v.name() + " ", NamedTextColor.WHITE)
|
||||
.append(Component.text("[" + phase + (v.ready() ? ", ready" : "") + "]",
|
||||
.append(Component.text("[" + phase + ready + "]",
|
||||
v.ready() ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
|
||||
}
|
||||
}
|
||||
@@ -384,8 +419,9 @@ public final class FelisVelocityPlugin {
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
}
|
||||
boolean zh = zh(player);
|
||||
if (!routingActive) {
|
||||
player.sendMessage(routingDisabled());
|
||||
player.sendMessage(routingDisabled(zh));
|
||||
return;
|
||||
}
|
||||
String target = serverArg.trim();
|
||||
@@ -398,12 +434,15 @@ public final class FelisVelocityPlugin {
|
||||
}
|
||||
if (match == null) {
|
||||
player.sendMessage(Component.text(
|
||||
"No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW));
|
||||
zh ? "没有名为「" + target + "」的 felis 服务器。试试 /felis server。"
|
||||
: "No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
Optional<ServerConnection> current = player.getCurrentServer();
|
||||
if (current.isPresent() && current.get().getServerInfo().getName().equalsIgnoreCase(match.name())) {
|
||||
player.sendMessage(Component.text("You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "你已经在「" + match.name() + "」上了。"
|
||||
: "You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
// Wake + park + transfer through the shared waiting queue; it reports its own
|
||||
@@ -416,29 +455,36 @@ public final class FelisVelocityPlugin {
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
}
|
||||
boolean zh = zh(player);
|
||||
if (!routingActive) {
|
||||
player.sendMessage(routingDisabled());
|
||||
player.sendMessage(routingDisabled(zh));
|
||||
return;
|
||||
}
|
||||
Optional<ServerConnection> current = player.getCurrentServer();
|
||||
if (current.isEmpty()) {
|
||||
player.sendMessage(Component.text("Join a server before claiming it.", NamedTextColor.YELLOW));
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "请先加入一个服务器再认领。" : "Join a server before claiming it.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
String name = current.get().getServerInfo().getName();
|
||||
if (!registry.isManaged(name)) {
|
||||
player.sendMessage(Component.text(
|
||||
"« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW));
|
||||
zh ? "「" + name + "」不是可认领的 felis 服务器。"
|
||||
: "« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
UUID uuid = player.getUniqueId();
|
||||
player.sendMessage(Component.text("Claiming « " + name + " »…", NamedTextColor.GRAY));
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "正在认领「" + name + "」……" : "Claiming « " + name + " »…", NamedTextColor.GRAY));
|
||||
async(() -> {
|
||||
try {
|
||||
apiClient.claim(name, uuid);
|
||||
player.sendMessage(Component.text("You now own « " + name + " ».", NamedTextColor.GREEN));
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "你现在拥有「" + name + "」了。" : "You now own « " + name + " ».",
|
||||
NamedTextColor.GREEN));
|
||||
} catch (LinkException e) {
|
||||
player.sendMessage(Component.text(claimError(e, name), NamedTextColor.RED));
|
||||
player.sendMessage(Component.text(claimError(e, name, zh), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -455,28 +501,34 @@ public final class FelisVelocityPlugin {
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
}
|
||||
boolean zh = zh(player);
|
||||
if (!routingActive) {
|
||||
player.sendMessage(routingDisabled());
|
||||
player.sendMessage(routingDisabled(zh));
|
||||
return;
|
||||
}
|
||||
UUID uuid = player.getUniqueId();
|
||||
String who = player.getUsername();
|
||||
player.sendMessage(Component.text("Starting account migration…", NamedTextColor.GRAY));
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "正在发起账户迁移……" : "Starting account migration…", NamedTextColor.GRAY));
|
||||
async(() -> {
|
||||
try {
|
||||
apiClient.migrateStart(uuid);
|
||||
String root = config.rootDomain();
|
||||
String panelHost = config.panelHostname();
|
||||
player.sendMessage(Component.text(
|
||||
"Migration started — finish it on the web console:", NamedTextColor.GREEN));
|
||||
zh ? "迁移已发起——请在网页控制台完成:"
|
||||
: "Migration started — finish it on the web console:", NamedTextColor.GREEN));
|
||||
player.sendMessage(Component.text(
|
||||
" " + (root == null ? "the players' web console" : "https://console." + root),
|
||||
" " + (panelHost == null
|
||||
? (zh ? "玩家网页控制台" : "the players' web console")
|
||||
: "https://" + panelHost + "/account"),
|
||||
NamedTextColor.WHITE));
|
||||
player.sendMessage(Component.text(
|
||||
"You'll confirm it's you, name the account to receive your servers, then get a code.",
|
||||
zh ? "你需要确认身份、指定接收服务器的账户,然后获得一个迁移码。"
|
||||
: "You'll confirm it's you, name the account to receive your servers, then get a code.",
|
||||
NamedTextColor.GRAY));
|
||||
logger.info("Felis: account migration started in-game by {} ({})", who, uuid);
|
||||
} catch (LinkException e) {
|
||||
player.sendMessage(Component.text(migrateError(e), NamedTextColor.RED));
|
||||
player.sendMessage(Component.text(migrateError(e, zh), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -485,17 +537,26 @@ public final class FelisVelocityPlugin {
|
||||
if (!gateInfo(source)) {
|
||||
return;
|
||||
}
|
||||
String root = config.rootDomain();
|
||||
if (root == null) {
|
||||
boolean zh = zh(source);
|
||||
String panelHost = config.panelHostname();
|
||||
String adminHost = config.adminHostname();
|
||||
if (panelHost == null && adminHost == null) {
|
||||
source.sendMessage(Component.text(
|
||||
"The web console isn't configured on this proxy.", NamedTextColor.YELLOW));
|
||||
zh ? "此代理未配置网页控制台。"
|
||||
: "The web console isn't configured on this proxy.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
source.sendMessage(Component.text("Felis web consoles", NamedTextColor.AQUA));
|
||||
source.sendMessage(field("players", "https://console." + root));
|
||||
source.sendMessage(field("operators", "https://op.console." + root));
|
||||
source.sendMessage(Component.text(
|
||||
" operators: /felis web op approve <code> vouches for a pending sign-in",
|
||||
zh ? "Felis 网页控制台" : "Felis web consoles", NamedTextColor.AQUA));
|
||||
if (panelHost != null) {
|
||||
source.sendMessage(field(zh ? "玩家" : "players", "https://" + panelHost));
|
||||
}
|
||||
if (adminHost != null) {
|
||||
source.sendMessage(field(zh ? "管理员" : "operators", "https://" + adminHost));
|
||||
}
|
||||
source.sendMessage(Component.text(
|
||||
zh ? " 管理员:/felis web op approve <code> 用于为待处理登录作担保"
|
||||
: " operators: /felis web op approve <code> vouches for a pending sign-in",
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
@@ -503,12 +564,20 @@ public final class FelisVelocityPlugin {
|
||||
if (!gateInfo(source)) {
|
||||
return;
|
||||
}
|
||||
source.sendMessage(Component.text("Operator sign-in", NamedTextColor.AQUA));
|
||||
boolean zh = zh(source);
|
||||
String adminHost = config.adminHostname();
|
||||
String site = adminHost != null ? adminHost : (zh ? "管理员控制台" : "the operator console");
|
||||
source.sendMessage(Component.text(
|
||||
"An operator signing in at op.console shows an approval code. Run", NamedTextColor.GRAY));
|
||||
zh ? "管理员登录" : "Operator sign-in", NamedTextColor.AQUA));
|
||||
source.sendMessage(Component.text(
|
||||
zh ? "管理员在 " + site + " 登录时会显示一个批准码。运行"
|
||||
: "An operator signing in at " + site + " shows an approval code. Run",
|
||||
NamedTextColor.GRAY));
|
||||
source.sendMessage(Component.text(" /felis web op approve <code>", NamedTextColor.WHITE));
|
||||
source.sendMessage(Component.text(
|
||||
"to vouch for it — you must be an online, linked administrator.", NamedTextColor.GRAY));
|
||||
zh ? "即可为其担保——你必须是已绑定并在线的管理员。"
|
||||
: "to vouch for it — you must be an online, linked administrator.",
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
private void doOpApprove(CommandSource source, String codeArg) {
|
||||
@@ -516,36 +585,56 @@ public final class FelisVelocityPlugin {
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
}
|
||||
boolean zh = zh(player);
|
||||
if (!routingActive) {
|
||||
player.sendMessage(routingDisabled());
|
||||
player.sendMessage(routingDisabled(zh));
|
||||
return;
|
||||
}
|
||||
String code = codeArg.trim();
|
||||
if (!OP_LOGIN_CODE.matcher(code).matches()) {
|
||||
player.sendMessage(Component.text(
|
||||
"That doesn't look like a valid approval code.", NamedTextColor.RED));
|
||||
zh ? "这不像一个有效的批准码。" : "That doesn't look like a valid approval code.",
|
||||
NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
UUID approver = player.getUniqueId();
|
||||
String who = player.getUsername();
|
||||
player.sendMessage(Component.text("Approving operator sign-in…", NamedTextColor.GRAY));
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "正在批准管理员登录……" : "Approving operator sign-in…", NamedTextColor.GRAY));
|
||||
async(() -> {
|
||||
try {
|
||||
apiClient.opLoginApprove(code, approver);
|
||||
player.sendMessage(Component.text(
|
||||
"Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
|
||||
zh ? "已批准——对方现在可以完成登录了。"
|
||||
: "Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
|
||||
logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver);
|
||||
} catch (LinkException e) {
|
||||
player.sendMessage(Component.text(opApproveError(e), NamedTextColor.RED));
|
||||
player.sendMessage(Component.text(opApproveError(e, zh), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private Component routingDisabled() {
|
||||
/**
|
||||
* zh reports whether the caller's client locale is Chinese, so player-facing
|
||||
* text can follow the client language. The console (and any non-player source)
|
||||
* always reads English, and a client that has not yet sent its settings falls
|
||||
* back to English too. Package-private so {@link WaitingRouter} and the other
|
||||
* proxy faces share the one locale rule.
|
||||
*/
|
||||
static boolean zh(CommandSource source) {
|
||||
if (!(source instanceof Player)) {
|
||||
return false;
|
||||
}
|
||||
Locale locale = ((Player) source).getPlayerSettings().getLocale();
|
||||
return locale != null && "zh".equalsIgnoreCase(locale.getLanguage());
|
||||
}
|
||||
|
||||
private Component routingDisabled(boolean zh) {
|
||||
return Component.text(
|
||||
"Felis routing is disabled on this proxy" + (onlineMode ? " (no root-domain set)." : " (offline mode)."),
|
||||
zh ? "此代理已禁用 Felis 路由" + (onlineMode ? "(未设置 root-domain)。" : "(离线模式)。")
|
||||
: "Felis routing is disabled on this proxy" + (onlineMode ? " (no root-domain set)." : " (offline mode)."),
|
||||
NamedTextColor.YELLOW);
|
||||
}
|
||||
|
||||
@@ -555,52 +644,66 @@ public final class FelisVelocityPlugin {
|
||||
}
|
||||
|
||||
// claimError maps the felis-api claim refusals (spec §9.3) to player-safe text.
|
||||
private static String claimError(LinkException e, String server) {
|
||||
private static String claimError(LinkException e, String server, boolean zh) {
|
||||
switch (e.statusCode()) {
|
||||
case 412:
|
||||
return "Link your account on the web console before claiming a server.";
|
||||
return zh ? "请先在网页控制台绑定账户,再认领服务器。"
|
||||
: "Link your account on the web console before claiming a server.";
|
||||
case 403:
|
||||
return "You've reached your server limit — you can't claim another.";
|
||||
return zh ? "你已达到服务器数量上限——无法再认领。"
|
||||
: "You've reached your server limit — you can't claim another.";
|
||||
case 409:
|
||||
return "« " + server + " » is already owned.";
|
||||
return zh ? "「" + server + "」已有主人。"
|
||||
: "« " + server + " » is already owned.";
|
||||
case 404:
|
||||
return "« " + server + " » is no longer available.";
|
||||
return zh ? "「" + server + "」已不可用。"
|
||||
: "« " + server + " » is no longer available.";
|
||||
case 0:
|
||||
return "Felis is temporarily unavailable — please try again.";
|
||||
return zh ? "Felis 暂时不可用——请稍后再试。"
|
||||
: "Felis is temporarily unavailable — please try again.";
|
||||
default:
|
||||
return "Couldn't claim « " + server + " » right now. Please try again.";
|
||||
return zh ? "现在无法认领「" + server + "」。请稍后再试。"
|
||||
: "Couldn't claim « " + server + " » right now. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// migrateError maps the felis-api migrate-start refusals (spec §B3) to player-safe
|
||||
// text. A 404 means the caller's UUID isn't linked to any account to migrate; a 409
|
||||
// means the linked account can't start one (already migrated, or retired).
|
||||
private static String migrateError(LinkException e) {
|
||||
private static String migrateError(LinkException e, boolean zh) {
|
||||
switch (e.statusCode()) {
|
||||
case 404:
|
||||
return "Link your account on the web console before migrating.";
|
||||
return zh ? "请先在网页控制台绑定账户,再进行迁移。"
|
||||
: "Link your account on the web console before migrating.";
|
||||
case 409:
|
||||
return "This account can't start a migration (already migrated or retired).";
|
||||
return zh ? "此账户无法发起迁移(已迁移或已停用)。"
|
||||
: "This account can't start a migration (already migrated or retired).";
|
||||
case 0:
|
||||
return "Felis is temporarily unavailable — please try again.";
|
||||
return zh ? "Felis 暂时不可用——请稍后再试。"
|
||||
: "Felis is temporarily unavailable — please try again.";
|
||||
default:
|
||||
return "Couldn't start the migration right now. Please try again.";
|
||||
return zh ? "现在无法发起迁移。请稍后再试。"
|
||||
: "Couldn't start the migration right now. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// opApproveError maps the internal approve refusals to player-safe text. A 403 is
|
||||
// the API's own admin re-check (defence in depth over the in-game gate); a 404
|
||||
// means no live pending request carries that code.
|
||||
private static String opApproveError(LinkException e) {
|
||||
private static String opApproveError(LinkException e, boolean zh) {
|
||||
switch (e.statusCode()) {
|
||||
case 403:
|
||||
return "Only a linked administrator may approve an operator sign-in.";
|
||||
return zh ? "只有已绑定的管理员才能批准管理员登录。"
|
||||
: "Only a linked administrator may approve an operator sign-in.";
|
||||
case 404:
|
||||
return "No pending operator sign-in with that code (it may have expired).";
|
||||
return zh ? "没有携带该码的待处理管理员登录(可能已过期)。"
|
||||
: "No pending operator sign-in with that code (it may have expired).";
|
||||
case 0:
|
||||
return "Felis is temporarily unavailable — please try again.";
|
||||
return zh ? "Felis 暂时不可用——请稍后再试。"
|
||||
: "Felis is temporarily unavailable — please try again.";
|
||||
default:
|
||||
return "Couldn't approve that sign-in right now. Please try again.";
|
||||
return zh ? "现在无法批准该登录。请稍后再试。"
|
||||
: "Couldn't approve that sign-in right now. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,14 +54,16 @@ public final class MotdResponder {
|
||||
event.setPing(b.build());
|
||||
}
|
||||
|
||||
// The server-list ping carries no client locale, so the MOTD status uses the
|
||||
// both-languages-in-one-line pattern the modded /link clients share.
|
||||
private static String statusLine(ServerView v) {
|
||||
if (v.ready()) {
|
||||
return "online";
|
||||
return "在线 / online";
|
||||
}
|
||||
if ("Running".equals(v.desiredState())) {
|
||||
return "starting…";
|
||||
return "启动中… / starting…";
|
||||
}
|
||||
return "sleeping — join to wake";
|
||||
return "休眠中,加入即唤醒 / sleeping — join to wake";
|
||||
}
|
||||
|
||||
private static NamedTextColor statusColor(ServerView v) {
|
||||
|
||||
@@ -47,8 +47,10 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* command/menu queue entries are checked the same way. The wake is then gated
|
||||
* server-side by autostartPolicy keyed on the player's online-mode UUID: a 403 means
|
||||
* this player may not start the server (we tell them and stop), a 429 means a wake is
|
||||
* already in flight (we keep waiting). Real user-backend joins are reported back so
|
||||
* the reaper sees activity and the player is auto-added to the allowlist.
|
||||
* already in flight (we keep waiting), and a 503 means the cluster is at capacity
|
||||
* (we tell them to try later — nothing is coming up, so we do not enqueue). Real
|
||||
* user-backend joins are reported back so the reaper sees activity and the player is
|
||||
* auto-added to the allowlist.
|
||||
*/
|
||||
public final class WaitingRouter {
|
||||
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
|
||||
@@ -132,7 +134,9 @@ public final class WaitingRouter {
|
||||
if (login.isEmpty()) {
|
||||
event.setInitialServer(null);
|
||||
player.disconnect(Component.text(
|
||||
"The Felis login gate is unavailable. Please reconnect shortly.",
|
||||
FelisVelocityPlugin.zh(player)
|
||||
? "Felis 登录网关不可用,请稍后重连。"
|
||||
: "The Felis login gate is unavailable. Please reconnect shortly.",
|
||||
NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
@@ -161,7 +165,10 @@ public final class WaitingRouter {
|
||||
event.setResult(ServerPreConnectEvent.ServerResult.denied());
|
||||
if (!serverNamed(event.getOriginalServer(), lobbyServer)) {
|
||||
player.sendMessage(Component.text(
|
||||
"The login gate may only release players to the lobby.", NamedTextColor.RED));
|
||||
FelisVelocityPlugin.zh(player)
|
||||
? "登录网关只能把玩家放行到大厅。"
|
||||
: "The login gate may only release players to the lobby.",
|
||||
NamedTextColor.RED));
|
||||
log.warn("Felis: denied login-gate transfer for {} to {}",
|
||||
player.getUniqueId(), event.getOriginalServer().getServerInfo().getName());
|
||||
return null;
|
||||
@@ -173,17 +180,20 @@ public final class WaitingRouter {
|
||||
private void authorizeLoginRelease(ServerPreConnectEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
UUID id = player.getUniqueId();
|
||||
boolean zh = FelisVelocityPlugin.zh(player);
|
||||
try {
|
||||
if (!api.linkStatus(id)) {
|
||||
player.sendMessage(Component.text(
|
||||
"Finish signing in before leaving the login area.", NamedTextColor.YELLOW));
|
||||
zh ? "请先完成登录,再离开登录区。"
|
||||
: "Finish signing in before leaving the login area.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
} catch (LinkException e) {
|
||||
log.warn("Felis: could not verify login release for {} (status={}): {}",
|
||||
id, e.statusCode(), e.getMessage());
|
||||
player.sendMessage(Component.text(
|
||||
"Login verification is temporarily unavailable. Please wait and try again.",
|
||||
zh ? "登录验证暂时不可用,请稍候重试。"
|
||||
: "Login verification is temporarily unavailable. Please wait and try again.",
|
||||
NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
@@ -202,7 +212,8 @@ public final class WaitingRouter {
|
||||
pendingTargets.remove(id, targetName);
|
||||
event.setResult(ServerPreConnectEvent.ServerResult.allowed(event.getOriginalServer()));
|
||||
player.sendMessage(Component.text(
|
||||
"« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
|
||||
zh ? "「" + targetName + "」已不可用。"
|
||||
: "« " + targetName + " » is no longer available.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
Optional<RegisteredServer> backend = registry.registered(targetName);
|
||||
@@ -260,11 +271,13 @@ public final class WaitingRouter {
|
||||
continue;
|
||||
}
|
||||
Player player = po.get();
|
||||
boolean zh = FelisVelocityPlugin.zh(player);
|
||||
if (now > w.deadlineMillis) {
|
||||
waiting.remove(id);
|
||||
player.sendMessage(Component.text(
|
||||
"« " + w.serverName + " » is taking longer than expected to start. "
|
||||
+ "You can try again from the lobby later.", NamedTextColor.YELLOW));
|
||||
zh ? "「" + w.serverName + "」启动耗时超出预期。你可以稍后在大厅重试。"
|
||||
: "« " + w.serverName + " » is taking longer than expected to start. "
|
||||
+ "You can try again from the lobby later.", NamedTextColor.YELLOW));
|
||||
continue;
|
||||
}
|
||||
Boolean ready = readyCache.get(w.serverName);
|
||||
@@ -287,7 +300,8 @@ public final class WaitingRouter {
|
||||
if (!api.linkStatus(id)) {
|
||||
waiting.remove(id);
|
||||
player.sendMessage(Component.text(
|
||||
"Your account is no longer linked. Reconnect to sign in again.",
|
||||
zh ? "你的账户已不再绑定。请重连以重新登录。"
|
||||
: "Your account is no longer linked. Reconnect to sign in again.",
|
||||
NamedTextColor.RED));
|
||||
continue;
|
||||
}
|
||||
@@ -298,7 +312,8 @@ public final class WaitingRouter {
|
||||
}
|
||||
waiting.remove(id);
|
||||
player.sendMessage(Component.text(
|
||||
"« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
|
||||
zh ? "「" + w.serverName + "」已就绪——正在把你传送过去……"
|
||||
: "« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
|
||||
// Tell a menu-driven lobby its tile is live before we pull the player off
|
||||
// it; the proxy still performs the actual Connect just below.
|
||||
MenuTransferListener listener = menuListener;
|
||||
@@ -311,18 +326,21 @@ public final class WaitingRouter {
|
||||
|
||||
private void authorizeAndWait(Player player, String serverName, boolean fromMenu) {
|
||||
UUID id = player.getUniqueId();
|
||||
boolean zh = FelisVelocityPlugin.zh(player);
|
||||
plugin.async(() -> {
|
||||
try {
|
||||
if (!api.linkStatus(id)) {
|
||||
player.sendMessage(Component.text(
|
||||
"Finish signing in before joining a server.", NamedTextColor.YELLOW));
|
||||
zh ? "请先完成登录,再加入服务器。"
|
||||
: "Finish signing in before joining a server.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
} catch (LinkException e) {
|
||||
log.warn("Felis: could not verify queue entry for {} (status={}): {}",
|
||||
id, e.statusCode(), e.getMessage());
|
||||
player.sendMessage(Component.text(
|
||||
"Login verification is temporarily unavailable. Please try again shortly.",
|
||||
zh ? "登录验证暂时不可用,请稍后重试。"
|
||||
: "Login verification is temporarily unavailable. Please try again shortly.",
|
||||
NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
@@ -335,26 +353,44 @@ public final class WaitingRouter {
|
||||
// both the account gate and the server-side autostart policy.
|
||||
private void wakeAndWaitLinked(Player player, String serverName, boolean fromMenu) {
|
||||
UUID id = player.getUniqueId();
|
||||
boolean zh = FelisVelocityPlugin.zh(player);
|
||||
try {
|
||||
api.wake(serverName, id);
|
||||
} catch (LinkException e) {
|
||||
switch (e.statusCode()) {
|
||||
case 403:
|
||||
player.sendMessage(Component.text(
|
||||
"You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
|
||||
zh ? "你无权启动「" + serverName + "」。"
|
||||
: "You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
|
||||
return;
|
||||
case 429:
|
||||
break; // a wake is already in flight → join the existing wait
|
||||
case 503:
|
||||
if ("at_capacity".equals(e.errorCode())) {
|
||||
// at_capacity: nothing is coming up, so enqueueing would only strand
|
||||
// the player until the timeout. Be honest and let them retry later.
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "集群当前已满——「" + serverName + "」暂时无法启动。请稍后再试。"
|
||||
: "The cluster is at capacity right now — « " + serverName
|
||||
+ " » can't start. Please try again later.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
// A 503 without the at_capacity code is a plain outage, not a
|
||||
// capacity verdict — report it like any other failure.
|
||||
// fall through
|
||||
default:
|
||||
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
|
||||
player.sendMessage(Component.text(
|
||||
"Couldn't start « " + serverName + " » right now. Try again shortly.",
|
||||
zh ? "现在无法启动「" + serverName + "」。请稍后再试。"
|
||||
: "Couldn't start « " + serverName + " » right now. Try again shortly.",
|
||||
NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
}
|
||||
player.sendMessage(Component.text(
|
||||
"Starting « " + serverName + " » — you'll be moved in automatically.",
|
||||
zh ? "正在启动「" + serverName + "」——就绪后会自动把你传送过去。"
|
||||
: "Starting « " + serverName + " » — you'll be moved in automatically.",
|
||||
NamedTextColor.GRAY));
|
||||
waiting.put(id, new Waiter(
|
||||
serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
|
||||
@@ -364,7 +400,9 @@ public final class WaitingRouter {
|
||||
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
|
||||
if (err != null || (result != null && !result.isSuccessful())) {
|
||||
player.sendMessage(Component.text(
|
||||
"Couldn't connect you to « " + serverName + " ». Please try again.",
|
||||
FelisVelocityPlugin.zh(player)
|
||||
? "无法把你连接到「" + serverName + "」。请重试。"
|
||||
: "Couldn't connect you to « " + serverName + " ». Please try again.",
|
||||
NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user