From 82310d02ec20d590265720ee4f9ea38a8b67fcc4 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 04:36:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(velocity):=20=E8=B7=AF=E7=94=B1=E5=BC=80?= =?UTF-8?q?=E5=90=AF=E6=97=B6=E8=AE=A9=E5=87=BA=20Velocity=20=E8=87=AA?= =?UTF-8?q?=E5=B8=A6=E7=9A=84=20/server=EF=BC=8C=E5=A4=A7=E5=8E=85?= =?UTF-8?q?=E7=9A=84=20/server=20=E8=8F=9C=E5=8D=95=E6=89=8D=E8=83=BD?= =?UTF-8?q?=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/README.md | 3 +- plugins/test.sh | 5 +- plugins/velocity/build.gradle | 5 +- .../felis/velocity/FelisVelocityPlugin.java | 33 ++++ .../felis/velocity/ServerCommandTest.java | 163 ++++++++++++++++++ 5 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 plugins/velocity/test/best/lolicon/felis/velocity/ServerCommandTest.java diff --git a/plugins/README.md b/plugins/README.md index 42b3857..b7d5bc0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -174,6 +174,7 @@ What it does when routing is active: | Join report (`ServerConnectedEvent`) | Reports real joins to a felis backend via `POST …/join-event`, so the reaper sees activity and the player is auto-added to the server allowlist. | | `/felis`, `/felis list` | Operator status: online-mode, root-domain, lobby, and the known server set with phase/ready. | | `/felis lobby`, `/felis go ` | Moves the player back to the lobby from any backend. Nothing is woken, and a wait already queued still moves them when its server is ready. Kept under `/felis` so a user server's own `/lobby` or `/hub` is not shadowed by the proxy. | +| `/server` | Velocity's own `/server` is removed so the command reaches the backend: the lobby's server menu there, a user server's own `/server` anywhere else. It listed the login gate, the lobby and every running server to every player. A `/server` another proxy plugin registered is kept. With routing off the built-in stays, since it is then the one way to switch servers. | Velocity-only config keys (read from the same `felis-link.properties` / env as `/link`; env wins): @@ -208,7 +209,7 @@ login gate treats its link-status polls the same way. The `paper/` module is the lobby's player-facing face for §27 scenario 10 (`/menu → plugin msg → velocity → api → 共用等待队列 → ready 后 Connect`). It runs on the Paper lobby server and gives players a chest GUI instead of a command -line: `/menu` (alias `/server`) opens a grid of one tile per server the proxy routes, +line: `/menu` (alias `/server`, which the proxy passes through with routing active) opens a grid of one tile per server the proxy routes, and clicking a tile wakes, claims, or joins that backend. **Pure UI face.** The lobby holds no felis-api token, opens no HTTP connection, diff --git a/plugins/test.sh b/plugins/test.sh index bc3b1d0..efe3f2f 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -39,14 +39,15 @@ # bundles: deploy/game-stack.lock's LIMBO_VERSION, which bootstrap passes too. # # 3. The velocity routing self-tests (`./gradlew routingTest`): ServerRegistry, -# WaitingRouter and ControlChannel run against the real velocity-api with a +# WaitingRouter, ControlChannel and the /server hand-off run against the real velocity-api with a # fake proxy and a stub felis-api — a refresh registers, moves and drops # backends and lets go of a renamed subdomain, the login gate and host routing # admit only linked players, each wake refusal reaches the player as its own # message (the stub answers a wake from the server's state in felis-api's own # order, so it cannot hand the router an answer the real API never gives), # felis:control acts only for the connection's player and holds its -# frame budget. They ride the module's verified dependency set, which is why +# frame budget, and Velocity's own /server steps aside for the backend's while +# one another proxy plugin registered stays. They ride the module's verified dependency set, which is why # they live in Gradle rather than in the javac mains above. # # 4. The lobby guard self-test (`./gradlew lobbyTest` in plugins/paper): LobbyGuard diff --git a/plugins/velocity/build.gradle b/plugins/velocity/build.gradle index b070143..e418a98 100644 --- a/plugins/velocity/build.gradle +++ b/plugins/velocity/build.gradle @@ -82,13 +82,14 @@ sourceSets { include 'best/lolicon/felis/velocity/ServerRegistryTest.java' include 'best/lolicon/felis/velocity/WaitingRouterTest.java' include 'best/lolicon/felis/velocity/ControlChannelTest.java' + include 'best/lolicon/felis/velocity/ServerCommandTest.java' } compileClasspath += sourceSets.main.output + configurations.compileClasspath runtimeClasspath += output + compileClasspath } } -def routingMains = ['ServerRegistryTest', 'WaitingRouterTest', 'ControlChannelTest'] +def routingMains = ['ServerRegistryTest', 'WaitingRouterTest', 'ControlChannelTest', 'ServerCommandTest'] routingMains.each { name -> tasks.register(name, JavaExec) { group = 'verification' @@ -100,7 +101,7 @@ routingMains.each { name -> tasks.register('routingTest') { group = 'verification' - description = 'Runs the routing self-tests (ServerRegistry, WaitingRouter, ControlChannel).' + description = 'Runs the routing self-tests (ServerRegistry, WaitingRouter, ControlChannel, ServerCommand).' dependsOn routingMains } diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index d609e98..9e6fe41 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -21,6 +21,7 @@ import com.velocitypowered.api.event.connection.DisconnectEvent; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.plugin.PluginManager; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; @@ -185,11 +186,43 @@ public final class FelisVelocityPlugin { // refresh above reached even a fork that reads the legacy list only once. legacyForwarding.accepting(); + if (yieldServerCommand(proxy.getCommandManager(), proxy.getPluginManager())) { + logger.info("Felis: Velocity's /server removed; /server now reaches the backend " + + "(the lobby's menu, or a server's own command)."); + } + this.routingActive = true; logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link, /felis and /invite registered.", config.rootDomain(), config.loginServer(), config.lobbyServer()); } + /** + * yieldServerCommand removes Velocity's own /server, which answers every player + * before any backend sees the command: it lists the login gate, the lobby and every + * running server, and it shadows the lobby's /server, which opens the Felis menu. + * Without it the command reaches the backend: the menu in the lobby, a user + * server's own /server anywhere else. A /server another proxy plugin registered is + * that plugin's choice and stays. Called only with routing active; without routing + * the built-in is the one way to change servers. + */ + static boolean yieldServerCommand(CommandManager commands, PluginManager plugins) { + CommandMeta meta = commands.getCommandMeta("server"); + if (meta == null) { + return false; + } + // Velocity registers its built-ins under its virtual plugin, id "velocity"; + // older proxies registered them with no plugin at all. + Object owner = meta.getPlugin(); + boolean builtin = owner == null || plugins.fromInstance(owner) + .map(c -> "velocity".equals(c.getDescription().getId())) + .orElse(false); + if (!builtin) { + return false; + } + commands.unregister("server"); + return true; + } + @Subscribe public void onDisconnect(DisconnectEvent event) { commandBudget.forget(event.getPlayer().getUniqueId()); diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/ServerCommandTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/ServerCommandTest.java new file mode 100644 index 0000000..bf9ee6c --- /dev/null +++ b/plugins/velocity/test/best/lolicon/felis/velocity/ServerCommandTest.java @@ -0,0 +1,163 @@ +package best.lolicon.felis.velocity; + +import com.velocitypowered.api.command.CommandManager; +import com.velocitypowered.api.command.CommandMeta; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginDescription; +import com.velocitypowered.api.plugin.PluginManager; + +import java.util.IdentityHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +import static best.lolicon.felis.velocity.Fakes.UNANSWERED; +import static best.lolicon.felis.velocity.Fakes.fake; + +/** + * ServerCommandTest drives FelisVelocityPlugin.yieldServerCommand against a fake + * command and plugin manager shaped like Velocity 3.5.1's: its built-ins are owned by + * the virtual plugin whose id is "velocity", aliases are case-insensitive, and + * unregistering removes only the alias named. Velocity's own /server goes, and so does + * one with no owner (how older proxies registered built-ins); a /server another plugin + * registered, or whose owner the plugin manager cannot name, stays; with no /server at + * all nothing is touched. Framework free: a failed assertion throws. + * + *

