Unverified Commit 82310d02 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 7ce1ba71
Loading
Loading
Loading
Loading
+2 −1
Changes for plugins/README.md: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
Changes for plugins/test.sh: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for plugins/velocity/build.gradle: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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
}

+33 −0
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java: 33 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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());
+163 −0
Changes for plugins/velocity/test/best/lolicon/felis/velocity/ServerCommandTest.java: 163 added lines, 0 removed lines.
Original line number Diff line number Diff line
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++;
    }
}