fix(velocity): 路由开启时让出 Velocity 自带的 /server,大厅的 /server 菜单才能打开

This commit is contained in:
Lemon-miaow committed 2026-09-27 04:36:53 +08:00
1 parent 7ce1ba7174
commit 82310d02ec
5 files changed
+204 -5

No files matched your search

+2 -1
View File
@@ -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 <lobby>` | 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,
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
}
@@ -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());
@@ -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.
*
* <p>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<String, CommandMeta> 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<String> aliases() {
return Set.copyOf(byAlias.keySet());
}
}
/** Plugins maps each plugin instance to its container, as fromInstance does. */
private static final class Plugins {
final Map<Object, PluginContainer> 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<PluginContainer> 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++;
}
}