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

+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++;
}
}