fix(menu): 大厅菜单按玩家标出每台服能否启动及原因,自己的服排前面,状态改成中文

This commit is contained in:
Lemon-miaow committed 2026-09-27 05:02:33 +08:00
1 parent 82310d02ec
commit 4ae64cc2e8
21 files changed
+1081 -136

No files matched your search

+19 -10
View File
@@ -219,14 +219,16 @@ a frame on the same channel. Velocity (the `ControlChannel`, above) is the only
side that talks to felis-api. This is enforced **physically** by the build, not
just by convention: the module's `sourceSets` include-filter compiles in only the
paper package plus the three codec classes, so the lobby jar contains exactly
five classes —
these classes —
```
best/lolicon/felis/link/Control.class (channel framing)
best/lolicon/felis/link/ControlFrame.class (the frame model)
best/lolicon/felis/link/Json.class (codec)
best/lolicon/felis/paper/FelisPaperPlugin.class
best/lolicon/felis/paper/FelisPaperPlugin.class (+ $1)
best/lolicon/felis/paper/LobbyGuard.class
best/lolicon/felis/paper/MenuHolder.class
best/lolicon/felis/paper/MenuTiles.class (+ $Kind, $Tile)
```
— and **no** `FelisApiClient`, `LinkClient`, or token-config class. If a codec
@@ -238,7 +240,11 @@ rather than silently widen the lobby's reach.
`StatusUpdate`, `TransferReady` and `Error`. `/menu` sends a `ListRequest`, and the
proxy answers with a `ListUpdate` naming every user server it routes, built from the
registry it routes by, so a server created in the panel appears without anyone editing
the lobby. The menu then paints a grey "loading" tile per server (45 per page, arrows
the lobby. The list leads with the player's own servers and carries, per server,
felis-api's verdict for this player (`GET /api/v1/internal/player/menu-access/{uuid}`,
one call per menu open): `owner`, `wake`, `owner_only`, `allowlist`, `retiring` or
`start_failed`. When felis-api cannot answer, the names go out alone and the tiles
fall back to the wake's own judgement. The menu then paints a grey "loading" tile per server (45 per page, arrows
in the bottom row) and fires a `StatusQuery` for each; the proxy answers with
`StatusUpdate` frames that repaint each tile by phase + ownership.
@@ -249,17 +255,20 @@ gates authorize against that verified identity. The frame's `server` field is th
trusted payload — it only names *which* tile was clicked. A fully compromised
lobby therefore cannot act as another player or reach the API directly.
**Button rules** (the tile a click sends depends on the last `StatusUpdate`):
**Button rules** (`MenuTiles`: the last `StatusUpdate` plus the player's verdict, first match wins):
| Tile state | Label | Frame sent |
| ---------- | ----- | ---------- |
| ownerless + stopped (`claimable`) | **Claim & Start** | `ClaimRequest{server}` |
| owned + running (`ready`) | **Join** | `WakeRequest{server}` |
| owned + stopped | **Wake** | `WakeRequest{server}` |
| up (`ready`), any verdict | **Join** (green) | `WakeRequest{server}` |
| ownerless (`claimable`) | **Claim & Start** (gold) | `ClaimRequest{server}` |
| `retiring` / `start_failed` / `owner_only` / `allowlist` | **Can't start** (grey, reason in the lore) | nothing; the reason goes to chat and the menu stays open |
| anything else (`owner`, `wake`, no verdict) | **Start** (red) | `WakeRequest{server}` |
"Join" and "Wake" are the **same** upstream frame (`WakeRequest`) — only the
label differs; the proxy treats a wake of an already-running owned server as a
join. A refusal comes back as an `Error` frame (`not_linked` / `quota_exceeded` /
The player's own servers are marked ★ and "Your server". The status line shows the
phase in the player's language (运行中 / Running, 启动中 / Starting, 停止中 / Stopping,
已停止 / Stopped, 启动失败 / Failed to start, 未知 / Unknown). "Join" and "Start" are the
**same** upstream frame (`WakeRequest`): the proxy joins a server that is already up,
and every linked player may join one. A refusal comes back as an `Error` frame (`not_linked` / `quota_exceeded` /
`already_claimed` → a friendly message), which is the only place a claim/quota/
policy failure surfaces to the player; readiness arrives as `TransferReady` just
before the proxy Connects them.
+13 -3
View File
@@ -62,23 +62,33 @@ tasks.withType(JavaCompile).configureEach {
}
// LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events around
// Proxy-built fakes (plain main, no framework, like the velocity routing tests). It is
// not part of `build`, which the lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`.
// Proxy-built fakes, and MenuTilesTest checks the menu's tile judgement (plain mains, no
// framework, like the velocity routing tests). Neither is part of `build`, which the
// lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`, which runs both.
sourceSets {
lobbyTest {
java {
srcDir 'test'
include 'best/lolicon/felis/paper/Fakes.java'
include 'best/lolicon/felis/paper/LobbyGuardTest.java'
include 'best/lolicon/felis/paper/MenuTilesTest.java'
}
compileClasspath += sourceSets.main.output + configurations.compileClasspath
runtimeClasspath += output + compileClasspath
}
}
tasks.register('menuTilesTest', JavaExec) {
group = 'verification'
description = 'Runs the MenuTilesTest self-test main.'
classpath = sourceSets.lobbyTest.runtimeClasspath
mainClass = 'best.lolicon.felis.paper.MenuTilesTest'
}
tasks.register('lobbyTest', JavaExec) {
group = 'verification'
description = 'Runs the LobbyGuardTest self-test main.'
description = 'Runs the LobbyGuardTest and MenuTilesTest self-test mains.'
dependsOn 'menuTilesTest'
classpath = sourceSets.lobbyTest.runtimeClasspath
mainClass = 'best.lolicon.felis.paper.LobbyGuardTest'
}
@@ -48,14 +48,16 @@ import java.util.concurrent.ConcurrentHashMap;
* <p><b>Flow.</b> {@code /menu} sends a {@code ListRequest}; the proxy answers with a
* {@code ListUpdate} naming every user server it routes, built from the same registry
* it routes by, so a server created in the panel shows up here without anyone editing
* this plugin. The menu then paints a "loading" tile per server on the page (45 per
* page, arrows in the bottom row) and fires a {@code StatusQuery} for each; the proxy
* answers with {@code StatusUpdate} frames that repaint each tile by phase +
* ownership. Clicking a tile sends a
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &amp;
* 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 claim refusal comes back as an {@code Error} frame and is shown to the
* this plugin. The list puts the player's own servers first and carries felis-api's
* verdict on each: whether this player may start it, and why not. The menu then
* paints a "loading" tile per server on the page (45 per page, arrows in the bottom
* row) and fires a {@code StatusQuery} for each; the proxy answers with
* {@code StatusUpdate} frames that repaint each tile ({@link MenuTiles}): Join when it
* is up, Claim &amp; Start when it is ownerless, a grey tile naming the reason when the
* player may not start it, Start otherwise, with the phase in the player's language.
* Claim sends a {@code ClaimRequest}; Join and Start both send a {@code WakeRequest};
* each closes the menu. A grey tile sends nothing: its reason goes to chat and the
* menu stays open. 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.
@@ -122,7 +124,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
}, LIST_TIMEOUT_TICKS);
}
private void openPage(Player player, List<String> all, int page) {
private void openPage(Player player, List<String> all, Map<String, String> access, int page) {
boolean zh = zh(player);
if (all.isEmpty()) {
player.sendMessage(Component.text(
@@ -133,7 +135,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
}
int pages = MenuHolder.pageCount(all.size());
int p = Math.max(0, Math.min(page, pages - 1));
MenuHolder holder = new MenuHolder(all, p);
MenuHolder holder = new MenuHolder(all, access, p);
List<String> view = holder.servers();
int size = pages > 1 ? 54 : invSize(view.size());
Inventory inv = Bukkit.createInventory(holder, size, menuTitle(zh, p, pages));
@@ -180,7 +182,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
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(), target);
openPage(player, holder.all(), holder.access(), target);
}
return;
}
@@ -192,13 +194,17 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
if (state == null) {
return; // still loading — no status yet, so we don't know which frame to send
}
// Claimable (ownerless + stopped) → Claim & Start; everything else → Wake
// (which the proxy treats as Join when the owned server is already running).
if (state.claimable()) {
sendUpstream(player, ControlFrame.claimRequest(player.getName(), server));
} else {
sendUpstream(player, ControlFrame.wakeRequest(player.getName(), server));
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();
}
@@ -231,7 +237,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
// 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(), 0);
openPage(player, frame.servers(), MenuTiles.accessByName(frame), 0);
}
break;
case ControlFrame.STATUS_UPDATE:
@@ -266,7 +272,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, zh(player)));
top.setItem(slot, tile(frame, holder.verdict(frame.server()), zh(player)));
}
// markUnavailable repaints a still-loading tile whose status query was refused, so
@@ -318,30 +324,42 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return item;
}
private ItemStack tile(ControlFrame f, boolean zh) {
private ItemStack tile(ControlFrame f, String verdict, boolean zh) {
MenuTiles.Tile t = MenuTiles.tile(f, verdict, zh);
Material material;
String action;
NamedTextColor color;
if (f.claimable()) {
material = Material.GOLD_BLOCK;
action = zh ? "认领并启动" : "Claim & Start";
color = NamedTextColor.GOLD;
} else if (f.ready()) {
material = Material.LIME_CONCRETE;
action = zh ? "加入" : "Join";
color = NamedTextColor.GREEN;
} else {
material = Material.RED_CONCRETE;
action = zh ? "唤醒" : "Wake";
color = NamedTextColor.RED;
switch (t.kind()) {
case CLAIM -> {
material = Material.GOLD_BLOCK;
color = NamedTextColor.GOLD;
}
case JOIN -> {
material = Material.LIME_CONCRETE;
color = NamedTextColor.GREEN;
}
case LOCKED -> {
material = Material.GRAY_CONCRETE;
color = NamedTextColor.GRAY;
}
default -> {
material = Material.RED_CONCRETE;
color = NamedTextColor.RED;
}
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(action + " · " + f.server(), color)
meta.displayName(Component.text((t.mine() ? "★ " : "") + t.action() + " · " + f.server(), color)
.decoration(TextDecoration.ITALIC, false));
List<Component> lore = new ArrayList<>();
lore.add(line(zh ? "状态" : "Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
if (t.mine()) {
lore.add(Component.text(zh ? "你的服务器" : "Your server", NamedTextColor.GOLD)
.decoration(TextDecoration.ITALIC, false));
}
lore.add(line(zh ? "状态" : "Status", MenuTiles.phase(f.phase(), zh)));
lore.add(line(zh ? "在线" : "Players", f.playersOnline() + "/" + f.playersMax()));
for (String r : t.reason()) {
lore.add(Component.text(r, NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false));
}
meta.lore(lore);
item.setItemMeta(meta);
return item;
@@ -18,10 +18,11 @@ import java.util.Map;
* on the plugin) means it is garbage-collected with the menu and never leaks across
* reopen.
*
* <p>It holds the full server list the proxy sent (so paging needs no new request),
* which page is showing, and the latest {@link ControlFrame} seen for each server on
* it, so a click knows whether to send a Claim or a Wake without re-querying. Within
* a page the list index <em>is</em> the slot.
* <p>It holds the full server list the proxy sent and felis-api's verdict for this
* player on each (so paging needs no new request), which page is showing, and the
* latest {@link ControlFrame} seen for each server on it, so a click knows what to
* send ({@link MenuTiles}) without re-querying. Within a page the list index
* <em>is</em> the slot.
*/
final class MenuHolder implements InventoryHolder {
@@ -29,13 +30,15 @@ final class MenuHolder implements InventoryHolder {
static final int PAGE_SIZE = 45;
private final List<String> all;
private final Map<String, String> access; // server → verdict, when known
private final int page;
private final List<String> servers; // this page; index = slot
private final Map<String, ControlFrame> latest = new HashMap<>();
private Inventory inventory;
MenuHolder(List<String> all, int page) {
MenuHolder(List<String> all, Map<String, String> access, int page) {
this.all = all;
this.access = access;
this.page = page;
int from = Math.min(page * PAGE_SIZE, all.size());
this.servers = all.subList(from, Math.min(from + PAGE_SIZE, all.size()));
@@ -50,6 +53,16 @@ final class MenuHolder implements InventoryHolder {
return all;
}
/** access is felis-api's verdict per server, as the proxy sent it. */
Map<String, String> access() {
return access;
}
/** verdict is felis-api's verdict for one server, or null when none came. */
String verdict(String server) {
return access.get(server);
}
int page() {
return page;
}
@@ -0,0 +1,140 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.ControlFrame;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* MenuTiles decides what a {@code /menu} tile says and what clicking it does, from the
* server's live status ({@code StatusUpdate}) and felis-api's verdict for this player
* (the {@code ListUpdate}'s access list). It is the whole of the menu's judgement, kept
* free of Bukkit so it can be tested as plain values; {@link FelisPaperPlugin} only
* turns a {@link Tile} into an item.
*
* <p>The order follows the proxy's wake. A server that is up is open to every linked
* player, so it is Join whatever the verdict. An ownerless one is Claim &amp; Start:
* claiming makes the player its owner before the wake. Past those, a verdict that
* refuses the wake locks the tile and names the reason, so nobody queues for a start
* that cannot happen. Everything else is Wake, including a tile with no verdict (the
* proxy could not ask felis-api), which the wake then judges as before.
*/
final class MenuTiles {
/** Kind is what a click on the tile does. */
enum Kind { CLAIM, JOIN, WAKE, LOCKED }
/**
* Tile is one tile's content: its kind, the action word in its title, whether it is
* the player's own server, and for a locked tile the reason, one lore line each.
*/
record Tile(Kind kind, String action, boolean mine, List<String> reason) {
}
// The verdicts felis-api's menu-access answers (handleInternalMenuAccess).
static final String OWNER = "owner";
static final String RETIRING = "retiring";
static final String START_FAILED = "start_failed";
static final String OWNER_ONLY = "owner_only";
static final String ALLOWLIST = "allowlist";
private MenuTiles() {
}
static Tile tile(ControlFrame status, String verdict, boolean zh) {
boolean mine = OWNER.equals(verdict);
if (status.ready()) {
return new Tile(Kind.JOIN, zh ? "加入" : "Join", mine, List.of());
}
if (status.claimable()) {
return new Tile(Kind.CLAIM, zh ? "认领并启动" : "Claim & Start", false, List.of());
}
List<String> reason = lockReason(verdict, zh);
if (!reason.isEmpty()) {
return new Tile(Kind.LOCKED, zh ? "无法启动" : "Can't start", mine, reason);
}
return new Tile(Kind.WAKE, zh ? "启动" : "Start", mine, List.of());
}
/**
* click is the frame a click on the tile sends, or null for a locked tile, which
* sends nothing and tells the player why ({@link #chatReason}).
*/
static ControlFrame click(Tile tile, String player, String server) {
switch (tile.kind()) {
case CLAIM:
return ControlFrame.claimRequest(player, server);
case LOCKED:
return null;
default:
// Join and Start are both a wake: the proxy joins a server that is up.
return ControlFrame.wakeRequest(player, server);
}
}
/** chatReason is a locked tile's reason as one sentence. */
static String chatReason(Tile tile, boolean zh) {
return zh ? String.join(",", tile.reason()) + "。" : String.join(". ", tile.reason()) + ".";
}
// lockReason is why a verdict refuses the wake, or empty when it does not.
private static List<String> lockReason(String verdict, boolean zh) {
if (verdict == null) {
return List.of();
}
switch (verdict) {
case RETIRING:
return zh ? List.of("这台服务器已被放弃或正在删除", "不能再启动")
: List.of("This server was given up or is being deleted", "It can't be started");
case START_FAILED:
return zh ? List.of("多次启动失败,已停止自动重试", "需要在网页控制台处理")
: List.of("It failed to start several times", "It needs a fix on the web console");
case OWNER_ONLY:
return zh ? List.of("只有主人能启动这台服务器", "它运行时任何人都能加入")
: List.of("Only its owner can start it", "Anyone can join while it's running");
case ALLOWLIST:
return zh ? List.of("你不在这台服务器的启动名单上", "它运行时任何人都能加入")
: List.of("You're not on its start list", "Anyone can join while it's running");
default:
return List.of();
}
}
/** phase is the tile's status line: the lifecycle phase in the player's language. */
static String phase(String phase, boolean zh) {
if (phase == null) {
return zh ? "未知" : "Unknown";
}
switch (phase) {
case "Running":
return zh ? "运行中" : "Running";
case "Starting":
return zh ? "启动中" : "Starting";
case "Stopping":
return zh ? "停止中" : "Stopping";
case "Stopped":
return zh ? "已停止" : "Stopped";
case "Failed":
return zh ? "启动失败" : "Failed to start";
default:
return zh ? "未知" : "Unknown";
}
}
/**
* accessByName reads a {@code ListUpdate}'s verdicts into server → verdict, leaving
* out the servers it gave none for (and every server when the proxy sent names only).
*/
static Map<String, String> accessByName(ControlFrame list) {
Map<String, String> out = new HashMap<>();
List<String> names = list.servers();
List<String> access = list.access();
for (int i = 0; i < names.size() && i < access.size(); i++) {
if (!access.get(i).isEmpty()) {
out.put(names.get(i), access.get(i));
}
}
return out;
}
}
@@ -0,0 +1,161 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* MenuTilesTest checks the menu's judgement: which tile a server gets from its live
* status and felis-api's verdict for the player, what a click on it sends, the reason
* a grey tile gives, the phase in each language, and that the verdicts survive the
* wire and the paging. Framework free: a failed assertion throws.
*
* <p>Run: {@code ./gradlew menuTilesTest} (or {@code lobbyTest}, which runs both mains)
* in plugins/paper.
*/
public final class MenuTilesTest {
private static int checks;
// Every verdict felis-api answers, then none, an empty one, and one from a newer felis-api.
private static final List<String> VERDICTS = Arrays.asList(
"owner", "wake", "owner_only", "allowlist", "retiring", "start_failed", null, "", "future_verdict");
public static void main(String[] args) {
upServers();
ownerless();
stopped();
clicks();
phases();
wire();
System.out.println("MenuTilesTest OK (" + checks + " checks)");
}
// An up server is open to every linked player: Join whatever the verdict, ownerless or not.
private static void upServers() {
for (String v : VERDICTS) {
for (boolean claimable : new boolean[] {false, true}) {
MenuTiles.Tile t = MenuTiles.tile(status(true, claimable), v, true);
assertEq("up, " + v + ", claimable=" + claimable, "JOIN 加入 " + "owner".equals(v) + " []", brief(t));
}
}
assertEq("up, English", "JOIN Join true []", brief(MenuTiles.tile(status(true, false), "owner", false)));
}
// An ownerless server that is down is Claim & Start: the claim makes the player its owner first.
private static void ownerless() {
for (String v : VERDICTS) {
assertEq("ownerless, " + v, "CLAIM 认领并启动 false []", brief(MenuTiles.tile(status(false, true), v, true)));
}
assertEq("ownerless, English", "CLAIM Claim & Start false []",
brief(MenuTiles.tile(status(false, true), "owner_only", false)));
}
// Down and owned: the verdict decides between Start and a grey tile with its reason.
private static void stopped() {
Map<String, String> zh = new HashMap<>();
zh.put("owner", "WAKE 启动 true []");
zh.put("wake", "WAKE 启动 false []");
zh.put(null, "WAKE 启动 false []");
zh.put("", "WAKE 启动 false []");
zh.put("future_verdict", "WAKE 启动 false []");
zh.put("owner_only", "LOCKED 无法启动 false [只有主人能启动这台服务器, 它运行时任何人都能加入]");
zh.put("allowlist", "LOCKED 无法启动 false [你不在这台服务器的启动名单上, 它运行时任何人都能加入]");
zh.put("retiring", "LOCKED 无法启动 false [这台服务器已被放弃或正在删除, 不能再启动]");
zh.put("start_failed", "LOCKED 无法启动 false [多次启动失败,已停止自动重试, 需要在网页控制台处理]");
for (String v : VERDICTS) {
assertEq("stopped, " + v, zh.get(v), brief(MenuTiles.tile(status(false, false), v, true)));
}
Map<String, String> en = new HashMap<>();
en.put("owner", "WAKE Start true []");
en.put("owner_only", "LOCKED Can't start false [Only its owner can start it, Anyone can join while it's running]");
en.put("allowlist", "LOCKED Can't start false [You're not on its start list, Anyone can join while it's running]");
en.put("retiring", "LOCKED Can't start false [This server was given up or is being deleted, It can't be started]");
en.put("start_failed", "LOCKED Can't start false [It failed to start several times, It needs a fix on the web console]");
en.forEach((v, want) ->
assertEq("stopped, English, " + v, want, brief(MenuTiles.tile(status(false, false), v, false))));
}
// Claim sends a ClaimRequest, Join and Start a WakeRequest, a grey tile nothing.
private static void clicks() {
ControlFrame down = status(false, false);
assertEq("claim click", ControlFrame.claimRequest("Steve", "alpha"),
MenuTiles.click(MenuTiles.tile(status(false, true), "wake", true), "Steve", "alpha"));
assertEq("join click", ControlFrame.wakeRequest("Steve", "alpha"),
MenuTiles.click(MenuTiles.tile(status(true, false), "owner_only", true), "Steve", "alpha"));
assertEq("start click", ControlFrame.wakeRequest("Steve", "alpha"),
MenuTiles.click(MenuTiles.tile(down, "owner", true), "Steve", "alpha"));
assertEq("start click, no verdict", ControlFrame.wakeRequest("Steve", "alpha"),
MenuTiles.click(MenuTiles.tile(down, null, true), "Steve", "alpha"));
for (String v : List.of("owner_only", "allowlist", "retiring", "start_failed")) {
assertEq("locked click sends nothing: " + v, null,
MenuTiles.click(MenuTiles.tile(down, v, true), "Steve", "alpha"));
}
assertEq("chat reason, zh", "只有主人能启动这台服务器,它运行时任何人都能加入。",
MenuTiles.chatReason(MenuTiles.tile(down, "owner_only", true), true));
assertEq("chat reason, en", "Only its owner can start it. Anyone can join while it's running.",
MenuTiles.chatReason(MenuTiles.tile(down, "owner_only", false), false));
}
private static void phases() {
String[][] table = {
{"Running", "运行中", "Running"},
{"Starting", "启动中", "Starting"},
{"Stopping", "停止中", "Stopping"},
{"Stopped", "已停止", "Stopped"},
{"Failed", "启动失败", "Failed to start"},
{"Unknown", "未知", "Unknown"},
{null, "未知", "Unknown"},
{"", "未知", "Unknown"},
{"Hibernating", "未知", "Unknown"},
};
for (String[] row : table) {
assertEq("phase " + row[0] + ", zh", row[1], MenuTiles.phase(row[0], true));
assertEq("phase " + row[0] + ", en", row[2], MenuTiles.phase(row[0], false));
}
}
// The verdicts ride the ListUpdate by position and must land on the right server,
// on every page.
private static void wire() {
List<String> names = new ArrayList<>();
List<String> access = new ArrayList<>();
for (int i = 0; i < 50; i++) {
names.add(String.format("s%02d", i));
access.add(i == 0 ? "owner" : i == 47 ? "owner_only" : "");
}
ControlFrame list = Control.decode(Control.encode(ControlFrame.listUpdate(names, access)));
Map<String, String> byName = MenuTiles.accessByName(list);
assertEq("verdicts by name, unknowns left out", Map.of("s00", "owner", "s47", "owner_only"), byName);
assertEq("names only: no verdicts",
Map.of(), MenuTiles.accessByName(ControlFrame.listUpdate(List.of("a", "b"))));
MenuHolder second = new MenuHolder(list.servers(), byName, 1);
assertEq("page 2 starts at s45", "s45", second.servers().get(0));
assertEq("page 2 knows s47's verdict", "owner_only", second.verdict("s47"));
assertEq("... and s46 has none", null, second.verdict("s46"));
assertEq("paging keeps every verdict", byName, second.access());
}
// status is a StatusUpdate for server "alpha" in the given state.
private static ControlFrame status(boolean ready, boolean claimable) {
return ControlFrame.statusUpdate("alpha", ready ? "Running" : "Stopped", ready, 0, 20, claimable);
}
private static String brief(MenuTiles.Tile t) {
return t.kind() + " " + t.action() + " " + t.mine() + " " + t.reason();
}
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++;
}
}
@@ -70,15 +70,12 @@ public final class Control {
case ControlFrame.LIST_REQUEST:
break;
case ControlFrame.LIST_UPDATE:
sb.append(",\"servers\":[");
List<String> names = frame.servers();
for (int i = 0; i < names.size(); i++) {
if (i > 0) {
sb.append(',');
}
jsonString(sb, names.get(i));
kvList(sb, "servers", frame.servers());
// Only a list that carries verdicts writes them, so a names-only
// frame stays the shape it always was.
if (!frame.access().isEmpty()) {
kvList(sb, "access", frame.access());
}
sb.append(']');
break;
case ControlFrame.LOGIN_RELEASE:
kv(sb, "player", frame.player());
@@ -128,7 +125,7 @@ public final class Control {
case ControlFrame.LIST_REQUEST:
return ControlFrame.listRequest();
case ControlFrame.LIST_UPDATE:
return ControlFrame.listUpdate(strList(o, "servers"));
return listUpdate(o);
case ControlFrame.LOGIN_RELEASE:
return ControlFrame.loginRelease(str(o, "player"));
default:
@@ -147,6 +144,17 @@ public final class Control {
}
}
private static void kvList(StringBuilder sb, String key, List<String> values) {
sb.append(",\"").append(key).append("\":[");
for (int i = 0; i < values.size(); i++) {
if (i > 0) {
sb.append(',');
}
jsonString(sb, values.get(i));
}
sb.append(']');
}
private static void kvBool(StringBuilder sb, String key, boolean value) {
sb.append(",\"").append(key).append("\":").append(value);
}
@@ -199,19 +207,22 @@ public final class Control {
return v instanceof String ? (String) v : null;
}
// strList keeps the string entries of an array field and skips anything else, so a
// partly malformed list still yields the names that are well-formed.
private static List<String> strList(Map<?, ?> o, String key) {
List<String> out = new ArrayList<>();
Object v = o.get(key);
if (v instanceof List) {
for (Object e : (List<?>) v) {
if (e instanceof String) {
out.add((String) e);
}
// listUpdate keeps the string entries of "servers" and skips anything else, so a
// partly malformed list still yields the names that are well-formed. "access" is
// read by the same index, so a skipped name takes its verdict with it and a
// verdict that is not a string reads as unknown.
private static ControlFrame listUpdate(Map<?, ?> o) {
List<String> names = new ArrayList<>();
List<String> access = new ArrayList<>();
List<?> rawNames = o.get("servers") instanceof List<?> l ? l : List.of();
List<?> rawAccess = o.get("access") instanceof List<?> l ? l : List.of();
for (int i = 0; i < rawNames.size(); i++) {
if (rawNames.get(i) instanceof String name) {
names.add(name);
access.add(i < rawAccess.size() && rawAccess.get(i) instanceof String v ? v : "");
}
}
return out;
return ControlFrame.listUpdate(names, access);
}
private static boolean bool(Map<?, ?> o, String key) {
@@ -25,7 +25,7 @@ import java.util.Objects;
* {@code Connect} the gate used to send, so {@code bungeecord:main} can be
* switched off proxy-wide.</li>
* <li><b>Downstream</b> (velocity → lobby): {@link #STATUS_UPDATE} is the tile
* projection; {@link #LIST_UPDATE} is the set of tiles to show;
* projection; {@link #LIST_UPDATE} is the set of tiles to show, with what the player may do with each;
* {@link #TRANSFER_READY} tells the lobby a parked player's backend is up;
* {@link #ERROR} reports a refusal.</li>
* </ul>
@@ -65,7 +65,7 @@ public final class ControlFrame {
public static final String ERROR = "Error";
/** Upstream (lobby): ask for the current tile list; answered by {@link #LIST_UPDATE}. */
public static final String LIST_REQUEST = "ListRequest";
/** Downstream: the user servers the lobby should show, in display order (servers). */
/** Downstream: the user servers the lobby should show, in display order (servers, access). */
public static final String LIST_UPDATE = "ListUpdate";
/** Upstream (login gate): the player finished signing in; move them to the lobby (player). */
public static final String LOGIN_RELEASE = "LoginRelease";
@@ -81,15 +81,16 @@ public final class ControlFrame {
private final String code;
private final String message;
private final List<String> servers;
private final List<String> access;
private ControlFrame(String type, String player, String server, String phase, boolean ready,
int playersOnline, int playersMax, boolean claimable, String code, String message) {
this(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message, List.of());
this(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message, List.of(), List.of());
}
private ControlFrame(String type, String player, String server, String phase, boolean ready,
int playersOnline, int playersMax, boolean claimable, String code, String message,
List<String> servers) {
List<String> servers, List<String> access) {
this.type = type;
this.player = player;
this.server = server;
@@ -101,6 +102,7 @@ public final class ControlFrame {
this.code = code;
this.message = message;
this.servers = servers;
this.access = access;
}
// ---- factories (tolerant: no field validation, so decode can always rebuild) ----
@@ -137,16 +139,34 @@ public final class ControlFrame {
/** listUpdate carries the tile names; null entries are dropped, the list is copied. */
public static ControlFrame listUpdate(List<String> servers) {
List<String> copy = new ArrayList<>();
return listUpdate(servers, null);
}
/**
* listUpdate with {@code access} also carries what the player may do with each
* server: {@code access.get(i)} is felis-api's verdict for {@code servers.get(i)}
* ({@code ""} when unknown). A null name drops its verdict with it; missing
* verdicts read as unknown, and extra ones are cut. With no known verdict at all
* the list stays empty and the frame is the plain name list.
*/
public static ControlFrame listUpdate(List<String> servers, List<String> access) {
List<String> names = new ArrayList<>();
List<String> verdicts = new ArrayList<>();
boolean anyKnown = false;
if (servers != null) {
for (String s : servers) {
if (s != null) {
copy.add(s);
for (int i = 0; i < servers.size(); i++) {
if (servers.get(i) == null) {
continue;
}
String v = access != null && i < access.size() && access.get(i) != null ? access.get(i) : "";
names.add(servers.get(i));
verdicts.add(v);
anyKnown |= !v.isEmpty();
}
}
return new ControlFrame(LIST_UPDATE, null, null, null, false, 0, 0, false, null, null,
Collections.unmodifiableList(copy));
Collections.unmodifiableList(names),
anyKnown ? Collections.unmodifiableList(verdicts) : List.of());
}
public static ControlFrame loginRelease(String player) {
@@ -200,6 +220,15 @@ public final class ControlFrame {
return servers;
}
/**
* access is the {@link #LIST_UPDATE}'s verdict per server, aligned with
* {@link #servers()} ({@code ""} for one felis-api gave none); empty when the
* proxy sent names only, and on every other type.
*/
public List<String> access() {
return access;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -219,13 +248,14 @@ public final class ControlFrame {
&& Objects.equals(phase, f.phase)
&& Objects.equals(code, f.code)
&& Objects.equals(message, f.message)
&& Objects.equals(servers, f.servers);
&& Objects.equals(servers, f.servers)
&& Objects.equals(access, f.access);
}
@Override
public int hashCode() {
return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message,
servers);
servers, access);
}
@Override
@@ -8,6 +8,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -145,6 +146,28 @@ public final class FelisApiClient {
return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + serverSegment(name) + "/menu", 200));
}
/**
* menuAccess reads what one player may start, for every user server at once
* ({@code GET /api/v1/internal/player/menu-access/{mc_uuid}} →
* {@code {"servers":{"<name>":"<verdict>"}}}): {@code owner}, {@code wake},
* {@code owner_only}, {@code allowlist}, {@code retiring} or {@code start_failed}.
* Velocity calls it once per menu open. An entry whose value is not a string is
* left out, as is the whole map when {@code servers} is missing.
*/
public Map<String, String> menuAccess(UUID mcUuid) throws LinkException {
Objects.requireNonNull(mcUuid, "mcUuid");
Map<?, ?> obj = getObject("/api/v1/internal/player/menu-access/" + mcUuid, 200);
Map<String, String> out = new HashMap<>();
if (obj.get("servers") instanceof Map<?, ?> servers) {
for (Map.Entry<?, ?> e : servers.entrySet()) {
if (e.getKey() instanceof String name && e.getValue() instanceof String verdict) {
out.put(name, verdict);
}
}
}
return out;
}
/**
* linkStatus polls whether the verified UUID has finished web account-link — the
* completion leg of the in-game login flow (spec §B3). After the player redeems
@@ -13,9 +13,10 @@ import java.util.Map;
*
* <p>Velocity reads this for a {@code StatusQuery} and projects it onto a
* {@link ControlFrame#STATUS_UPDATE} frame the felis-paper lobby renders as a tile:
* the {@code phase}/{@code ready}/{@code claimable} triple chooses the button
* (Claim&nbsp;&amp;&nbsp;Start / Join / Wake) and {@code playersOnline}/{@code
* playersMax} render the "3/20" count.
* {@code ready} and {@code claimable}, with felis-api's per-player verdict from the
* {@code ListUpdate}, choose the button (Join / Claim&nbsp;&amp;&nbsp;Start / Start, or
* a grey tile naming why not), {@code phase} is the status line, and
* {@code playersOnline}/{@code playersMax} render the "3/20" count.
*
* <p>{@link #fromJson(Map)} is tolerant in the same way as {@link ServerView}: an
* absent field degrades to null/zero/false rather than throwing, so a partial body
@@ -34,6 +34,7 @@ public final class ControlRoundTripTest {
errorOmitsServerWhenAbsentButRoundTrips();
escapesAwkwardStrings();
listUpdateCarriesNamesInOrder();
listUpdateCarriesAccessByName();
rejectsMalformedAndUnknownFrames();
System.out.println("ControlRoundTripTest OK (" + checks + " checks)");
}
@@ -52,6 +53,7 @@ public final class ControlRoundTripTest {
roundTrip(ControlFrame.listRequest());
roundTrip(ControlFrame.listUpdate(Arrays.asList("alpha", "beta-2", "gamma")));
roundTrip(ControlFrame.listUpdate(List.of()));
roundTrip(ControlFrame.listUpdate(List.of("mine", "theirs"), List.of("owner", "owner_only")));
roundTrip(ControlFrame.loginRelease("Notch"));
}
@@ -81,6 +83,34 @@ public final class ControlRoundTripTest {
assertEq("non-string entries skipped", List.of("a", "b"), mixed.servers());
}
// ListUpdate's access list is the player's verdict per name, by index: it holds
// its alignment through a dropped name on either end, a missing or non-string
// verdict reads as unknown (""), and a list with no verdict at all is the plain
// name list on the wire.
private static void listUpdateCarriesAccessByName() {
ControlFrame f = decode(ControlFrame.listUpdate(
Arrays.asList("mine", null, "theirs", "fresh"),
Arrays.asList("owner", "wake", "owner_only")));
assertEq("names", List.of("mine", "theirs", "fresh"), f.servers());
assertEq("verdicts follow their names", List.of("owner", "owner_only", ""), f.access());
assertEq("names only: no verdicts", List.of(), decode(ControlFrame.listUpdate(List.of("a"))).access());
assertEq("all unknown: no verdicts", List.of(),
decode(ControlFrame.listUpdate(List.of("a", "b"), Arrays.asList("", null))).access());
assertEq("names only: nothing extra on the wire",
"{\"type\":\"ListUpdate\",\"servers\":[\"a\"]}",
new String(Control.encode(ControlFrame.listUpdate(List.of("a"), List.of(""))), StandardCharsets.UTF_8));
assertEq("extra verdicts are cut", List.of("wake"),
ControlFrame.listUpdate(List.of("a"), List.of("wake", "owner")).access());
ControlFrame mixed = Control.decode(("{\"type\":\"ListUpdate\",\"servers\":[\"a\",1,\"b\",\"c\"],"
+ "\"access\":[\"owner\",\"wake\",7,\"allowlist\"]}").getBytes(StandardCharsets.UTF_8));
assertEq("hand-written: names", List.of("a", "b", "c"), mixed.servers());
assertEq("hand-written: a skipped name takes its verdict", List.of("owner", "", "allowlist"), mixed.access());
ControlFrame shortList = Control.decode(("{\"type\":\"ListUpdate\",\"servers\":[\"a\",\"b\"],"
+ "\"access\":[\"retiring\"]}").getBytes(StandardCharsets.UTF_8));
assertEq("hand-written: a missing verdict is unknown", List.of("retiring", ""), shortList.access());
assertEq("access on a non-list frame", List.of(), decode(ControlFrame.statusQuery("s")).access());
}
// StatusUpdate refines the spec's "players" into ready + online + max; the GUI
// renders all three, so all three must survive the round-trip with exact values.
private static void wireCarriesRefinedStatusFields() {
@@ -44,6 +44,9 @@ public final class FelisApiClientTest {
if (path.endsWith("/wake")) {
status = 202;
body = "{\"name\":\"alpha\",\"phase\":\"Starting\",\"ready\":false}";
} else if (path.startsWith("/api/v1/internal/player/menu-access/")) {
status = 200;
body = "{\"servers\":{\"mine\":\"owner\",\"odd\":7,\"pub\":\"wake\"}}";
} else if (path.equals("/api/v1/internal/op-login/mismatch/approve")) {
status = 409;
body = "{\"error\":{\"code\":\"op_login_mismatch\",\"message\":\"that operator login is for a different account\"}}";
@@ -78,6 +81,7 @@ public final class FelisApiClientTest {
opaqueSegmentsArePercentEncoded(api);
opLoginShowNamesTheAccount(api);
opLoginApproveSendsTheTypedName(api);
menuAccessReadsVerdictsByName(api);
} finally {
stub.stop(0);
}
@@ -96,6 +100,15 @@ public final class FelisApiClientTest {
"GET /api/v1/internal/servers/abc/status"), seen);
}
// menuAccess reads the verdict map for the player's own UUID and keeps only the
// string verdicts, so one malformed entry cannot hide the others.
private static void menuAccessReadsVerdictsByName(FelisApiClient api) throws LinkException {
seen.clear();
UUID id = UUID.fromString("00000000-0000-0000-0000-00000000000a");
assertEq("verdicts", java.util.Map.of("mine", "owner", "pub", "wake"), api.menuAccess(id));
assertEq("route", List.of("GET /api/v1/internal/player/menu-access/" + id), seen);
}
private static void pathBendingNamesNeverLeaveTheClient(FelisApiClient api) {
UUID id = UUID.fromString("00000000-0000-0000-0000-000000000002");
String[] bad = {
+7 -3
View File
@@ -46,16 +46,20 @@
# message (the stub answers a wake from the server's state in felis-api's own
# order, so it cannot hand the router an answer the real API never gives),
# felis:control acts only for the connection's player and holds its
# frame budget, and Velocity's own /server steps aside for the backend's while
# frame budget, the menu list leads with the player's own servers and falls
# back to names alone when felis-api or the call pool cannot answer, and Velocity's own /server steps aside for the backend's while
# one another proxy plugin registered stays. They ride the module's verified dependency set, which is why
# they live in Gradle rather than in the javac mains above.
#
# 4. The lobby guard self-test (`./gradlew lobbyTest` in plugins/paper): LobbyGuard
# 4. The lobby self-tests (`./gradlew lobbyTest` in plugins/paper): LobbyGuard
# runs against real paper-api events around fake players and worlds, so a
# passer-by cannot change the lobby while a builder can, nobody is hurt or
# starved, a fall into the void lands at spawn, a join sets adventure mode and
# names /menu with a click, and a world rule the server refuses is logged
# without taking the menu down with it.
# without taking the menu down with it. MenuTiles decides each menu tile from
# the server's status and felis-api's verdict for the player: Join when it is up,
# 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.
#
# No test framework: the mains are the same javac one-liners their javadocs document,
# so a local run and CI run the same bytes.
@@ -18,6 +18,7 @@ import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import org.slf4j.Logger;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -47,6 +48,8 @@ import java.util.concurrent.ConcurrentHashMap;
* frames are metered per player ({@link FrameBudget}) and menu projections are
* shared across players for {@link #STATUS_TTL_MILLIS}: a lobby full of players
* opening the menu at once reads each server's status once, not once per player.
* The one per-player read is a menu open's {@code ListRequest}: a single call that
* says what the player may do with every tile.
*
* <p><b>Threading.</b> {@code felis:control} frames arrive on a Velocity event
* thread, but every felis-api call below blocks on HTTP. So each handler does the
@@ -57,7 +60,8 @@ import java.util.concurrent.ConcurrentHashMap;
* it cannot be set from the async hop.
*
* <p>The lobby's upstream frames map onto the menu (spec §12): a {@code ListRequest}
* asks which tiles to draw ({@code ListUpdate} back, built from the registry); a
* asks which tiles to draw ({@code ListUpdate} back: the registry's names, the
* player's own first, each with what felis-api says the player may do with it); a
* {@code StatusQuery} refreshes a tile ({@code StatusUpdate} back); a
* {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest}
* (ownerless server) runs the two-rule split — claim asserts ownership/quota, then
@@ -82,9 +86,12 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
// Two full menu pages (45 tiles + the list each) in a burst, then 10 frames a second.
private static final int FRAME_BURST = 96;
private static final double FRAME_REFILL_PER_SECOND = 10.0;
// Upper bound on names in one ListUpdate. A proxy→backend plugin message is capped
// at 32767 bytes; 500 names of at most 32 chars stays well under it.
// Upper bound on tiles in one ListUpdate. A proxy→backend plugin message is capped
// at 32767 bytes; 500 names of at most 32 chars plus 500 verdicts of at most 12
// (each quoted, comma-separated) come to about 25 KB.
static final int MAX_LISTED = 500;
// The menu-access verdict for a server the player owns; those tiles lead the list.
static final String MENU_OWNER = "owner";
// A refused source is logged at most once per interval, so a hostile backend
// cannot turn its own refusals into a log flood.
private static final long REFUSAL_LOG_INTERVAL_MILLIS = 60_000L;
@@ -182,7 +189,7 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
handleClaim(source, player, frame.server());
break;
case ControlFrame.LIST_REQUEST:
send(source, ControlFrame.listUpdate(listed()));
handleList(source, player);
break;
case ControlFrame.LOGIN_RELEASE:
router.releaseFromLogin(player);
@@ -198,6 +205,47 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
budget.forget(event.getPlayer().getUniqueId());
}
// A ListRequest draws the menu: the names come from the registry, and what this
// player may do with each comes from felis-api in one call. When felis-api cannot
// answer (or the pool is full) the names still go out, and the lobby draws its
// tiles without verdicts, as before they existed.
private void handleList(ServerConnection source, Player player) {
List<String> names = listed();
UUID id = player.getUniqueId();
boolean taken = plugin.async(() -> {
Map<String, String> access;
try {
access = api.menuAccess(id);
} catch (LinkException e) {
log.debug("Felis: menu access for {} unavailable; listing names only: {}", id, e.getMessage());
access = Map.of();
}
send(source, menuList(names, access));
});
if (!taken) {
send(source, menuList(names, Map.of()));
}
}
/**
* menuList orders the tiles and pairs each with its verdict: the player's own
* servers first, then everything else, each group by name. The cap applies after
* the ordering, so a player's own servers are never the ones cut.
*/
static ControlFrame menuList(List<String> names, Map<String, String> access) {
List<String> sorted = new ArrayList<>(names);
sorted.sort(Comparator.comparing((String n) -> !MENU_OWNER.equals(access.get(n)))
.thenComparing(Comparator.naturalOrder()));
if (sorted.size() > MAX_LISTED) {
sorted = sorted.subList(0, MAX_LISTED);
}
List<String> verdicts = new ArrayList<>(sorted.size());
for (String n : sorted) {
verdicts.add(access.getOrDefault(n, ""));
}
return ControlFrame.listUpdate(sorted, verdicts);
}
// listed is the lobby's tile set: every managed user server, by name. The system
// servers are the lobby itself and the gate in front of it, so neither is a tile.
private List<String> listed() {
@@ -207,8 +255,7 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
names.add(v.name());
}
}
names.sort(String::compareTo);
return names.size() > MAX_LISTED ? names.subList(0, MAX_LISTED) : names;
return names;
}
private void refused(String sourceName, ControlFrame frame, ControlPolicy.Verdict verdict) {
@@ -253,7 +300,7 @@ public final class ControlChannel implements WaitingRouter.MenuTransferListener
}
}
// A WakeRequest is the menu's Join/Wake button on a server the player owns: wake
// A WakeRequest is the menu's Join or Start button: wake
// it and park them in the shared queue. enqueueFromMenu does the HTTP off-thread
// and reports its own refusals to the player; nothing to await here.
private void handleWake(ServerConnection source, Player player, String server) {
@@ -19,6 +19,8 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
/**
* ControlChannelTest drives the real ControlChannel (with the real WaitingRouter,
@@ -74,6 +76,7 @@ public final class ControlChannelTest {
consumption();
sources();
lists();
statusQueries(reg, plugin);
claims();
wakeAndTransfer();
@@ -133,12 +136,90 @@ public final class ControlChannelTest {
assertEq("malformed name not echoed", "Error not_found null", brief(r.get(2)));
assertEq("bad server: no felis-api call", 0, api.count("POST " + SERVERS + "nope/wake"));
// The tile list: user servers only, sorted.
// The tile list: user servers only, sorted; no verdicts known for this player.
message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
Fakes.await("list answered", () -> q.pluginMessages.size() == 4);
List<ControlFrame> all = frames(q);
ControlFrame list = all.get(all.size() - 1);
assertEq("list type", ControlFrame.LIST_UPDATE, list.type());
assertEq("list: user servers, sorted", List.of("alpha", "beta", "gamma"), list.servers());
assertEq("list: no verdicts", List.of(), list.access());
}
private static void lists() throws Exception {
String accessPath = "GET /api/v1/internal/player/menu-access/";
// A menu open: the player's own servers lead, and each tile carries what felis-api
// says this player may do with it. A verdict for a server the proxy does not
// route is no tile.
Fakes.FakePlayer p = player(true);
api.access.put(p.id, Map.of("gamma", "owner", "alpha", "owner_only", "beta", "wake", "zeta", "wake"));
message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
Fakes.await("list with verdicts", () -> p.pluginMessages.size() == 1);
ControlFrame f = frames(p).get(0);
assertEq("own servers first, then by name", List.of("gamma", "alpha", "beta"), f.servers());
assertEq("verdicts aligned with the names", List.of("owner", "owner_only", "wake"), f.access());
assertEq("asked felis-api about the connection's player", 1, api.count(accessPath + p.id));
// felis-api down: the names still go out, sorted, without verdicts.
api.menuAccessError = "500 internal";
Fakes.FakePlayer d = player(true);
api.access.put(d.id, Map.of("gamma", "owner"));
message(d.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
Fakes.await("list while felis-api is down", () -> d.pluginMessages.size() == 1);
ControlFrame down = frames(d).get(0);
assertEq("felis-api down: names, sorted", List.of("alpha", "beta", "gamma"), down.servers());
assertEq("felis-api down: no verdicts", List.of(), down.access());
api.menuAccessError = null;
// A full call pool (8 in flight, 64 waiting): the rest are answered at once with
// the names alone, and the held ones still arrive with verdicts once felis-api answers.
CountDownLatch hold = new CountDownLatch(1);
api.menuAccessHold = hold;
Fakes.FakePlayer b = player(true);
api.access.put(b.id, Map.of("gamma", "owner"));
try {
for (int i = 0; i < 80; i++) {
message(b.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
}
List<ControlFrame> inline = frames(b);
assertEq("pool full: the overflow answered at once (" + inline.size() + ")",
true, inline.size() >= 8 && inline.size() <= 16);
for (ControlFrame x : inline) {
assertEq("pool full: names only", "[alpha, beta, gamma] []", x.servers() + " " + x.access());
}
} finally {
hold.countDown();
api.menuAccessHold = null;
}
Fakes.await("held lists answered", () -> b.pluginMessages.size() == 80);
List<ControlFrame> after = frames(b);
assertEq("held lists carry verdicts", "[gamma, alpha, beta] [owner, , ]",
after.get(after.size() - 1).servers() + " " + after.get(after.size() - 1).access());
// The cap cuts after the ordering: a player's own servers are never the ones dropped.
List<String> many = new ArrayList<>();
for (int i = 0; i < ControlChannel.MAX_LISTED + 100; i++) {
many.add(String.format("s%03d", i));
}
ControlFrame capped = ControlChannel.menuList(many, Map.of("s599", "owner", "s001", "wake"));
assertEq("cap: size", ControlChannel.MAX_LISTED, capped.servers().size());
assertEq("cap: own server kept, first", "s599 owner", capped.servers().get(0) + " " + capped.access().get(0));
assertEq("cap: then by name", "s000 s001 wake", capped.servers().get(1) + " "
+ capped.servers().get(2) + " " + capped.access().get(2));
assertEq("cap: the tail is what goes", "s498", capped.servers().get(ControlChannel.MAX_LISTED - 1));
// The worst case still fits one proxy→backend plugin message: longest names,
// longest verdict on every tile.
List<String> longest = new ArrayList<>();
Map<String, String> worst = new java.util.HashMap<>();
for (int i = 0; i < ControlChannel.MAX_LISTED + 100; i++) {
String n = String.format("%032d", i);
longest.add(n);
worst.put(n, "start_failed");
}
int bytes = Control.encode(ControlChannel.menuList(longest, worst)).length;
assertEq("worst case fits one plugin message (" + bytes + " bytes)", true, bytes < 32767);
}
private static void statusQueries(ServerRegistry reg, FelisVelocityPlugin plugin) throws Exception {
@@ -246,19 +327,20 @@ public final class ControlChannelTest {
private static void budget() {
// 96 frames in a burst, then 10 a second: a flood is cut off, the connection
// is not. ListRequest is answered inline, so every accepted frame shows at once.
// is not. Every accepted ListRequest is answered exactly once (from the call pool,
// or at once when the pool is full), so the answers count the accepted frames.
Fakes.FakePlayer p = player(true);
for (int i = 0; i < 150; i++) {
message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
}
int answered = p.pluginMessages.size();
int answered = settled(p, 96);
assertEq("flood cut at the burst (plus what refilled meanwhile): " + answered,
true, answered >= 96 && answered <= 99);
// Another player has a budget of their own.
Fakes.FakePlayer q = player(true);
message(q.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
assertEq("other player unaffected", 1, q.pluginMessages.size());
assertEq("other player unaffected", 1, settled(q, 1));
// Leaving the proxy forgets the budget: a fresh burst, far more than a refill.
channel.onDisconnect(new DisconnectEvent(p.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN));
@@ -266,7 +348,29 @@ public final class ControlChannelTest {
for (int i = 0; i < 50; i++) {
message(p.on(lobby), ControlChannel.CHANNEL, Control.encode(ControlFrame.listRequest()));
}
assertEq("budget forgotten on disconnect", 50, p.pluginMessages.size());
assertEq("budget forgotten on disconnect", 50, settled(p, 50));
}
// settled waits for at least min answers, then until none has arrived for 300 ms,
// and returns how many there are: the count once the pool has drained.
private static int settled(Fakes.FakePlayer p, int min) {
Fakes.await(min + " answers", () -> p.pluginMessages.size() >= min);
int seen = p.pluginMessages.size();
long quietSince = System.nanoTime();
while (System.nanoTime() - quietSince < 300_000_000L) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AssertionError("interrupted settling");
}
int now = p.pluginMessages.size();
if (now != seen) {
seen = now;
quietSince = System.nanoTime();
}
}
return seen;
}
// ---- helpers ----
@@ -31,6 +31,8 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
import java.util.function.Function;
import java.util.regex.Matcher;
@@ -482,6 +484,15 @@ final class Fakes {
/** menuError and claimError map a server to "status code", like wakeError. */
final Map<String, String> menuError = new ConcurrentHashMap<>();
final Map<String, String> claimError = new ConcurrentHashMap<>();
/**
* access is what the menu-access route answers per player: server → verdict.
* A player with no entry gets an empty map; menuAccessError ("status code")
* makes the route fail for everyone.
*/
final Map<UUID, Map<String, String>> access = new ConcurrentHashMap<>();
volatile String menuAccessError;
/** menuAccessHold, while set, holds every menu-access request until it opens. */
volatile CountDownLatch menuAccessHold;
/** bodies holds the last request body per "METHOD path". */
final Map<String, String> bodies = new ConcurrentHashMap<>();
/** calls lists every request as "METHOD path", in arrival order. */
@@ -506,6 +517,7 @@ final class Fakes {
calls.add(method + " " + path);
String status = "/api/v1/internal/account/link/status/";
String servers = "/api/v1/internal/servers/";
String menuAccess = "/api/v1/internal/player/menu-access/";
if (path.startsWith(status)) {
if (linkDown) {
reply(ex, 500, "{\"error\":{\"code\":\"internal\",\"message\":\"down\"}}");
@@ -515,6 +527,24 @@ final class Fakes {
reply(ex, 200, "{\"linked\":" + yes + "}");
return;
}
if (path.startsWith(menuAccess) && "GET".equals(method)) {
CountDownLatch hold = menuAccessHold;
if (hold != null) {
try {
hold.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
if (!error(ex, menuAccessError)) {
UUID who = UUID.fromString(path.substring(menuAccess.length()));
StringBuilder sb = new StringBuilder("{\"servers\":{");
access.getOrDefault(who, Map.of()).forEach((n, v) ->
sb.append(sb.length() > 12 ? "," : "").append('"').append(n).append("\":\"").append(v).append('"'));
reply(ex, 200, sb.append("}}").toString());
}
return;
}
if (path.startsWith(servers)) {
String[] parts = path.substring(servers.length()).split("/");
String name = parts[0];