test(plugins): 登录闸门、大厅菜单和 mod /link 抽成可测类并补行为测试
This commit is contained in:
19 files changed
+2802
-826
No files matched your search
@@ -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++;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user