Unverified Commit 60b0ec97 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(invite): let a player bring a friend to the server they're on

/invite <player> posts a chat card to the invitee with a green [Accept] and a
red [Deny] button, and Accept walks them to the server the inviter is standing
on. It is a UX wrapper over `/felis go` and nothing more: the accept runs the
same doGo path on the ACCEPTING player's own verified uuid, so a stored invite
carries a server name and never an identity to act as, and the prompt needs no
unguessable token.

Why it can be this simple: an invite can only name the server its sender is
currently on, so the target is running by construction, and a running felis
server already admits any linked player through <name>.<root-domain> on the
link check alone (WaitingRouter.onServerPreConnect) — no wake, no
autostartPolicy consultation. Hence enqueueFromInvite: a READY backend is
joined directly, and only the not-ready case falls through to the policy-gated
wake path unchanged. Routing an accept through wakeAndWaitLinked would have
asked the API to wake a server that needs no waking, and autostartPolicy
defaults to ownerOnly, so the API would answer 403 and the green button would
do nothing for exactly the people you would invite.

It is not consequence-free, and the inviter is told so at send time rather
than in a comment only we read. Landing on a felis server records the player
in its allowlist (onServerConnected -> join-event -> RecordJoin); on an
autostartPolicy=allowlist server that row is what lets them come back and
START the thing later. The same row they would earn by walking in unaided —
the invite shortened the walk, it did not widen the door — but it outlives the
invite, so accessNotice says so. The wording follows the policy: only under
allowlist does it claim they will be able to start the server themselves,
because under the ownerOnly default (and the empty string the API reports for
an unset field) that row grants no waking and the claim would be a lie.

InviteBook also holds a 30s per-sender cooldown, because the one capability
/invite genuinely adds is "make a chat card appear on any online player", and
unrated that is a way to follow someone around their own chat log. It is
charged in put() rather than at the top of the command, so an invite refused
for an offline name or a player already on the server costs the sender
nothing, and the gate sits after every other validation for the same reason.
The stamp is global per sender on purpose: a per-(sender, invitee) key would
wave through one player papering the whole proxy, which is the thing being
limited.

The card lives in InviteCard as a pure function so the buttons — the whole
point of the feature — can be asserted without a live proxy, and the button
clicks are pinned to the server the card named, so a stale card cannot answer
a newer invite (checked with peek before the invite is spent, so refusing a
superseded card leaves the live one answerable).

Verified: production gradle 8.14 + JDK 21 build; jar carries the plugin classes
and no test classes; InviteBookTest (36 checks) and InviteCardTest (48 checks);
and a live Velocity 3.5.1 that loads the jar, registers /invite <player> and
answers /invite accept <server>, with a malformed subcommand as a negative
control.
parent a0064adf
Loading
Loading
Loading
Loading
+312 −7
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java: 312 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -68,10 +68,16 @@ import java.util.regex.Pattern;
public final class FelisVelocityPlugin {
    private static final Duration REGISTRATION_REFRESH = Duration.ofSeconds(15);
    private static final Duration WAIT_POLL = Duration.ofSeconds(2);
    private static final Duration INVITE_TTL = Duration.ofSeconds(120);
    // Long enough that spraying cards at a room is tedious, short enough that showing three
    // friends around one after another is not. Sub-TTL on purpose: a sender may hold several
    // live invites, they just cannot post them all in one breath.
    private static final Duration INVITE_COOLDOWN = Duration.ofSeconds(30);

    private final ProxyServer proxy;
    private final Logger logger;
    private final Path dataDirectory;
    private final InviteBook invites = new InviteBook(INVITE_TTL.toMillis(), INVITE_COOLDOWN.toMillis());

