test(plugins): 登录闸门、大厅菜单和 mod /link 抽成可测类并补行为测试

This commit is contained in:
Lemon-miaow committed 2026-09-27 05:55:58 +08:00
1 parent 6d5aca79fb
commit 9a238780e5
19 files changed
+2802 -826

No files matched your search

@@ -11,21 +11,14 @@ import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.plugin.messaging.PluginMessageListener;
import java.util.ArrayList;
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
@@ -61,18 +54,14 @@ import java.util.concurrent.ConcurrentHashMap;
* 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.
*
* <p>What the menu does lives in {@link LobbyMenu}, which {@code ./gradlew lobbyTest}
* drives against real paper-api events; this class wires it up and draws its slots
* as items.
*/
public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener {
public final class FelisPaperPlugin extends JavaPlugin {
// Bottom-row navigation slots on a paged menu.
private static final int PREV_SLOT = 45;
private static final int PAGE_SLOT = 49;
private static final int NEXT_SLOT = 53;
// How long /menu waits for the proxy's ListUpdate before saying the list is down.
private static final long LIST_TIMEOUT_TICKS = 60L;
/** Players who ran /menu and are waiting for the proxy's ListUpdate. */
private final Map<UUID, Boolean> pendingOpen = new ConcurrentHashMap<>();
private LobbyMenu menu;
@Override
public void onEnable() {
@@ -81,11 +70,14 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
getLogger().info("config.yml 'servers' is no longer read: the menu lists what the proxy routes.");
}
LobbyMenu menu = new LobbyMenu(PaperScreen::new,
(task, ticks) -> getServer().getScheduler().runTaskLater(this, task, ticks), getLogger());
this.menu = menu;
// Open both ends of felis:control. Outgoing carries Wake/Claim/StatusQuery to
// the proxy; incoming receives StatusUpdate/TransferReady/Error back.
getServer().getMessenger().registerOutgoingPluginChannel(this, Control.CHANNEL);
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this);
getServer().getPluginManager().registerEvents(this, this);
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, menu);
getServer().getPluginManager().registerEvents(menu, this);
// The lobby is a hub nobody can hurt or be hurt in (LobbyGuard). Worlds loaded
// before this point get the rules here, later ones on their WorldLoadEvent.
@@ -105,215 +97,87 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
sender.sendMessage(Component.text("Only a player can open the server menu.", NamedTextColor.RED));
return true;
}
openMenu((Player) sender);
menu.request((Player) sender);
return true;
}
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);
/** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
static boolean zh(Player player) {
return "zh".equalsIgnoreCase(player.locale().getLanguage());
}
private void openPage(Player player, List<String> all, Map<String, String> access, int page) {
boolean zh = zh(player);
if (all.isEmpty()) {
player.sendMessage(Component.text(
zh ? "还没有可加入的服务器——在网页控制台创建一个吧。"
: "There are no servers to join yet — create one on the web console.",
NamedTextColor.YELLOW));
return;
}
int pages = MenuHolder.pageCount(all.size());
int p = Math.max(0, Math.min(page, pages - 1));
MenuHolder holder = new MenuHolder(all, access, p);
List<String> view = holder.servers();
int size = pages > 1 ? 54 : invSize(view.size());
Inventory inv = Bukkit.createInventory(holder, size, menuTitle(zh, p, pages));
holder.setInventory(inv);
for (int i = 0; i < view.size(); i++) {
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);
// Ask the proxy for live status of every tile on this page; answers repaint them.
for (String server : view) {
sendUpstream(player, ControlFrame.statusQuery(server));
}
}
// ---- rendering: PaperScreen draws the menu's slots as items ----
// ---- click: a tile is a button, never an item to pick up ----
private final class PaperScreen implements LobbyMenu.Screen {
private final Player player;
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
Inventory top = event.getView().getTopInventory();
if (!(top.getHolder() instanceof MenuHolder)) {
return; // not our GUI
PaperScreen(Player player) {
this.player = player;
}
// Every slot in our GUI is a button: cancel unconditionally so nothing can be
// taken out, even on clicks in empty slots or the player's own inventory.
event.setCancelled(true);
if (event.getClickedInventory() != top) {
return; // click landed in the player's inventory, not a tile
}
if (!(event.getWhoClicked() instanceof Player)) {
return;
}
Player player = (Player) event.getWhoClicked();
MenuHolder holder = (MenuHolder) top.getHolder();
int slot = event.getSlot();
if (holder.pages() > 1 && (slot == PREV_SLOT || slot == NEXT_SLOT)) {
int target = holder.page() + (slot == PREV_SLOT ? -1 : 1);
if (target >= 0 && target < holder.pages()) {
openPage(player, holder.all(), holder.access(), target);
}
return;
}
if (slot < 0 || slot >= holder.servers().size()) {
return; // padding slot
}
String server = holder.servers().get(slot);
ControlFrame state = holder.latest(server);
if (state == null) {
return; // still loading — no status yet, so we don't know which frame to send
}
boolean zh = zh(player);
MenuTiles.Tile tile = MenuTiles.tile(state, holder.verdict(server), zh);
ControlFrame click = MenuTiles.click(tile, player.getName(), server);
if (click == null) {
// A start felis-api would refuse: say why and leave the menu open, so the
// player can pick another server.
player.sendMessage(Component.text("⚠ " + server + ": " + MenuTiles.chatReason(tile, zh),
NamedTextColor.YELLOW));
return;
}
sendUpstream(player, click);
player.closeInventory();
}
@EventHandler
public void onInventoryDrag(InventoryDragEvent event) {
// A drag can deposit into or sweep across our tiles without ever firing a
// single InventoryClickEvent on them, so the click guard alone is not enough:
// cancel any drag that touches our GUI so a tile can never be grabbed or smeared.
if (event.getView().getTopInventory().getHolder() instanceof MenuHolder) {
event.setCancelled(true);
@Override
public String name() {
return player.getName();
}
}
// ---- downstream: felis:control frames from the proxy ----
@Override
public boolean zh() {
return FelisPaperPlugin.zh(player);
}
@Override
public void onPluginMessageReceived(String channel, Player player, byte[] message) {
if (!Control.CHANNEL.equals(channel)) {
return;
@Override
public boolean online() {
return player.isOnline();
}
ControlFrame frame;
try {
frame = Control.decode(message);
} catch (IllegalArgumentException e) {
getLogger().fine("Dropping malformed felis:control frame: " + e.getMessage());
return;
@Override
public void send(ControlFrame frame) {
player.sendPluginMessage(FelisPaperPlugin.this, Control.CHANNEL, Control.encode(frame));
}
switch (frame.type()) {
case ControlFrame.LIST_UPDATE:
// Only a /menu that is still waiting opens; a late answer after the
// timeout message is dropped rather than popping a menu up unasked.
if (pendingOpen.remove(player.getUniqueId()) != null) {
openPage(player, frame.servers(), MenuTiles.accessByName(frame), 0);
@Override
public void chat(String text, LobbyMenu.Tone tone) {
player.sendMessage(Component.text(text, tone == LobbyMenu.Tone.ERROR ? NamedTextColor.RED : NamedTextColor.YELLOW));
}
@Override
public void open(MenuHolder holder, String title, int size, LobbyMenu.Slot[] slots) {
Inventory inv = Bukkit.createInventory(holder, size,
Component.text(title, NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false));
holder.setInventory(inv);
boolean zh = zh();
for (int i = 0; i < slots.length; i++) {
if (slots[i] != null) {
inv.setItem(i, item(slots[i], zh));
}
break;
case ControlFrame.STATUS_UPDATE:
applyStatus(player, frame);
break;
case ControlFrame.ERROR:
if (frame.server() != null && markUnavailable(player, frame)) {
break; // a tile's status query failed: shown on the tile itself
}
// The proxy already sanitizes transport faults; this is the only place
// a claim/quota/policy refusal becomes visible to the player.
player.sendMessage(Component.text("⚠ " + errorText(frame, zh(player)), NamedTextColor.RED));
break;
case ControlFrame.TRANSFER_READY:
// The proxy performs the actual Connect; just make sure a stale menu is
// not left open over the join.
closeIfMenu(player);
break;
default:
// Upstream-only types (Wake/Claim/StatusQuery) are never expected back.
}
player.openInventory(inv);
}
}
private void applyStatus(Player player, ControlFrame frame) {
Inventory top = player.getOpenInventory().getTopInventory();
if (!(top.getHolder() instanceof MenuHolder)) {
return; // the player closed the menu before the answer arrived
@Override
public void paint(MenuHolder holder, int slot, LobbyMenu.Slot content) {
holder.getInventory().setItem(slot, item(content, zh()));
}
MenuHolder holder = (MenuHolder) top.getHolder();
int slot = holder.servers().indexOf(frame.server());
if (slot < 0) {
return; // a server we are not showing
}
holder.put(frame.server(), frame);
top.setItem(slot, tile(frame, holder.verdict(frame.server()), zh(player)));
}
// markUnavailable repaints a still-loading tile whose status query was refused, so
// 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;
@Override
public MenuHolder shown() {
InventoryHolder holder = player.getOpenInventory().getTopInventory().getHolder();
return holder instanceof MenuHolder menuHolder ? menuHolder : null;
}
MenuHolder holder = (MenuHolder) top.getHolder();
int slot = holder.servers().indexOf(error.server());
if (slot < 0 || holder.latest(error.server()) != null) {
return false;
}
boolean zh = zh(player);
ItemStack item = new ItemStack(Material.BARRIER);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(error.server(), NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false));
meta.lore(List.of(Component.text(errorText(error, zh), NamedTextColor.GRAY)
.decoration(TextDecoration.ITALIC, false)));
item.setItemMeta(meta);
top.setItem(slot, item);
return true;
}
private void closeIfMenu(Player player) {
if (player.getOpenInventory().getTopInventory().getHolder() instanceof MenuHolder) {
@Override
public void close() {
player.closeInventory();
}
}
// ---- rendering ----
private static Component menuTitle(boolean zh, int page, int pages) {
String title = zh ? "Felis 服务器" : "Felis Servers";
if (pages > 1) {
title += " (" + (page + 1) + "/" + pages + ")";
}
return Component.text(title, NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
private static ItemStack item(LobbyMenu.Slot slot, boolean zh) {
return switch (slot) {
case LobbyMenu.Loading l -> loadingTile(l.server(), zh);
case LobbyMenu.Status s -> tile(s.status(), s.tile(), zh);
case LobbyMenu.Unavailable u -> unavailableTile(u.server(), u.reason());
case LobbyMenu.Nav n -> navItem(n.arrow() ? Material.ARROW : Material.PAPER, n.label());
};
}
private static ItemStack navItem(Material material, String label) {
@@ -324,8 +188,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return item;
}
private ItemStack tile(ControlFrame f, String verdict, boolean zh) {
MenuTiles.Tile t = MenuTiles.tile(f, verdict, zh);
private static ItemStack tile(ControlFrame f, MenuTiles.Tile t, boolean zh) {
Material material;
NamedTextColor color;
switch (t.kind()) {
@@ -365,7 +228,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return item;
}
private ItemStack loadingTile(String server, boolean zh) {
private static ItemStack loadingTile(String server, boolean zh) {
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
@@ -375,71 +238,18 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
return item;
}
private static ItemStack unavailableTile(String server, String reason) {
ItemStack item = new ItemStack(Material.BARRIER);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(server, NamedTextColor.DARK_GRAY).decoration(TextDecoration.ITALIC, false));
meta.lore(List.of(Component.text(reason, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)));
item.setItemMeta(meta);
return item;
}
private static Component line(String key, String value) {
return Component.text(key + ": ", NamedTextColor.GRAY)
.append(Component.text(value, NamedTextColor.WHITE))
.decoration(TextDecoration.ITALIC, false);
}
private static String errorText(ControlFrame f, boolean zh) {
String code = f.code();
if (code != null) {
switch (code) {
case "not_linked":
return zh ? "请先绑定账号——运行 /link,然后在网页控制台完成绑定。"
: "Link your account first — run /link, then finish on the web console.";
case "quota_exceeded":
return zh ? "你已达到服务器配额上限。"
: "You've reached your server quota.";
case "already_claimed":
return zh ? "该服务器已被认领。"
: "That server is already claimed.";
case "at_capacity":
return zh ? "集群当前已满,请稍后再试。"
: "The cluster is full right now — please try again later.";
case "cooldown":
return zh ? "这台服务器刚被唤醒过,请稍候再试。"
: "That server was just woken — try again in a moment.";
case "forbidden":
return zh ? "你没有权限这样做。"
: "You're not allowed to do that.";
case "not_found":
case "bad_name":
case "invalid_server_name":
return zh ? "这台服务器已不存在。"
: "That server no longer exists.";
case "busy":
return zh ? "Felis 现在很忙,请过一会儿再试。"
: "Felis is busy right now — try again in a moment.";
case "transport_error":
case "interrupted":
return zh ? "Felis 暂时不可用,请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default:
break;
}
}
// An unmapped code carries felis-api's own English message; a Chinese client
// gets a generic line rather than untranslated text.
if (!zh && f.message() != null && !f.message().isEmpty()) {
return f.message();
}
return zh ? "请求失败,请重试。" : "Request failed — please try again.";
}
// ---- helpers ----
/** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
static boolean zh(Player player) {
return "zh".equalsIgnoreCase(player.locale().getLanguage());
}
private void sendUpstream(Player player, ControlFrame frame) {
player.sendPluginMessage(this, Control.CHANNEL, Control.encode(frame));
}
private static int invSize(int count) {
int rows = Math.max(1, (count + 8) / 9);
return Math.min(rows, 6) * 9;
}
}
@@ -0,0 +1,347 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.plugin.messaging.PluginMessageListener;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.logging.Logger;
/**
* LobbyMenu is what the {@code /menu} chest GUI does (see {@link FelisPaperPlugin} for
* the flow): it asks the proxy for the list, opens a page of tiles, asks for each
* tile's status and repaints it, turns a click into the one frame it stands for, and
* shows what the proxy refuses. It decides what every slot holds ({@link Slot}); the
* plugin's {@link Screen} draws that as items, which is what lets LobbyMenuTest drive
* the whole menu against real paper-api events without a running server.
*/
final class LobbyMenu implements Listener, PluginMessageListener {
// Bottom-row navigation slots on a paged menu.
static final int PREV_SLOT = 45;
static final int PAGE_SLOT = 49;
static final int NEXT_SLOT = 53;
// How long /menu waits for the proxy's ListUpdate before saying the list is down.
static final long LIST_TIMEOUT_TICKS = 60L;
/** Screen is one player's client as the menu sees it. */
interface Screen {
String name();
/** zh mirrors the Velocity rule: Chinese when the client locale is zh-*. */
boolean zh();
boolean online();
void send(ControlFrame frame);
void chat(String text, Tone tone);
/** open shows a new menu page; slots[i] is slot i's content, null for none. */
void open(MenuHolder holder, String title, int size, Slot[] slots);
/** paint redraws one slot of a menu page this screen shows. */
void paint(MenuHolder holder, int slot, Slot content);
/** shown is the menu page in front of the player, or null for none. */
MenuHolder shown();
void close();
}
/** Tone is a chat line's colour: a notice (yellow) or a refusal (red). */
enum Tone { NOTICE, ERROR }
/** Slot is what one menu slot holds. */
sealed interface Slot permits Loading, Status, Unavailable, Nav {
}
/** Loading is a tile whose status has not come back yet. */
record Loading(String server) implements Slot {
}
/** Status is a tile painted from the server's latest status. */
record Status(ControlFrame status, MenuTiles.Tile tile) implements Slot {
}
/** Unavailable is a tile whose status query the proxy refused, with the reason. */
record Unavailable(String server, String reason) implements Slot {
}
/** Nav is a bottom-row page control: an arrow, or the page number. */
record Nav(boolean arrow, String label) implements Slot {
}
/** Later runs a task on the main thread after a number of ticks. */
interface Later {
void run(Runnable task, long ticks);
}
private final Function<Player, Screen> screens;
private final Later later;
private final Logger log;
// Players who ran /menu and are waiting for the proxy's ListUpdate, each with the
// token of their latest request, so an earlier request's timeout cannot cut a
// later one's wait short.
private final Map<UUID, Object> pendingOpen = new ConcurrentHashMap<>();
LobbyMenu(Function<Player, Screen> screens, Later later, Logger log) {
this.screens = screens;
this.later = later;
this.log = log;
}
/** request is /menu: ask the proxy for the list, and give up after a few seconds. */
void request(Player player) {
UUID id = player.getUniqueId();
Screen screen = screens.apply(player);
Object token = new Object();
pendingOpen.put(id, token);
screen.send(ControlFrame.listRequest());
later.run(() -> {
if (pendingOpen.remove(id, token) && screen.online()) {
screen.chat(screen.zh() ? "暂时拿不到服务器列表,请稍后再试。"
: "The server list isn't available right now — please try again shortly.", Tone.NOTICE);
}
}, LIST_TIMEOUT_TICKS);
}
// ---- downstream: felis:control frames from the proxy ----
@Override
public void onPluginMessageReceived(String channel, Player player, byte[] message) {
if (!Control.CHANNEL.equals(channel)) {
return;
}
ControlFrame frame;
try {
frame = Control.decode(message);
} catch (IllegalArgumentException e) {
log.fine("Dropping malformed felis:control frame: " + e.getMessage());
return;
}
Screen screen = screens.apply(player);
switch (frame.type()) {
case ControlFrame.LIST_UPDATE:
// Only a /menu that is still waiting opens; a late answer after the
// timeout message is dropped rather than popping a menu up unasked.
if (pendingOpen.remove(player.getUniqueId()) != null) {
openPage(screen, frame.servers(), MenuTiles.accessByName(frame), 0);
}
break;
case ControlFrame.STATUS_UPDATE:
applyStatus(screen, frame);
break;
case ControlFrame.ERROR:
if (frame.server() != null && markUnavailable(screen, frame)) {
break; // a tile's status query failed: shown on the tile itself
}
// The proxy already sanitizes transport faults; this is the only place
// a claim/quota/policy refusal becomes visible to the player.
screen.chat("⚠ " + errorText(frame, screen.zh()), Tone.ERROR);
break;
case ControlFrame.TRANSFER_READY:
// The proxy performs the actual Connect; just make sure a stale menu is
// not left open over the join.
if (screen.shown() != null) {
screen.close();
}
break;
default:
// Upstream-only types (Wake/Claim/StatusQuery) are never expected back.
}
}
private void openPage(Screen screen, List<String> all, Map<String, String> access, int page) {
boolean zh = screen.zh();
if (all.isEmpty()) {
screen.chat(zh ? "还没有可加入的服务器——在网页控制台创建一个吧。"
: "There are no servers to join yet — create one on the web console.", Tone.NOTICE);
return;
}
int pages = MenuHolder.pageCount(all.size());
int p = Math.max(0, Math.min(page, pages - 1));
MenuHolder holder = new MenuHolder(all, access, p);
List<String> view = holder.servers();
int size = pages > 1 ? 54 : invSize(view.size());
Slot[] slots = new Slot[size];
for (int i = 0; i < view.size(); i++) {
slots[i] = new Loading(view.get(i));
}
if (pages > 1) {
if (p > 0) {
slots[PREV_SLOT] = new Nav(true, zh ? "上一页" : "Previous page");
}
slots[PAGE_SLOT] = new Nav(false, (p + 1) + " / " + pages);
if (p < pages - 1) {
slots[NEXT_SLOT] = new Nav(true, zh ? "下一页" : "Next page");
}
}
screen.open(holder, title(zh, p, pages), size, slots);
// Ask the proxy for live status of every tile on this page; answers repaint them.
for (String server : view) {
screen.send(ControlFrame.statusQuery(server));
}
}
private void applyStatus(Screen screen, ControlFrame frame) {
MenuHolder holder = screen.shown();
if (holder == null) {
return; // the player closed the menu before the answer arrived
}
int slot = holder.servers().indexOf(frame.server());
if (slot < 0) {
return; // a server we are not showing
}
holder.put(frame.server(), frame);
screen.paint(holder, slot, new Status(frame, MenuTiles.tile(frame, holder.verdict(frame.server()), screen.zh())));
}
// markUnavailable repaints a still-loading tile whose status query was refused, so
// it stops saying "loading" forever. Returns false when there is no such tile (the
// error belongs to a click, and goes to chat).
private boolean markUnavailable(Screen screen, ControlFrame error) {
MenuHolder holder = screen.shown();
if (holder == null) {
return false;
}
int slot = holder.servers().indexOf(error.server());
if (slot < 0 || holder.latest(error.server()) != null) {
return false;
}
screen.paint(holder, slot, new Unavailable(error.server(), errorText(error, screen.zh())));
return true;
}
// ---- clicks: a tile is a button, never an item to pick up ----
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
Inventory top = event.getView().getTopInventory();
if (!(top.getHolder() instanceof MenuHolder holder)) {
return; // not our GUI
}
// Every slot in our GUI is a button: cancel unconditionally so nothing can be
// taken out, even on clicks in empty slots or the player's own inventory.
event.setCancelled(true);
if (event.getClickedInventory() != top) {
return; // click landed in the player's inventory, not a tile
}
if (event.getWhoClicked() instanceof Player player) {
click(screens.apply(player), holder, event.getSlot());
}
}
@EventHandler
public void onInventoryDrag(InventoryDragEvent event) {
// A drag can deposit into or sweep across our tiles without ever firing a
// single InventoryClickEvent on them, so the click guard alone is not enough:
// cancel any drag that touches our GUI so a tile can never be grabbed or smeared.
if (event.getView().getTopInventory().getHolder() instanceof MenuHolder) {
event.setCancelled(true);
}
}
private void click(Screen screen, MenuHolder holder, int slot) {
if (holder.pages() > 1 && (slot == PREV_SLOT || slot == NEXT_SLOT)) {
int target = holder.page() + (slot == PREV_SLOT ? -1 : 1);
if (target >= 0 && target < holder.pages()) {
openPage(screen, holder.all(), holder.access(), target);
}
return;
}
if (slot < 0 || slot >= holder.servers().size()) {
return; // padding slot
}
String server = holder.servers().get(slot);
ControlFrame state = holder.latest(server);
if (state == null) {
return; // still loading — no status yet, so we don't know which frame to send
}
boolean zh = screen.zh();
MenuTiles.Tile tile = MenuTiles.tile(state, holder.verdict(server), zh);
ControlFrame click = MenuTiles.click(tile, screen.name(), server);
if (click == null) {
// A start felis-api would refuse: say why and leave the menu open, so the
// player can pick another server.
screen.chat("⚠ " + server + ": " + MenuTiles.chatReason(tile, zh), Tone.NOTICE);
return;
}
screen.send(click);
screen.close();
}
// ---- text ----
static String title(boolean zh, int page, int pages) {
String title = zh ? "Felis 服务器" : "Felis Servers";
if (pages > 1) {
title += " (" + (page + 1) + "/" + pages + ")";
}
return title;
}
static String errorText(ControlFrame f, boolean zh) {
String code = f.code();
if (code != null) {
switch (code) {
case "not_linked":
return zh ? "请先绑定账号——运行 /link,然后在网页控制台完成绑定。"
: "Link your account first — run /link, then finish on the web console.";
case "quota_exceeded":
return zh ? "你已达到服务器配额上限。"
: "You've reached your server quota.";
case "already_claimed":
return zh ? "该服务器已被认领。"
: "That server is already claimed.";
case "at_capacity":
return zh ? "集群当前已满,请稍后再试。"
: "The cluster is full right now — please try again later.";
case "cooldown":
return zh ? "这台服务器刚被唤醒过,请稍候再试。"
: "That server was just woken — try again in a moment.";
case "forbidden":
return zh ? "你没有权限这样做。"
: "You're not allowed to do that.";
case "not_found":
case "bad_name":
case "invalid_server_name":
return zh ? "这台服务器已不存在。"
: "That server no longer exists.";
case "busy":
return zh ? "Felis 现在很忙,请过一会儿再试。"
: "Felis is busy right now — try again in a moment.";
case "transport_error":
case "interrupted":
return zh ? "Felis 暂时不可用,请稍后再试。"
: "Felis is temporarily unavailable — please try again.";
default:
break;
}
}
// An unmapped code carries felis-api's own English message; a Chinese client
// gets a generic line rather than untranslated text.
if (!zh && f.message() != null && !f.message().isEmpty()) {
return f.message();
}
return zh ? "请求失败,请重试。" : "Request failed — please try again.";
}
// invSize is the smallest chest (one to six rows) that holds count tiles.
static int invSize(int count) {
int rows = Math.max(1, (count + 8) / 9);
return Math.min(rows, 6) * 9;
}
}