Run: {@code ./gradlew routingTest} in plugins/velocity. + */ +public final class ServerCommandTest { + + private static int checks; + + public static void main(String[] args) { + Object velocity = new Object(); + Object other = new Object(); + Plugins plugins = new Plugins(); + plugins.add(velocity, "velocity"); + plugins.add(other, "serverselector"); + + Commands builtin = new Commands(); + builtin.add("server", velocity); + builtin.add("glist", velocity); + builtin.add("felis", other); + assertEq("Velocity's /server is yielded", true, + FelisVelocityPlugin.yieldServerCommand(builtin.manager, plugins.manager)); + assertEq("... and gone", Set.of("glist", "felis"), builtin.aliases()); + assertEq("... by one unregister", 1, builtin.unregisters); + + Commands ownerless = new Commands(); + ownerless.add("Server", null); + assertEq("an ownerless /server is yielded", true, + FelisVelocityPlugin.yieldServerCommand(ownerless.manager, plugins.manager)); + assertEq("... and gone", Set.of(), ownerless.aliases()); + + Commands theirs = new Commands(); + theirs.add("server", other); + assertEq("another plugin's /server is kept", false, + FelisVelocityPlugin.yieldServerCommand(theirs.manager, plugins.manager)); + assertEq("... registered", Set.of("server"), theirs.aliases()); + assertEq("... untouched", 0, theirs.unregisters); + + Object lookalike = new Object(); + plugins.add(lookalike, "velocitytools"); + Commands near = new Commands(); + near.add("server", lookalike); + assertEq("a plugin whose id only starts with velocity keeps its /server", false, + FelisVelocityPlugin.yieldServerCommand(near.manager, plugins.manager)); + assertEq("... registered", Set.of("server"), near.aliases()); + + Commands unknown = new Commands(); + unknown.add("server", new Object()); + assertEq("a /server of an owner no plugin claims is kept", false, + FelisVelocityPlugin.yieldServerCommand(unknown.manager, plugins.manager)); + assertEq("... registered", Set.of("server"), unknown.aliases()); + + Commands none = new Commands(); + none.add("glist", velocity); + assertEq("no /server: nothing to yield", false, + FelisVelocityPlugin.yieldServerCommand(none.manager, plugins.manager)); + assertEq("... nothing unregistered", 0, none.unregisters); + assertEq("... the rest kept", Set.of("glist"), none.aliases()); + + System.out.println("ServerCommandTest OK (" + checks + " checks)"); + } + + /** Commands holds one meta per alias, keyed the way Velocity keys them: lower case. */ + private static final class Commands { + final Map byAlias = new TreeMap<>(); + int unregisters; + final CommandManager manager = fake(CommandManager.class, (m, a) -> { + switch (m) { + case "getCommandMeta": + return byAlias.get(((String) a[0]).toLowerCase(Locale.ENGLISH)); + case "hasCommand": + return a.length == 1 && byAlias.containsKey(((String) a[0]).toLowerCase(Locale.ENGLISH)); + case "unregister": + if (a[0] instanceof String alias) { + unregisters++; + byAlias.remove(alias.toLowerCase(Locale.ENGLISH)); + return null; + } + throw new UnsupportedOperationException("unregister(" + a[0].getClass().getSimpleName() + ")"); + default: + return UNANSWERED; + } + }); + + void add(String alias, Object owner) { + byAlias.put(alias.toLowerCase(Locale.ENGLISH), fake(CommandMeta.class, (m, a) -> { + switch (m) { + case "getPlugin": + return owner; + case "getAliases": + return Set.of(alias.toLowerCase(Locale.ENGLISH)); + default: + return UNANSWERED; + } + })); + } + + Set aliases() { + return Set.copyOf(byAlias.keySet()); + } + } + + /** Plugins maps each plugin instance to its container, as fromInstance does. */ + private static final class Plugins { + final Map byInstance = new IdentityHashMap<>(); + final PluginManager manager = fake(PluginManager.class, (m, a) -> + "fromInstance".equals(m) ? fromInstance(a[0]) : UNANSWERED); + + // Velocity's own fromInstance refuses a null instance. + private Optional fromInstance(Object instance) { + if (instance == null) { + throw new NullPointerException("instance"); + } + return Optional.ofNullable(byInstance.get(instance)); + } + + void add(Object instance, String id) { + PluginDescription description = fake(PluginDescription.class, + (m, a) -> "getId".equals(m) ? id : UNANSWERED); + byInstance.put(instance, fake(PluginContainer.class, (m, a) -> { + switch (m) { + case "getDescription": + return description; + case "getInstance": + return Optional.of(instance); + default: + return UNANSWERED; + } + })); + } + } + + 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++; + } +}