    private FelisVelocityConfig config;
    private LinkClient linkClient;
@@ -99,6 +105,7 @@ public final class FelisVelocityPlugin {
        this.linkClient = new LinkClient(config.linkConfig());
        registerLinkCommand();
        registerFelisCommand();
        registerInviteCommand();

        this.onlineMode = proxy.getConfiguration().isOnlineMode();
        if (!onlineMode) {
@@ -136,7 +143,7 @@ public final class FelisVelocityPlugin {
        repeating(WAIT_POLL, router::tick);

        this.routingActive = true;
        logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link and /felis registered.",
        logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link, /felis and /invite registered.",
                config.rootDomain(), config.loginServer(), config.lobbyServer());
    }

@@ -267,7 +274,7 @@ public final class FelisVelocityPlugin {
                .then(BrigadierCommand.literalArgumentBuilder("go")
                        .then(BrigadierCommand.requiredArgumentBuilder("server", StringArgumentType.word())
                                .executes(ctx -> {
                                    doGo(ctx.getSource(), StringArgumentType.getString(ctx, "server"));
                                    doGo(ctx.getSource(), StringArgumentType.getString(ctx, "server"), false);
                                    return Command.SINGLE_SUCCESS;
                                })))
                .then(BrigadierCommand.literalArgumentBuilder("claim")
@@ -414,15 +421,23 @@ public final class FelisVelocityPlugin {
        }
    }

    private void doGo(CommandSource source, String serverArg) {
    /**
     * doGo parks the caller on the server they named. Returns whether the request reached
     * the waiting queue — false means a guard refused it and told the player why, which is
     * what {@link #doInviteAnswer} reports back to the inviter instead of guessing.
     *
     * <p>{@code joinIfReady} is set only by an accepted invite: see
     * {@link WaitingRouter#enqueueFromInvite}.
     */
    private boolean doGo(CommandSource source, String serverArg, boolean joinIfReady) {
        Player player = requirePlayer(source);
        if (player == null || !ensureOutOfLimbo(player)) {
            return;
            return false;
        }
        boolean zh = zh(player);
        if (!routingActive) {
            player.sendMessage(routingDisabled(zh));
            return;
            return false;
        }
        String target = serverArg.trim();
        ServerView match = null;
@@ -436,19 +451,24 @@ public final class FelisVelocityPlugin {
            player.sendMessage(Component.text(
                    zh ? "没有名为「" + target + "」的 felis 服务器。试试 /felis server。"
                       : "No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW));
            return;
            return false;
        }
        Optional<ServerConnection> current = player.getCurrentServer();
        if (current.isPresent() && current.get().getServerInfo().getName().equalsIgnoreCase(match.name())) {
            player.sendMessage(Component.text(
                    zh ? "你已经在「" + match.name() + "」上了。"
                       : "You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
            return;
            return false;
        }
        // Wake + park + transfer through the shared waiting queue; it reports its own
        // policy-gate (403) and transient refusals to the player.
        if (joinIfReady) {
            router.enqueueFromInvite(player, match.name());
        } else {
            router.enqueueFromCommand(player, match.name());
        }
        return true;
    }

    private void doClaim(CommandSource source) {
        Player player = requirePlayer(source);
@@ -614,6 +634,291 @@ public final class FelisVelocityPlugin {
        });
    }

    // ---- /invite (bring another player to the server you're on) ----
    //
    // /invite is a UX wrapper over `/felis go`, and nothing more: Accept runs the same doGo
    // path on the ACCEPTING player's own verified uuid, so it fills in the name of a place
    // the invitee could already reach unaided. You can only invite someone to the server you
    // are standing on, so the target is RUNNING — and a running felis server already admits
    // any linked player through <name>.<root-domain> on the link check alone (WaitingRouter
    // .onServerPreConnect). An invite therefore hands over no access the invitee lacked: at
    // worst a stale or guessed accept sends you somewhere you could have walked yourself,
    // which is why a stored invite carries only a server name and never an identity to act
    // as, and why the prompt needs no unguessable token.
    //
    // It is NOT consequence-free, though, and the inviter is told so. Landing on a felis
    // server records the player in its allowlist (WaitingRouter.onServerConnected -> the
    // join-event -> RecordJoin), and on an autostartPolicy=allowlist server that record is
    // what lets them come back later and START the thing. Same record they would earn by
    // walking in themselves, so this is not an escalation — but it outlives the invite, so
    // it belongs on screen at send time rather than in a comment only we read.
    //
    // The remaining new capability is "make a chat card appear on any online player", which
    // is rate-limited per sender by InviteBook rather than left to good manners.
    //
    // The prompt renders in the INVITEE's language (they are the one being asked) while
    // the inviter's confirmations follow theirs.

    private void registerInviteCommand() {
        CommandManager commands = proxy.getCommandManager();
        LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("invite")
                .executes(ctx -> {
                    sendInviteUsage(ctx.getSource());
                    return Command.SINGLE_SUCCESS;
                })
                // The optional <server> is what the card's buttons carry: it pins a click to
                // the invite that drew it, so an old card cannot answer a newer invite. Typed
                // bare, both still answer whatever is pending.
                .then(BrigadierCommand.literalArgumentBuilder("accept")
                        .executes(ctx -> {
                            doInviteAnswer(ctx.getSource(), true, null);
                            return Command.SINGLE_SUCCESS;
                        })
                        .then(BrigadierCommand.requiredArgumentBuilder("server", StringArgumentType.word())
                                .executes(ctx -> {
                                    doInviteAnswer(ctx.getSource(), true,
                                            StringArgumentType.getString(ctx, "server"));
                                    return Command.SINGLE_SUCCESS;
                                })))
                .then(BrigadierCommand.literalArgumentBuilder("deny")
                        .executes(ctx -> {
                            doInviteAnswer(ctx.getSource(), false, null);
                            return Command.SINGLE_SUCCESS;
                        })
                        .then(BrigadierCommand.requiredArgumentBuilder("server", StringArgumentType.word())
                                .executes(ctx -> {
                                    doInviteAnswer(ctx.getSource(), false,
                                            StringArgumentType.getString(ctx, "server"));
                                    return Command.SINGLE_SUCCESS;
                                })))
                // ponytail: Brigadier matches literals before arguments, so a player
                // actually named "accept"/"deny" cannot be invited by name. They can
                // still reach the server with /felis go, and renaming the subcommands
                // would break the click handlers for a case worth less than that.
                .then(BrigadierCommand.requiredArgumentBuilder("player", StringArgumentType.word())
                        .suggests((ctx, builder) -> {
                            // Brigadier does not filter suggestions for us: without the
                            // prefix test every keystroke re-offers the whole proxy roster.
                            String typed = builder.getRemaining().toLowerCase(Locale.ROOT);
                            UUID self = ctx.getSource() instanceof Player
                                    ? ((Player) ctx.getSource()).getUniqueId() : null;
                            proxy.getAllPlayers().stream()
                                    .filter(p -> !p.getUniqueId().equals(self))
                                    .map(Player::getUsername)
                                    .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(typed))
                                    .forEach(builder::suggest);
                            return builder.buildFuture();
                        })
                        .executes(ctx -> {
                            doInvite(ctx.getSource(), StringArgumentType.getString(ctx, "player"));
                            return Command.SINGLE_SUCCESS;
                        }))
                .build();
        CommandMeta meta = commands.metaBuilder("invite").plugin(this).build();
        commands.register(meta, new BrigadierCommand(node));
    }

    private void sendInviteUsage(CommandSource source) {
        boolean zh = zh(source);
        source.sendMessage(Component.text(zh ? "邀请玩家" : "Invite a player", NamedTextColor.AQUA));
        helpLine(source, "/invite <player>",
                zh ? "邀请一名在线玩家来你所在的服务器" : "invite an online player to the server you're on");
        helpLine(source, "/invite accept",
                zh ? "接受待处理的邀请" : "accept your pending invite");
        helpLine(source, "/invite deny",
                zh ? "拒绝待处理的邀请" : "decline your pending invite");
    }

    private void doInvite(CommandSource source, String playerArg) {
        Player inviter = requirePlayer(source);
        if (inviter == null || !ensureOutOfLimbo(inviter)) {
            return;
        }
        boolean zh = zh(inviter);
        if (!routingActive) {
            inviter.sendMessage(routingDisabled(zh));
            return;
        }
        // You can only invite someone to where you already are, so the invite names a
        // server the inviter is demonstrably on rather than any server they can spell.
        Optional<ServerConnection> current = inviter.getCurrentServer();
        if (current.isEmpty()
                || isSystemServer(current.get().getServerInfo().getName())
                || !registry.isManaged(current.get().getServerInfo().getName())) {
            inviter.sendMessage(Component.text(
                    zh ? "只能邀请别人来你所在的 felis 服务器——你现在不在这样的服务器上。"
                       : "You can only invite someone to a felis server you're on — you aren't on one.",
                    NamedTextColor.YELLOW));
            return;
        }
        String server = current.get().getServerInfo().getName();

        String target = playerArg.trim();
        Optional<Player> found = proxy.getPlayer(target);
        if (found.isEmpty()) {
            inviter.sendMessage(Component.text(
                    zh ? "「" + target + "」不在线。" : "« " + target + " » isn't online.",
                    NamedTextColor.YELLOW));
            return;
        }
        Player invitee = found.get();
        if (invitee.getUniqueId().equals(inviter.getUniqueId())) {
            inviter.sendMessage(Component.text(
                    zh ? "你不用邀请自己。" : "You don't need to invite yourself.", NamedTextColor.GRAY));
            return;
        }
        // A player still at the login gate can see the card but not answer it — doInviteAnswer's
        // own limbo guard would refuse the click. Refuse here instead, so an invite is never a
        // button that does nothing, and the inviter learns why rather than waiting for silence.
        Optional<ServerConnection> theirs = invitee.getCurrentServer();
        if (theirs.isEmpty()
                || config.loginServer().equalsIgnoreCase(theirs.get().getServerInfo().getName())) {
            inviter.sendMessage(Component.text(
                    zh ? "「" + invitee.getUsername() + "」还没完成登录,现在收不了邀请。"
                       : "« " + invitee.getUsername() + " » hasn't finished signing in yet.",
                    NamedTextColor.YELLOW));
            return;
        }
        if (theirs.get().getServerInfo().getName().equalsIgnoreCase(server)) {
            inviter.sendMessage(Component.text(
                    zh ? "「" + invitee.getUsername() + "」已经在「" + server + "」上了。"
                       : "« " + invitee.getUsername() + " » is already on « " + server + " ».",
                    NamedTextColor.GRAY));
            return;
        }

        // Last gate, so that every invite refused above stays free: the cooldown exists to
        // stop cards being sprayed at players, and a refusal sends no card.
        long now = System.currentTimeMillis();
        long wait = invites.cooldownRemaining(inviter.getUniqueId(), now);
        if (wait > 0) {
            long secs = (wait + 999) / 1000; // round up: "0 秒后再试" would be a lie
            inviter.sendMessage(Component.text(
                    zh ? "邀请发得太快了,请 " + secs + " 秒后再试。"
                       : "Too many invites — try again in " + secs + "s.",
                    NamedTextColor.YELLOW));
            return;
        }

        invites.put(invitee.getUniqueId(), inviter.getUniqueId(), server, now);
        sendInviteCard(invitee, inviter.getUsername(), server);
        inviter.sendMessage(Component.text(
                zh ? "已邀请「" + invitee.getUsername() + "」前往「" + server + "」。"
                   : "Invited « " + invitee.getUsername() + " » to « " + server + " ».",
                NamedTextColor.GREEN));
        inviter.sendMessage(accessNotice(server, zh));
    }

    /**
     * accessNotice tells the inviter what the invite costs them, because it is not nothing:
     * a player who accepts and lands on the server is written into its allowlist by the join
     * event, exactly as if they had walked in on their own.
     *
     * <p>What that record is WORTH depends on the server's autostartPolicy, so the wording
     * does too. Under {@code allowlist} it is durable authority — that row is what lets them
     * start the server themselves later — and the inviter is told plainly. Under any other
     * policy (including the {@code ownerOnly} default, and the empty string the API reports
     * when the field was never set) the row grants no waking, so claiming it did would be a
     * lie; there it says only that they were recorded.
     */
    private Component accessNotice(String server, boolean zh) {
        ServerView view = registry.view(server);
        boolean gatesOnAllowlist = view != null && "allowlist".equalsIgnoreCase(view.autostartPolicy());
        return Component.text(
                gatesOnAllowlist
                        ? (zh ? "  提示:TA 接受后会被加入「" + server + "」的白名单,之后可以自行进入并启动这台服务器。"
                              : "  Note: accepting adds them to « " + server + " »'s allowlist — they'll then be"
                                + " able to come back and start it themselves.")
                        : (zh ? "  提示:TA 接受后会被记入「" + server + "」的白名单。"
                              : "  Note: accepting records them in « " + server + " »'s allowlist."),
                NamedTextColor.GRAY);
    }

    // The card itself lives in InviteCard so the buttons — the whole point of the feature —
    // can be asserted without a live proxy. All this does is address it.
    private void sendInviteCard(Player invitee, String inviterName, String server) {
        InviteCard.lines(inviterName, server, zh(invitee), INVITE_TTL.toSeconds())
                .forEach(invitee::sendMessage);
    }

    /**
     * doInviteAnswer handles both buttons. {@code fromCard} is the server the clicked card
     * named, or null when the player typed the subcommand bare.
     */
    private void doInviteAnswer(CommandSource source, boolean accept, String fromCard) {
        Player player = requirePlayer(source);
        if (player == null || !ensureOutOfLimbo(player)) {
            return;
        }
        boolean zh = zh(player);
        long now = System.currentTimeMillis();
        InviteBook.Invite pending = invites.peek(player.getUniqueId(), now);
        if (pending == null) {
            player.sendMessage(Component.text(
                    zh ? "你没有待处理的邀请(可能已过期)。"
                       : "You have no pending invite (it may have expired).", NamedTextColor.YELLOW));
            return;
        }
        // Checked before consuming: a click on a card a later invite superseded must leave
        // the live invite alone, so the player can still answer the card that is current.
        if (fromCard != null && !fromCard.equalsIgnoreCase(pending.server())) {
            player.sendMessage(Component.text(
                    zh ? "这张邀请卡已被新的邀请取代——你当前的邀请是前往「" + pending.server() + "」。"
                       : "That invite was superseded — your pending one is to « " + pending.server() + " ».",
                    NamedTextColor.YELLOW));
            return;
        }
        // ponytail: peek-then-take is not atomic — an invite landing in that window is
        // taken instead of the one just validated. "Newest wins" is already the rule the
        // book enforces, so the outcome is one this player would have got anyway; make it
        // a computeIfPresent if invites ever arrive fast enough for anyone to notice.
        InviteBook.Invite invite = invites.take(player.getUniqueId(), now);
        if (invite == null) {
            return; // answered by a racing click; that one owns the reply
        }
        if (!accept) {
            notifyInviter(invite, player.getUsername(), Answer.DECLINED);
            player.sendMessage(Component.text(
                    zh ? "已拒绝邀请。" : "Invite declined.", NamedTextColor.GRAY));
            return;
        }
        // Accept IS `/felis go` with the name filled in — doGo re-runs every guard: routing
        // active, the server still registered and non-system, not already there. It differs
        // only in joining a server that is already up rather than asking to wake it; see
        // WaitingRouter.enqueueFromInvite for why that is the difference between a working
        // button and a 403.
        //
        // Reported to the inviter AFTER the handoff, not on the click: telling them "accepted"
        // while their guest is being turned away is worse than telling them nothing.
        notifyInviter(invite, player.getUsername(),
                doGo(player, invite.server(), true) ? Answer.ACCEPTED : Answer.FAILED);
    }

    private enum Answer { ACCEPTED, DECLINED, FAILED }

    // notifyInviter closes the loop for whoever sent the invite; without it they wait on a
    // prompt they can never see the answer to. Silently skipped if they left in the meantime.
    //
    // ponytail: ACCEPTED means the transfer was handed to the waiting queue, which is as far
    // as this can see synchronously — a wake that fails later is reported to the guest only.
    private void notifyInviter(InviteBook.Invite invite, String who, Answer answer) {
        proxy.getPlayer(invite.from()).ifPresent(p -> {
            boolean zh = zh(p);
            switch (answer) {
                case ACCEPTED -> p.sendMessage(Component.text(
                        zh ? "「" + who + "」接受了你的邀请。" : "« " + who + " » accepted your invite.",
                        NamedTextColor.GREEN));
                case DECLINED -> p.sendMessage(Component.text(
                        zh ? "「" + who + "」拒绝了你的邀请。" : "« " + who + " » declined your invite.",
                        NamedTextColor.GRAY));
                case FAILED -> p.sendMessage(Component.text(
                        zh ? "「" + who + "」接受了邀请,但没能过来。"
                           : "« " + who + " » accepted, but couldn't get through.",
                        NamedTextColor.YELLOW));
            }
        });
    }

    // ---- helpers ----

    /**
+105 −0

File added.

Preview size limit exceeded, changes collapsed.

+68 −0
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/InviteCard.java: 68 added lines, 0 removed lines.
Original line number Diff line number Diff line
package best.lolicon.felis.velocity;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;

import java.util.List;

/**
 * InviteCard builds the chat prompt an invited player sees: one line naming who wants
 * them where, then a green Accept and a red Deny that are real click-to-run commands,
 * so answering is a click rather than a command they have to retype.
 *
 * <p>It is a pure function of (inviter, server, language, ttl) and holds no Velocity
 * types, which is the point: the buttons are the whole feature, and this way
 * {@link InviteCardTest} can assert their colour and their click command without a live
 * proxy. Sending is left to the caller.
 */
final class InviteCard {

    static final String ACCEPT_COMMAND = "/invite accept";
    static final String DENY_COMMAND = "/invite deny";

    private InviteCard() {
    }

    /**
     * lines renders the prompt in the INVITEE's language — they are the one being asked.
     * The two buttons carry a hover tip as well as the click: a player who does not know
     * chat can be clicked finds out by pointing at it, and one who has clicks disabled at
     * least sees the command to type.
     */
    static List<Component> lines(String inviterName, String server, boolean zh, long ttlSeconds) {
        Component headline = Component.text(
                zh ? inviterName + " 邀请你前往「" + server + "」服务器"
                   : inviterName + " invites you to « " + server + " »",
                NamedTextColor.AQUA);

        // Each button names the server this card is advertising. Chat scrollback keeps old
        // cards clickable forever, and a newer invite replaces the pending one, so a bare
        // "/invite accept" clicked on last week's card would honour today's invite and send
        // the player somewhere they never agreed to. Naming it makes the click checkable.
        String accept = ACCEPT_COMMAND + " " + server;
        String deny = DENY_COMMAND + " " + server;
        Component buttons = Component.text("  ")
                .append(button(zh ? "[ 接受 ]" : "[ Accept ]", NamedTextColor.GREEN, accept,
                        zh ? "点击接受(或输入 " + accept + ")"
                           : "Click to accept (or type " + accept + ")"))
                .append(Component.text("   "))
                .append(button(zh ? "[ 拒绝 ]" : "[ Deny ]", NamedTextColor.RED, deny,
                        zh ? "点击拒绝(或输入 " + deny + ")"
                           : "Click to decline (or type " + deny + ")"));

        Component footer = Component.text(
                zh ? "  (" + ttlSeconds + " 秒内有效)"
                   : "  (valid for " + ttlSeconds + "s)",
                NamedTextColor.GRAY);

        return List.of(headline, buttons, footer);
    }

    private static Component button(String label, NamedTextColor colour, String command, String tip) {
        return Component.text(label, colour)
                .clickEvent(ClickEvent.runCommand(command))
                .hoverEvent(HoverEvent.showText(Component.text(tip)));
    }
}
+46 −3

File changed.

Preview size limit exceeded, changes collapsed.

+204 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading