feat(paper): 大厅菜单从代理动态拉取服务器列表并分页

This commit is contained in:
Lemon-miaow committed 2026-09-24 13:39:38 +08:00
1 parent 955433ba81
commit 8f684cedc0
3 files changed
+181 -44

No files matched your search

@@ -23,6 +23,9 @@ import org.bukkit.plugin.messaging.PluginMessageListener;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* FelisPaperPlugin is the felis-paper lobby face (spec §12): the {@code /menu} (and * FelisPaperPlugin is the felis-paper lobby face (spec §12): the {@code /menu} (and
@@ -42,9 +45,13 @@ import java.util.List;
* compiled in from the shared core; {@code FelisApiClient} and the token config are * compiled in from the shared core; {@code FelisApiClient} and the token config are
* not on the lobby's classpath at all. * not on the lobby's classpath at all.
* *
* <p><b>Flow.</b> Opening the menu paints a "loading" tile per configured server and * <p><b>Flow.</b> {@code /menu} sends a {@code ListRequest}; the proxy answers with a
* fires a {@code StatusQuery} for each; the proxy answers with {@code StatusUpdate} * {@code ListUpdate} naming every user server it routes, built from the same registry
* frames that repaint each tile by phase + ownership. Clicking a tile sends a * 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; * {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &amp;
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join" * 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 * of a running owned server and the "Wake" of a stopped owned one), then closes the
@@ -55,16 +62,22 @@ import java.util.List;
*/ */
public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener { public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener {
private static final int MAX_TILES = 54; // a double chest, the GUI ceiling // Bottom-row navigation slots on a paged menu.
private static final int PREV_SLOT = 45;
private static final int PAGE_SLOT = 49;
private static final int NEXT_SLOT = 53;
// How long /menu waits for the proxy's ListUpdate before saying the list is down.
private static final long LIST_TIMEOUT_TICKS = 60L;
/** Server names to show as tiles, in display order; loaded from config. */ /** Players who ran /menu and are waiting for the proxy's ListUpdate. */
private final List<String> servers = new ArrayList<>(); private final Map<UUID, Boolean> pendingOpen = new ConcurrentHashMap<>();
@Override @Override
public void onEnable() { public void onEnable() {
saveDefaultConfig(); saveDefaultConfig();
servers.clear(); if (!getConfig().getStringList("servers").isEmpty()) {
servers.addAll(getConfig().getStringList("servers")); getLogger().info("config.yml 'servers' is no longer read: the menu lists what the proxy routes.");
}
// Open both ends of felis:control. Outgoing carries Wake/Claim/StatusQuery to // Open both ends of felis:control. Outgoing carries Wake/Claim/StatusQuery to
// the proxy; incoming receives StatusUpdate/TransferReady/Error back. // the proxy; incoming receives StatusUpdate/TransferReady/Error back.
@@ -72,8 +85,8 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this); getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this);
getServer().getPluginManager().registerEvents(this, this); getServer().getPluginManager().registerEvents(this, this);
getLogger().info("felis-paper enabled: " + servers.size() getLogger().info("felis-paper enabled: felis:control open, server list from the proxy. "
+ " server tile(s), felis:control open. Pure UI face — no felis-api token."); + "Pure UI face — no felis-api token.");
} }
// ---- commands: /menu and /server both open the GUI ---- // ---- commands: /menu and /server both open the GUI ----
@@ -89,24 +102,50 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
} }
private void openMenu(Player player) { private void openMenu(Player player) {
UUID id = player.getUniqueId();
pendingOpen.put(id, Boolean.TRUE);
sendUpstream(player, ControlFrame.listRequest());
getServer().getScheduler().runTaskLater(this, () -> {
if (pendingOpen.remove(id) != null && player.isOnline()) {
boolean zh = zh(player);
player.sendMessage(Component.text(
zh ? "暂时拿不到服务器列表,请稍后再试。"
: "The server list isn't available right now — please try again shortly.",
NamedTextColor.YELLOW));
}
}, LIST_TIMEOUT_TICKS);
}
private void openPage(Player player, List<String> all, int page) {
boolean zh = zh(player); boolean zh = zh(player);
if (servers.isEmpty()) { if (all.isEmpty()) {
player.sendMessage(Component.text( player.sendMessage(Component.text(
zh ? "还没有配置任何服务器——请管理员先配置 felis-paper。" zh ? "还没有可加入的服务器——在网页控制台创建一个吧。"
: "No servers are configured yet — ask an operator to set up felis-paper.", : "There are no servers to join yet — create one on the web console.",
NamedTextColor.YELLOW)); NamedTextColor.YELLOW));
return; return;
} }
int shown = Math.min(servers.size(), MAX_TILES); int pages = MenuHolder.pageCount(all.size());
List<String> view = new ArrayList<>(servers.subList(0, shown)); int p = Math.max(0, Math.min(page, pages - 1));
MenuHolder holder = new MenuHolder(view); MenuHolder holder = new MenuHolder(all, p);
Inventory inv = Bukkit.createInventory(holder, invSize(shown), menuTitle(zh)); List<String> view = holder.servers();
int size = pages > 1 ? 54 : invSize(view.size());
Inventory inv = Bukkit.createInventory(holder, size, menuTitle(zh, p, pages));
holder.setInventory(inv); holder.setInventory(inv);
for (int i = 0; i < shown; i++) { for (int i = 0; i < view.size(); i++) {
inv.setItem(i, loadingTile(view.get(i), zh)); inv.setItem(i, loadingTile(view.get(i), zh));
} }
if (pages > 1) {
if (p > 0) {
inv.setItem(PREV_SLOT, navItem(Material.ARROW, zh ? "上一页" : "Previous page"));
}
inv.setItem(PAGE_SLOT, navItem(Material.PAPER, (p + 1) + " / " + pages));
if (p < pages - 1) {
inv.setItem(NEXT_SLOT, navItem(Material.ARROW, zh ? "下一页" : "Next page"));
}
}
player.openInventory(inv); player.openInventory(inv);
// Ask the proxy for live status of every tile; answers repaint them. // Ask the proxy for live status of every tile on this page; answers repaint them.
for (String server : view) { for (String server : view) {
sendUpstream(player, ControlFrame.statusQuery(server)); sendUpstream(player, ControlFrame.statusQuery(server));
} }
@@ -132,6 +171,13 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
Player player = (Player) event.getWhoClicked(); Player player = (Player) event.getWhoClicked();
MenuHolder holder = (MenuHolder) top.getHolder(); MenuHolder holder = (MenuHolder) top.getHolder();
int slot = event.getSlot(); int slot = event.getSlot();
if (holder.pages() > 1 && (slot == PREV_SLOT || slot == NEXT_SLOT)) {
int target = holder.page() + (slot == PREV_SLOT ? -1 : 1);
if (target >= 0 && target < holder.pages()) {
openPage(player, holder.all(), target);
}
return;
}
if (slot < 0 || slot >= holder.servers().size()) { if (slot < 0 || slot >= holder.servers().size()) {
return; // padding slot return; // padding slot
} }
@@ -175,10 +221,20 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return; return;
} }
switch (frame.type()) { switch (frame.type()) {
case ControlFrame.LIST_UPDATE:
// Only a /menu that is still waiting opens; a late answer after the
// timeout message is dropped rather than popping a menu up unasked.
if (pendingOpen.remove(player.getUniqueId()) != null) {
openPage(player, frame.servers(), 0);
}
break;
case ControlFrame.STATUS_UPDATE: case ControlFrame.STATUS_UPDATE:
applyStatus(player, frame); applyStatus(player, frame);
break; break;
case ControlFrame.ERROR: case ControlFrame.ERROR:
if (frame.server() != null && markUnavailable(player, frame)) {
break; // a tile's status query failed: shown on the tile itself
}
// The proxy already sanitizes transport faults; this is the only place // The proxy already sanitizes transport faults; this is the only place
// a claim/quota/policy refusal becomes visible to the player. // a claim/quota/policy refusal becomes visible to the player.
player.sendMessage(Component.text("⚠ " + errorText(frame, zh(player)), NamedTextColor.RED)); player.sendMessage(Component.text("⚠ " + errorText(frame, zh(player)), NamedTextColor.RED));
@@ -207,6 +263,31 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
top.setItem(slot, tile(frame, zh(player))); top.setItem(slot, tile(frame, zh(player)));
} }
// markUnavailable repaints a still-loading tile whose status query was refused, so
// it stops saying "loading" forever. Returns false when there is no such tile (the
// error belongs to a click, and goes to chat).
private boolean markUnavailable(Player player, ControlFrame error) {
Inventory top = player.getOpenInventory().getTopInventory();
if (!(top.getHolder() instanceof MenuHolder)) {
return false;
}
MenuHolder holder = (MenuHolder) top.getHolder();
int slot = holder.servers().indexOf(error.server());
if (slot < 0 || holder.latest(error.server()) != null) {
return false;
}
boolean zh = zh(player);
ItemStack item = new ItemStack(Material.BARRIER);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(error.server(), NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false));
meta.lore(List.of(Component.text(errorText(error, zh), NamedTextColor.GRAY)
.decoration(TextDecoration.ITALIC, false)));
item.setItemMeta(meta);
top.setItem(slot, item);
return true;
}
private void closeIfMenu(Player player) { private void closeIfMenu(Player player) {
if (player.getOpenInventory().getTopInventory().getHolder() instanceof MenuHolder) { if (player.getOpenInventory().getTopInventory().getHolder() instanceof MenuHolder) {
player.closeInventory(); player.closeInventory();
@@ -215,9 +296,20 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
// ---- rendering ---- // ---- rendering ----
private static Component menuTitle(boolean zh) { private static Component menuTitle(boolean zh, int page, int pages) {
return Component.text(zh ? "Felis 服务器" : "Felis Servers", NamedTextColor.AQUA) String title = zh ? "Felis 服务器" : "Felis Servers";
.decoration(TextDecoration.ITALIC, false); if (pages > 1) {
title += " (" + (page + 1) + "/" + pages + ")";
}
return Component.text(title, NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
}
private static ItemStack navItem(Material material, String label) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(label, NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false));
item.setItemMeta(meta);
return item;
} }
private ItemStack tile(ControlFrame f, boolean zh) { private ItemStack tile(ControlFrame f, boolean zh) {
@@ -278,13 +370,34 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
case "already_claimed": case "already_claimed":
return zh ? "该服务器已被认领。" return zh ? "该服务器已被认领。"
: "That server is already claimed."; : "That server is already claimed.";
case "at_capacity":
return zh ? "集群当前已满,请稍后再试。"
: "The cluster is full right now — please try again later.";
case "cooldown":
return zh ? "这台服务器刚被唤醒过,请稍候再试。"
: "That server was just woken — try again in a moment.";
case "forbidden":
return zh ? "你没有权限这样做。"
: "You're not allowed to do that.";
case "not_found":
case "bad_name":
case "invalid_server_name":
return zh ? "这台服务器已不存在。"
: "That server no longer exists.";
case "transport_error":
case "interrupted":
return zh ? "Felis 暂时不可用,请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default: default:
break; break;
} }
} }
return f.message() != null && !f.message().isEmpty() // An unmapped code carries felis-api's own English message; a Chinese client
? f.message() // gets a generic line rather than untranslated text.
: (code != null ? code : (zh ? "请求失败,请重试。" : "Request failed — please try again.")); if (!zh && f.message() != null && !f.message().isEmpty()) {
return f.message();
}
return zh ? "请求失败,请重试。" : "Request failed — please try again.";
} }
// ---- helpers ---- // ---- helpers ----
@@ -18,22 +18,47 @@ import java.util.Map;
* on the plugin) means it is garbage-collected with the menu and never leaks across * on the plugin) means it is garbage-collected with the menu and never leaks across
* reopen. * reopen.
* *
* <p>It holds two things: the ordered list of server names (the list index <em>is</em> * <p>It holds the full server list the proxy sent (so paging needs no new request),
* the slot, so a click slot maps straight to a server) and the latest * which page is showing, and the latest {@link ControlFrame} seen for each server on
* {@link ControlFrame} seen for each, so a click knows whether to send a Claim or a * it, so a click knows whether to send a Claim or a Wake without re-querying. Within
* Wake without re-querying. * a page the list index <em>is</em> the slot.
*/ */
final class MenuHolder implements InventoryHolder { final class MenuHolder implements InventoryHolder {
private final List<String> servers; // index = slot /** Tiles per page: five rows, leaving the bottom row for page navigation. */
static final int PAGE_SIZE = 45;
private final List<String> all;
private final int page;
private final List<String> servers; // this page; index = slot
private final Map<String, ControlFrame> latest = new HashMap<>(); private final Map<String, ControlFrame> latest = new HashMap<>();
private Inventory inventory; private Inventory inventory;
MenuHolder(List<String> servers) { MenuHolder(List<String> all, int page) {
this.servers = servers; this.all = all;
this.page = page;
int from = Math.min(page * PAGE_SIZE, all.size());
this.servers = all.subList(from, Math.min(from + PAGE_SIZE, all.size()));
} }
/** servers returns the tile order; the list index is the inventory slot. */ static int pageCount(int total) {
return Math.max(1, (total + PAGE_SIZE - 1) / PAGE_SIZE);
}
/** all is the whole list the proxy sent, across every page. */
List<String> all() {
return all;
}
int page() {
return page;
}
int pages() {
return pageCount(all.size());
}
/** servers returns this page's tile order; the list index is the inventory slot. */
List<String> servers() { List<String> servers() {
return servers; return servers;
} }
+10 -11
View File
@@ -2,15 +2,14 @@
# #
# This plugin is a PURE UI face: it speaks only the felis:control plugin-message # This plugin is a PURE UI face: it speaks only the felis:control plugin-message
# channel to the Velocity proxy. It holds no felis-api token and never contacts # channel to the Velocity proxy. It holds no felis-api token and never contacts
# felis-api directly. The list below is only which server tiles to show in the # felis-api directly.
# /menu GUI; the proxy (and felis-api behind it) stay the source of truth for
# status, ownership and autostart — every tile is filled in by a live StatusQuery
# over felis:control when the menu opens, and acting on a tile sends a Wake or
# Claim frame that the proxy authorizes against the player's verified identity.
# #
# Replace the examples below with the names of your felis servers (the CRD # There is nothing to configure. /menu asks the proxy which servers to show
# metadata.name / the server's felis name, not its display title). Up to 54 are # (a ListRequest over felis:control) and the proxy answers from the same registry
# shown. An empty list makes /menu say there is nothing to show. # it routes by, so a server created in the panel appears here on its own. Tiles are
servers: # paged 45 at a time. Status, ownership and autostart stay with the proxy and
- smp # felis-api: every tile is filled in by a live StatusQuery when the menu opens, and
- creative # acting on a tile sends a Wake or Claim frame that the proxy authorizes against the
# player's verified identity.
#
# Older versions read a static `servers:` list from this file. It is ignored now.