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

+14 -5
View File
@@ -62,9 +62,10 @@ tasks.withType(JavaCompile).configureEach {
}
// LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events around
// 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.
// Proxy-built fakes, MenuTilesTest checks the menu's tile judgement, and LobbyMenuTest
// drives the whole menu (frames, clicks, drags, paging) against a fake screen (plain
// mains, no framework, like the velocity routing tests). None is part of `build`, which
// the lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`, which runs all three.
sourceSets {
lobbyTest {
java {
@@ -72,6 +73,7 @@ sourceSets {
include 'best/lolicon/felis/paper/Fakes.java'
include 'best/lolicon/felis/paper/LobbyGuardTest.java'
include 'best/lolicon/felis/paper/MenuTilesTest.java'
include 'best/lolicon/felis/paper/LobbyMenuTest.java'
}
compileClasspath += sourceSets.main.output + configurations.compileClasspath
runtimeClasspath += output + compileClasspath
@@ -85,10 +87,17 @@ tasks.register('menuTilesTest', JavaExec) {
mainClass = 'best.lolicon.felis.paper.MenuTilesTest'
}
tasks.register('lobbyMenuTest', JavaExec) {
group = 'verification'
description = 'Runs the LobbyMenuTest self-test main.'
classpath = sourceSets.lobbyTest.runtimeClasspath
mainClass = 'best.lolicon.felis.paper.LobbyMenuTest'
}
tasks.register('lobbyTest', JavaExec) {
group = 'verification'
description = 'Runs the LobbyGuardTest and MenuTilesTest self-test mains.'
dependsOn 'menuTilesTest'
description = 'Runs the LobbyGuardTest, MenuTilesTest and LobbyMenuTest self-test mains.'
dependsOn 'menuTilesTest', 'lobbyMenuTest'
classpath = sourceSets.lobbyTest.runtimeClasspath
mainClass = 'best.lolicon.felis.paper.LobbyGuardTest'
}
@@ -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;
}
}
@@ -0,0 +1,547 @@
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.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import static best.lolicon.felis.paper.Fakes.UNANSWERED;
import static best.lolicon.felis.paper.Fakes.fake;
/**
* LobbyMenuTest drives the real {@link LobbyMenu} the way Paper does: felis:control
* frames through its plugin-message listener, and real paper-api click and drag events
* over fake inventory views, with a fake screen per player recording what the menu
* showed, painted, sent and said. It checks the menu a player actually gets: /menu
* opens only on a list it is still waiting for, each tile starts as loading and is
* repainted by its own status, a click sends the one frame its tile stands for (a grey
* tile sends nothing and says why), paging walks the whole list with the verdicts
* intact, a refused status query marks its tile while other refusals reach chat in the
* player's language, and nothing in the menu can be picked up or dragged.
*
* <p>Run: {@code ./gradlew lobbyTest} in plugins/paper (plugins/test.sh does).
*/
public final class LobbyMenuTest {
private static final Logger QUIET = Logger.getLogger("LobbyMenuTest");
private static int checks;
public static void main(String[] args) {
QUIET.setLevel(Level.OFF);
listOpensAsLoadingTiles();
statusRepaintsItsOwnTile();
clicksSendWhatTheTileSays();
nothingCanBeTakenOrDragged();
listWaitTimesOut();
eachRequestGetsItsOwnWait();
emptyListSaysSo();
pagingWalksTheWholeList();
refusalsReachThePlayer();
errorTextInEachLanguage();
transferReadyClosesOnlyTheMenu();
strayFramesAreDropped();
System.out.println("LobbyMenuTest OK (" + checks + " checks)");
}
private static void listOpensAsLoadingTiles() {
Rig rig = new Rig();
FakeScreen s = rig.screen("Steve", true);
rig.menu.request(s.player);
assertEq("/menu asks the proxy for the list", List.of(ControlFrame.listRequest()), s.sent);
assertEq("... and opens nothing yet", 0, s.opened.size());
rig.frame(s, ControlFrame.listUpdate(List.of("mine", "open", "locked"), List.of("owner", "wake", "owner_only")));
assertEq("one page opens", 1, s.opened.size());
Opened page = s.opened.get(0);
assertEq("title in the player's language", "Felis 服务器", page.title);
assertEq("one row for three servers", 9, page.size);
assertEq("each tile loading, in list order",
Arrays.asList(new LobbyMenu.Loading("mine"), new LobbyMenu.Loading("open"), new LobbyMenu.Loading("locked"),
null, null, null, null, null, null),
Arrays.asList(page.slots));
assertEq("a status query per tile, in order",
List.of(ControlFrame.listRequest(), ControlFrame.statusQuery("mine"), ControlFrame.statusQuery("open"),
ControlFrame.statusQuery("locked")),
s.sent);
assertEq("the page is what the player sees", page.holder, s.shown);
FakeScreen other = rig.screen("Alex", false);
rig.frame(other, ControlFrame.listUpdate(List.of("mine")));
assertEq("a list nobody asked for opens nothing", 0, other.opened.size());
}
private static void statusRepaintsItsOwnTile() {
Rig rig = new Rig();
FakeScreen s = rig.openMenu("Steve", false, List.of("mine", "open", "locked", "free"),
List.of("owner", "wake", "owner_only", "wake"));
rig.frame(s, up("open"));
rig.frame(s, stopped("locked"));
rig.frame(s, stopped("mine"));
rig.frame(s, ownerless("free"));
assertEq("an up server: Join", MenuTiles.Kind.JOIN, kind(s, 1));
assertEq("owner_only and stopped: grey", MenuTiles.Kind.LOCKED, kind(s, 2));
assertEq("the player's own stopped server: Start", MenuTiles.Kind.WAKE, kind(s, 0));
assertTrue("... marked as theirs", ((LobbyMenu.Status) s.slots[0]).tile().mine());
assertEq("ownerless: Claim", MenuTiles.Kind.CLAIM, kind(s, 3));
assertEq("the tile keeps the frame it was painted from", up("open"), ((LobbyMenu.Status) s.slots[1]).status());
int paints = s.paints;
rig.frame(s, up("elsewhere"));
assertEq("a server not on the page repaints nothing", paints, s.paints);
s.close();
rig.frame(s, up("open"));
assertEq("a closed menu repaints nothing", paints, s.paints);
}
private static void clicksSendWhatTheTileSays() {
Rig rig = new Rig();
List<String> names = List.of("open", "locked", "free", "pending");
FakeScreen s = rig.openMenu("Steve", true, names, List.of("wake", "owner_only", "wake", "wake"));
rig.frame(s, up("open"));
rig.frame(s, stopped("locked"));
rig.frame(s, ownerless("free"));
int sent = s.sent.size();
rig.click(s, 3);
assertEq("a loading tile sends nothing", sent, s.sent.size());
rig.click(s, 7);
assertEq("an empty slot sends nothing", sent, s.sent.size());
rig.click(s, 1);
assertEq("a grey tile sends nothing", sent, s.sent.size());
assertEq("... says why in chat", "NOTICE ⚠ locked: 只有主人能启动这台服务器,它运行时任何人都能加入。", last(s.chat));
assertTrue("... and leaves the menu open", s.shown != null);
rig.click(s, 0);
assertEq("Join sends a wake for this player", ControlFrame.wakeRequest("Steve", "open"), last(s.sent));
assertEq("... and closes the menu", null, s.shown);
FakeScreen c = rig.openMenu("Alex", false, names, List.of());
rig.frame(c, ownerless("free"));
rig.click(c, 2);
assertEq("Claim sends a claim", ControlFrame.claimRequest("Alex", "free"), last(c.sent));
assertEq("... and closes the menu", null, c.shown);
}
private static void nothingCanBeTakenOrDragged() {
Rig rig = new Rig();
FakeScreen s = rig.openMenu("Steve", false, List.of("open"), List.of());
rig.frame(s, up("open"));
int sent = s.sent.size();
assertTrue("a click on a tile is cancelled", rig.click(s, 0).isCancelled());
s.shown = s.opened.get(0).holder; // the click closed it; look again
assertTrue("a click on an empty slot is cancelled", rig.click(s, 5).isCancelled());
// Raw slot 9 is the player's own first slot, which Paper also numbers 0: the same
// index as the Join tile above it.
InventoryClickEvent own = rig.click(s, 9);
assertTrue("a click in the player's own inventory is cancelled too", own.isCancelled());
assertEq("... and acts on no tile", sent + 1, s.sent.size());
assertTrue("a drag over the menu is cancelled", rig.drag(s.shown, 9).isCancelled());
InventoryHolder chest = fake(InventoryHolder.class, (m, a) -> UNANSWERED);
assertTrue("a click in some other chest is left alone", !rig.clickIn(s, chest, 27, 0).isCancelled());
assertTrue("a drag in some other chest is left alone", !rig.drag(chest, 27).isCancelled());
assertEq("... and sends nothing", sent + 1, s.sent.size());
}
private static void listWaitTimesOut() {
Rig rig = new Rig();
FakeScreen s = rig.screen("Steve", false);
rig.menu.request(s.player);
rig.ticks.advance(LobbyMenu.LIST_TIMEOUT_TICKS - 1);
assertEq("no message while the list may still come", List.of(), s.chat);
rig.ticks.advance(1);
assertEq("the player hears the list is down",
List.of("NOTICE The server list isn't available right now — please try again shortly."), s.chat);
rig.frame(s, ControlFrame.listUpdate(List.of("open")));
assertEq("a list after that opens nothing", 0, s.opened.size());
FakeScreen gone = rig.screen("Alex", true);
rig.menu.request(gone.player);
gone.online = false;
rig.ticks.advance(LobbyMenu.LIST_TIMEOUT_TICKS);
assertEq("a player who left hears nothing", List.of(), gone.chat);
FakeScreen answered = rig.screen("Kai", true);
rig.menu.request(answered.player);
rig.frame(answered, ControlFrame.listUpdate(List.of("open")));
rig.ticks.advance(LobbyMenu.LIST_TIMEOUT_TICKS);
assertEq("an answered /menu never says the list is down", List.of(), answered.chat);
}
private static void eachRequestGetsItsOwnWait() {
Rig rig = new Rig();
FakeScreen s = rig.screen("Steve", false);
rig.menu.request(s.player);
rig.ticks.advance(50);
rig.menu.request(s.player); // /menu again before the first gave up
rig.ticks.advance(20); // past the first request's deadline
assertEq("the first timeout does not cut the second wait short", List.of(), s.chat);
rig.frame(s, ControlFrame.listUpdate(List.of("open")));
assertEq("... so its list still opens", 1, s.opened.size());
rig.ticks.advance(LobbyMenu.LIST_TIMEOUT_TICKS);
assertEq("... and no timeout follows", List.of(), s.chat);
}
private static void emptyListSaysSo() {
Rig rig = new Rig();
FakeScreen s = rig.screen("Steve", true);
rig.menu.request(s.player);
rig.frame(s, ControlFrame.listUpdate(List.of()));
assertEq("no servers: no menu", 0, s.opened.size());
assertEq("... and a pointer to the console", List.of("NOTICE 还没有可加入的服务器——在网页控制台创建一个吧。"), s.chat);
assertEq("a chest just big enough: 1, 9, 10, 45 tiles", List.of(9, 9, 18, 45),
List.of(LobbyMenu.invSize(1), LobbyMenu.invSize(9), LobbyMenu.invSize(10), LobbyMenu.invSize(45)));
}
private static void pagingWalksTheWholeList() {
Rig rig = new Rig();
List<String> names = new ArrayList<>();
List<String> access = new ArrayList<>();
for (int i = 0; i < 100; i++) {
names.add(String.format("s%03d", i));
access.add(i == 50 ? "allowlist" : "wake");
}
FakeScreen s = rig.openMenu("Steve", false, names, access);
Opened p1 = s.opened.get(0);
assertEq("a long list opens a full chest", 54, p1.size);
assertEq("title counts the pages", "Felis Servers (1/3)", p1.title);
assertEq("45 tiles on the first page", new LobbyMenu.Loading("s044"), p1.slots[44]);
assertEq("no previous arrow on the first page", null, p1.slots[LobbyMenu.PREV_SLOT]);
assertEq("the page number", new LobbyMenu.Nav(false, "1 / 3"), p1.slots[LobbyMenu.PAGE_SLOT]);
assertEq("a next arrow", new LobbyMenu.Nav(true, "Next page"), p1.slots[LobbyMenu.NEXT_SLOT]);
assertEq("status asked for this page only", 1 + 45, s.sent.size());
rig.click(s, LobbyMenu.PREV_SLOT);
assertEq("the empty previous slot on page one does nothing", 1, s.opened.size());
rig.click(s, LobbyMenu.NEXT_SLOT);
Opened p2 = s.opened.get(1);
assertEq("next opens page two", "Felis Servers (2/3)", p2.title);
assertEq("... starting where page one ended", new LobbyMenu.Loading("s045"), p2.slots[0]);
assertEq("... with a previous arrow", new LobbyMenu.Nav(true, "Previous page"), p2.slots[LobbyMenu.PREV_SLOT]);
assertEq("... and asks for its own tiles", ControlFrame.statusQuery("s089"), last(s.sent));
rig.frame(s, stopped("s050"));
assertEq("the list's verdicts survive the paging", MenuTiles.Kind.LOCKED, kind(s, 5));
rig.click(s, LobbyMenu.NEXT_SLOT);
Opened p3 = s.opened.get(2);
assertEq("the last page holds the rest", new LobbyMenu.Loading("s099"), p3.slots[9]);
assertEq("... and no more", null, p3.slots[10]);
assertEq("no next arrow on the last page", null, p3.slots[LobbyMenu.NEXT_SLOT]);
int sent = s.sent.size();
rig.click(s, LobbyMenu.NEXT_SLOT);
rig.click(s, 20);
assertEq("next and padding on the last page do nothing", List.of(3, sent), List.of(s.opened.size(), s.sent.size()));
rig.click(s, LobbyMenu.PREV_SLOT);
assertEq("previous goes back", "Felis Servers (2/3)", s.opened.get(3).title);
}
private static void refusalsReachThePlayer() {
Rig rig = new Rig();
FakeScreen s = rig.openMenu("Steve", true, List.of("gone", "open"), List.of());
rig.frame(s, ControlFrame.error("not_found", "server not found", "gone"));
assertEq("a refused status query marks its tile", new LobbyMenu.Unavailable("gone", "这台服务器已不存在。"), s.slots[0]);
assertEq("... instead of chat", List.of(), s.chat);
rig.frame(s, up("open"));
rig.frame(s, ControlFrame.error("cooldown", "woken recently", "open"));
assertEq("a refused click on a painted tile goes to chat", "ERROR ⚠ 这台服务器刚被唤醒过,请稍候再试。", last(s.chat));
assertEq("... and keeps the tile", MenuTiles.Kind.JOIN, kind(s, 1));
rig.frame(s, ControlFrame.error("quota_exceeded", "quota", null));
assertEq("a refusal naming no server goes to chat", "ERROR ⚠ 你已达到服务器配额上限。", last(s.chat));
rig.frame(s, ControlFrame.error("already_claimed", "claimed", "elsewhere"));
assertEq("a refusal for a server not on the page goes to chat", "ERROR ⚠ 该服务器已被认领。", last(s.chat));
s.close();
rig.frame(s, ControlFrame.error("at_capacity", "full", "gone"));
assertEq("with the menu closed it goes to chat", "ERROR ⚠ 集群当前已满,请稍后再试。", last(s.chat));
}
private static void errorTextInEachLanguage() {
String[][] same = {{"not_found", "bad_name"}, {"not_found", "invalid_server_name"}, {"transport_error", "interrupted"}};
for (String[] pair : same) {
for (boolean zh : new boolean[]{true, false}) {
assertEq(pair[1] + " reads as " + pair[0], text(pair[0], "x", zh), text(pair[1], "y", zh));
}
}
String[] codes = {"not_linked", "quota_exceeded", "already_claimed", "at_capacity", "cooldown", "forbidden",
"not_found", "busy", "transport_error"};
Map<String, String> seen = new HashMap<>();
for (String code : codes) {
for (boolean zh : new boolean[]{true, false}) {
String t = text(code, "felis-api's words", zh);
assertTrue(code + " has its own line", seen.put(t, code) == null);
assertTrue(code + " is in the player's language: " + t, zh == t.codePoints().anyMatch(c -> c >= 0x4E00));
assertTrue(code + " hides felis-api's words", !t.contains("felis-api's words"));
}
}
assertTrue("not_linked names /link", text("not_linked", null, true).contains("/link") && text("not_linked", null, false).contains("/link"));
assertEq("an unknown code shows felis-api's message in English", "The world is frozen.", text("new_code", "The world is frozen.", false));
assertEq("... and a generic line in Chinese", "请求失败,请重试。", text("new_code", "The world is frozen.", true));
assertEq("no code, no message: generic", "Request failed — please try again.", text(null, "", false));
assertEq("no code, a message: the message", "Server is retiring.", text(null, "Server is retiring.", false));
}
private static void transferReadyClosesOnlyTheMenu() {
Rig rig = new Rig();
FakeScreen s = rig.openMenu("Steve", false, List.of("open"), List.of());
rig.frame(s, ControlFrame.transferReady("Steve", "open"));
assertEq("the menu closes before the move", List.of(1, "none"), List.of(s.closes, s.shown == null ? "none" : "open"));
rig.frame(s, ControlFrame.transferReady("Steve", "open"));
assertEq("with no menu open, nothing else is closed", 1, s.closes);
}
private static void strayFramesAreDropped() {
Rig rig = new Rig();
FakeScreen s = rig.screen("Steve", false);
rig.menu.request(s.player);
rig.menu.onPluginMessageReceived(Control.CHANNEL, s.player, new byte[]{1, 2, 3});
rig.menu.onPluginMessageReceived("minecraft:brand", s.player, Control.encode(ControlFrame.listUpdate(List.of("open"))));
assertEq("a malformed frame or another channel opens nothing", 0, s.opened.size());
rig.frame(s, ControlFrame.wakeRequest("Steve", "open"));
assertEq("an upstream-only frame does nothing", List.of(0, 0), List.of(s.opened.size(), s.chat.size()));
rig.frame(s, ControlFrame.listUpdate(List.of("open")));
assertEq("... and the /menu still opens on its list", 1, s.opened.size());
}
// ---- rig ----
private static final class Rig {
final Ticks ticks = new Ticks();
final Map<Player, FakeScreen> screens = new HashMap<>();
final LobbyMenu menu = new LobbyMenu(screens::get, ticks, QUIET);
FakeScreen screen(String name, boolean zh) {
FakeScreen s = new FakeScreen(name, zh);
screens.put(s.player, s);
return s;
}
/** openMenu runs /menu for a new player and answers it with a list. */
FakeScreen openMenu(String name, boolean zh, List<String> servers, List<String> access) {
FakeScreen s = screen(name, zh);
menu.request(s.player);
frame(s, access.isEmpty() ? ControlFrame.listUpdate(servers) : ControlFrame.listUpdate(servers, access));
return s;
}
void frame(FakeScreen s, ControlFrame f) {
menu.onPluginMessageReceived(Control.CHANNEL, s.player, Control.encode(f));
}
/** click clicks a raw slot of the menu the player has open. */
InventoryClickEvent click(FakeScreen s, int rawSlot) {
return clickIn(s, s.shown, s.shown == null ? 9 : s.shownSize(), rawSlot);
}
InventoryClickEvent clickIn(FakeScreen s, InventoryHolder holder, int size, int rawSlot) {
InventoryClickEvent e = new InventoryClickEvent(view(holder, size, s.player), InventoryType.SlotType.CONTAINER,
rawSlot, ClickType.LEFT, InventoryAction.PICKUP_ALL);
menu.onInventoryClick(e);
return e;
}
InventoryDragEvent drag(InventoryHolder holder, int size) {
Map<Integer, ItemStack> slots = new HashMap<>();
slots.put(0, null);
slots.put(1, null);
InventoryDragEvent e = new InventoryDragEvent(view(holder, size, null), null, new ItemStack() {
}, false, slots);
menu.onInventoryDrag(e);
return e;
}
// view is a chest of size slots above a 36-slot player inventory, as Paper lays
// out an open container: raw slots count the chest first.
private static InventoryView view(InventoryHolder holder, int size, Player player) {
Inventory top = fake(Inventory.class, (m, a) -> switch (m) {
case "getHolder" -> holder;
case "getSize" -> size;
default -> UNANSWERED;
});
Inventory bottom = fake(Inventory.class, (m, a) -> m.equals("getSize") ? 36 : UNANSWERED);
return fake(InventoryView.class, (m, a) -> switch (m) {
case "getTopInventory" -> top;
case "getBottomInventory" -> bottom;
case "getPlayer" -> player;
case "convertSlot" -> (int) a[0] < size ? a[0] : (int) a[0] - size;
case "getInventory" -> (int) a[0] < 0 ? null : (int) a[0] < size ? top : bottom;
default -> UNANSWERED;
});
}
}
/** Ticks runs the menu's delayed tasks on a virtual main thread. */
private static final class Ticks implements LobbyMenu.Later {
private final List<Object[]> tasks = new ArrayList<>();
private long now;
@Override
public void run(Runnable task, long ticks) {
tasks.add(new Object[]{now + ticks, task});
}
void advance(long ticks) {
now += ticks;
List<Object[]> due = new ArrayList<>();
tasks.removeIf(t -> (long) t[0] <= now && due.add(t));
due.sort((x, y) -> Long.compare((long) x[0], (long) y[0]));
due.forEach(t -> ((Runnable) t[1]).run());
}
}
private record Opened(MenuHolder holder, String title, int size, LobbyMenu.Slot[] slots) {
}
/** FakeScreen is one player's client: what the menu showed, painted, sent and said. */
private static final class FakeScreen implements LobbyMenu.Screen {
final String name;
final boolean zh;
final Player player;
final List<ControlFrame> sent = new ArrayList<>();
final List<String> chat = new ArrayList<>();
final List<Opened> opened = new ArrayList<>();
boolean online = true;
MenuHolder shown;
LobbyMenu.Slot[] slots;
int paints;
int closes;
FakeScreen(String name, boolean zh) {
this.name = name;
this.zh = zh;
UUID id = UUID.randomUUID();
this.player = fake(Player.class, (m, a) -> switch (m) {
case "getUniqueId" -> id;
case "getName" -> name;
default -> UNANSWERED;
});
}
int shownSize() {
for (Opened o : opened) {
if (o.holder == shown) {
return o.size;
}
}
throw new AssertionError("no open page");
}
@Override
public String name() {
return name;
}
@Override
public boolean zh() {
return zh;
}
@Override
public boolean online() {
return online;
}
@Override
public void send(ControlFrame frame) {
sent.add(frame);
}
@Override
public void chat(String text, LobbyMenu.Tone tone) {
chat.add(tone + " " + text);
}
@Override
public void open(MenuHolder holder, String title, int size, LobbyMenu.Slot[] content) {
if (content.length != size) {
throw new AssertionError("slots for " + content.length + " in a chest of " + size);
}
opened.add(new Opened(holder, title, size, content.clone()));
shown = holder;
slots = content.clone();
}
@Override
public void paint(MenuHolder holder, int slot, LobbyMenu.Slot content) {
if (holder != shown) {
throw new AssertionError("painted a page that is not showing");
}
slots[slot] = content;
paints++;
}
@Override
public MenuHolder shown() {
return shown;
}
@Override
public void close() {
shown = null;
closes++;
}
}
// ---- helpers ----
private static ControlFrame up(String server) {
return ControlFrame.statusUpdate(server, "Running", true, 3, 20, false);
}
private static ControlFrame stopped(String server) {
return ControlFrame.statusUpdate(server, "Stopped", false, 0, 20, false);
}
private static ControlFrame ownerless(String server) {
return ControlFrame.statusUpdate(server, "Stopped", false, 0, 20, true);
}
private static MenuTiles.Kind kind(FakeScreen s, int slot) {
if (!(s.slots[slot] instanceof LobbyMenu.Status st)) {
throw new AssertionError("slot " + slot + " holds " + s.slots[slot] + ", want a painted tile");
}
return st.tile().kind();
}
private static String text(String code, String message, boolean zh) {
return LobbyMenu.errorText(ControlFrame.error(code, message, null), zh);
}
private static <T> T last(List<T> list) {
return list.isEmpty() ? null : list.get(list.size() - 1);
}
private static void assertTrue(String what, boolean ok) {
if (!ok) {
throw new AssertionError(what);
}
checks++;
}
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++;
}
}