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 cdddeff..4842673 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 @@ -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. + * + *

{@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,18 +451,23 @@ 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 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. - router.enqueueFromCommand(player, match.name()); + if (joinIfReady) { + router.enqueueFromInvite(player, match.name()); + } else { + router.enqueueFromCommand(player, match.name()); + } + return true; } private void doClaim(CommandSource 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 . 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 node = BrigadierCommand.literalArgumentBuilder("invite") + .executes(ctx -> { + sendInviteUsage(ctx.getSource()); + return Command.SINGLE_SUCCESS; + }) + // The optional 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 ", + 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 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 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 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. + * + *

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 ---- /** diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/InviteBook.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/InviteBook.java new file mode 100644 index 0000000..ba4ff8f --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/InviteBook.java @@ -0,0 +1,105 @@ +package best.lolicon.felis.velocity; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * InviteBook holds the live {@code /invite} prompts: at most one per invitee, each + * lapsing on its own deadline. It is proxy-local and deliberately unpersisted — an + * invite is a chat prompt, not state worth surviving a restart. + * + *

An entry stores only who asked and which server they were on. Nothing here is an + * authority: accepting runs the same {@code /felis go} path on the accepting player's + * own verified uuid, so the worst a lapsed-but-honoured invite could do is offer a + * player a server they were already allowed to reach. The deadline exists so a stale + * button doesn't move someone who wandered off ten minutes ago, not to protect access. + * + *

It also holds the send-side cooldown, because the one capability {@code /invite} + * genuinely adds is "make a chat card appear on any online player" — unrated, that is a + * way to follow someone around their own chat log. The stamps live here rather than in a + * second class so they are pruned by the same pass that prunes the prompts. + * + *

It takes {@code now} as a parameter rather than reading the clock itself, which is + * what lets {@link InviteBookTest} check expiry and cooldown without sleeping. No + * Velocity types appear here for the same reason. + */ +final class InviteBook { + private final Map pending = new ConcurrentHashMap<>(); + private final Map lastSent = new ConcurrentHashMap<>(); + private final long ttlMillis; + private final long cooldownMillis; + + InviteBook(long ttlMillis, long cooldownMillis) { + this.ttlMillis = ttlMillis; + this.cooldownMillis = cooldownMillis; + } + + /** + * put records a fresh prompt for invitee, replacing any prompt they had not yet + * answered — the newest invite is the one a click should honour. Lapsed entries for + * everyone else are dropped in the same pass, so a map holding one entry per player + * with a live prompt never needs a sweeper task of its own. + * + *

It also starts the sender's cooldown. Charging it HERE rather than at the top of + * the command is what keeps a refused invite free: an offline name or a player already + * on the server sends no card to anybody, so it costs the sender nothing. + */ + void put(UUID invitee, UUID from, String server, long now) { + pending.values().removeIf(i -> i.expiresAt() <= now); + pending.put(invitee, new Invite(from, server, now + ttlMillis)); + lastSent.values().removeIf(t -> t + cooldownMillis <= now); + lastSent.put(from, now); + } + + /** + * cooldownRemaining is how long the sender must still wait, in millis, or 0 when they + * may send now. + * + *

ponytail: one global stamp per sender, so inviting Alex also holds off inviting + * Steve. That is the shape that actually stops the spam — a per-(sender, invitee) key + * would let one sender paper every player on the proxy at once, which is the thing + * being rate-limited. Key it per pair only if a real group of players complains. + */ + long cooldownRemaining(UUID from, long now) { + Long last = lastSent.get(from); + if (last == null) { + return 0L; + } + long remaining = last + cooldownMillis - now; + return remaining > 0L ? remaining : 0L; + } + + /** + * take consumes the invitee's prompt and returns it, or null when they have none or + * theirs has lapsed. Consuming either way is the point: a single answer, so a + * double-click cannot queue two transfers. + */ + Invite take(UUID invitee, long now) { + Invite invite = pending.remove(invitee); + return invite == null || invite.expiresAt() <= now ? null : invite; + } + + /** + * peek reads the invitee's live prompt without consuming it, so a click can be checked + * against what is actually pending before it is spent. A card that names a superseded + * server must be refused WITHOUT burning the invite the player still holds. + */ + Invite peek(UUID invitee, long now) { + Invite invite = pending.get(invitee); + return invite == null || invite.expiresAt() <= now ? null : invite; + } + + /** size is the number of entries still held, lapsed ones included. Visible for the test. */ + int size() { + return pending.size(); + } + + /** cooldownSize is the number of send stamps still held. Visible for the test. */ + int cooldownSize() { + return lastSent.size(); + } + + /** Invite is one live prompt: who sent it, the server they were on, when it lapses. */ + record Invite(UUID from, String server, long expiresAt) {} +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/InviteCard.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/InviteCard.java new file mode 100644 index 0000000..2004c19 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/InviteCard.java @@ -0,0 +1,68 @@ +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. + * + *

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 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))); + } +} diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java index 9a29ca5..434e759 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java @@ -100,7 +100,7 @@ public final class WaitingRouter { * fire {@link MenuTransferListener} on transfer. */ void enqueueFromMenu(Player player, String serverName) { - authorizeAndWait(player, serverName, true); + authorizeAndWait(player, serverName, true, false); } /** @@ -113,7 +113,38 @@ public final class WaitingRouter { * exactly as the other origins, so this adds a new entry point, not a new authority. */ void enqueueFromCommand(Player player, String serverName) { - authorizeAndWait(player, serverName, false); + authorizeAndWait(player, serverName, false, false); + } + + /** + * enqueueFromInvite is {@link #enqueueFromCommand} for an accepted invite, differing in + * one thing: a server that is ALREADY RUNNING is joined directly instead of woken. + * + *

An invite can only name the server its sender is standing on, so the target is + * running by construction — and a running felis server is already reachable by any + * linked player through {@code .}, which + * {@link #onServerPreConnect} admits on the link check alone: no wake, no + * autostartPolicy consultation. Routing an accept through {@link #wakeAndWaitLinked} + * instead asks the API to wake a server that needs no waking, and autostartPolicy + * defaults to ownerOnly, so the API answers 403 and the invitee is turned away from a + * place they could have walked into unaided — the green button does nothing for + * exactly the people you would invite. + * + *

Joining a live backend therefore grants no authority the invitee did not already + * have. WAKING a stopped one still does, which is why the not-ready case falls through + * to the policy-gated path unchanged: only the owner may start a stopped ownerOnly + * server, invite or no invite. + * + *

It does leave a mark, though, and one that outlives the invite: landing here fires + * {@link #onServerConnected}, whose join-event appends the player to the server's + * allowlist. On an autostartPolicy=allowlist server that row is the wake permission, so + * an accepted invite ends in the invitee being able to start the server later. That is + * the same row they would have earned by walking in unaided — the invite shortened the + * walk, it did not widen the door — but it is a consequence the INVITER is warned about + * up front (FelisVelocityPlugin#accessNotice), because they are the one causing it. + */ + void enqueueFromInvite(Player player, String serverName) { + authorizeAndWait(player, serverName, false, true); } @Subscribe @@ -324,7 +355,8 @@ public final class WaitingRouter { } } - private void authorizeAndWait(Player player, String serverName, boolean fromMenu) { + private void authorizeAndWait(Player player, String serverName, boolean fromMenu, + boolean joinIfReady) { UUID id = player.getUniqueId(); boolean zh = FelisVelocityPlugin.zh(player); plugin.async(() -> { @@ -344,6 +376,17 @@ public final class WaitingRouter { NamedTextColor.RED)); return; } + // Same ready-or-wake split as the host path above, for the one caller whose + // target is running by construction. See enqueueFromInvite for why joining a + // live backend is not an escalation and waking a stopped one still is. + if (joinIfReady) { + ServerView view = registry.view(serverName); + Optional backend = registry.registered(serverName); + if (view != null && view.ready() && backend.isPresent()) { + transfer(player, serverName, backend.get()); + return; + } + } wakeAndWaitLinked(player, serverName, fromMenu); }); } diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/InviteBookTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/InviteBookTest.java new file mode 100644 index 0000000..ff23fe2 --- /dev/null +++ b/plugins/velocity/test/best/lolicon/felis/velocity/InviteBookTest.java @@ -0,0 +1,204 @@ +package best.lolicon.felis.velocity; + +import java.util.UUID; + +/** + * InviteBookTest is a hermetic, dependency-free check of the {@code /invite} prompt + * store. It lives outside {@code src/main/java} so it never ships in the plugin jar, and + * it has no test framework: a failed assertion throws and the process exits non-zero. + * + *

{@link InviteBook} is the one piece of {@code /invite} that can be checked above + * "compiles" without a live proxy — everything else is Velocity guards and chat text. + * What it asserts is what a wrong answer would cost a player: a lapsed prompt must not + * move anybody, one answer must consume the prompt so a double-click cannot queue two + * transfers, a second invite must supersede the first rather than leave two live, and + * lapsed entries for other players must not accumulate forever. The send cooldown is here + * too — it is the only limit on making a chat card appear on an arbitrary online player, + * so its boundary is checked rather than eyeballed. + * + *

Run: {@code javac -d velocity/src/main/java/best/lolicon/felis/velocity/InviteBook.java + * velocity/test/best/lolicon/felis/velocity/InviteBookTest.java && java -cp + * best.lolicon.felis.velocity.InviteBookTest}. + */ +public final class InviteBookTest { + + private static final long TTL = 120_000L; + private static final long COOLDOWN = 30_000L; + private static int checks; + + public static void main(String[] args) { + takesBackWhatWasPut(); + lapsedInviteIsNotHonoured(); + oneAnswerConsumesThePrompt(); + secondInviteSupersedesTheFirst(); + putPrunesEveryoneElsesLapsedEntries(); + strangerHasNothingToTake(); + peekLooksWithoutSpending(); + cooldownStartsOnSendAndRunsOut(); + cooldownIsPerSenderNotPerInvitee(); + spentCooldownsArePruned(); + System.out.println("InviteBookTest OK (" + checks + " checks)"); + } + + // The happy path: what the inviter offered is what the invitee gets back. + private static void takesBackWhatWasPut() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + UUID invitee = UUID.randomUUID(); + UUID from = UUID.randomUUID(); + book.put(invitee, from, "survival", 1_000L); + InviteBook.Invite got = book.take(invitee, 1_000L); + assertNotNull("invite present", got); + assertEq("from", from, got.from()); + assertEq("server", "survival", got.server()); + assertEq("expiresAt", 1_000L + TTL, got.expiresAt()); + } + + // A prompt that has run out must not move anyone, and the boundary counts as + // lapsed: at exactly expiresAt the button is dead. + private static void lapsedInviteIsNotHonoured() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + UUID invitee = UUID.randomUUID(); + book.put(invitee, UUID.randomUUID(), "survival", 0L); + assertNull("at the deadline", book.take(invitee, TTL)); + + book.put(invitee, UUID.randomUUID(), "survival", 0L); + assertNull("past the deadline", book.take(invitee, TTL + 1)); + + book.put(invitee, UUID.randomUUID(), "survival", 0L); + assertNotNull("one milli before", book.take(invitee, TTL - 1)); + } + + // Answering consumes the prompt whether or not it was honoured, so a player who + // clicks Accept twice cannot queue two transfers. + private static void oneAnswerConsumesThePrompt() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + UUID invitee = UUID.randomUUID(); + book.put(invitee, UUID.randomUUID(), "survival", 0L); + assertNotNull("first click", book.take(invitee, 1L)); + assertNull("second click", book.take(invitee, 1L)); + assertEq("nothing left held", 0, book.size()); + + // And a lapsed take clears it too, so it can't be revived by an earlier clock. + book.put(invitee, UUID.randomUUID(), "survival", 0L); + assertNull("lapsed click", book.take(invitee, TTL + 1)); + assertEq("lapsed take still consumed", 0, book.size()); + } + + // Two invites for the same player leave one live prompt — the newest — so the + // buttons in chat can't disagree about where Accept sends them. + private static void secondInviteSupersedesTheFirst() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + UUID invitee = UUID.randomUUID(); + book.put(invitee, UUID.randomUUID(), "survival", 0L); + book.put(invitee, UUID.randomUUID(), "creative", 10L); + assertEq("only one held", 1, book.size()); + InviteBook.Invite got = book.take(invitee, 10L); + assertNotNull("invite present", got); + assertEq("newest wins", "creative", got.server()); + } + + // Prompts nobody ever answers are dropped by the next put, so the map tracks live + // prompts rather than growing for the life of the proxy. + private static void putPrunesEveryoneElsesLapsedEntries() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + for (int i = 0; i < 5; i++) { + book.put(UUID.randomUUID(), UUID.randomUUID(), "survival", 0L); + } + assertEq("five held", 5, book.size()); + UUID fresh = UUID.randomUUID(); + book.put(fresh, UUID.randomUUID(), "creative", TTL + 1); + assertEq("lapsed swept, fresh kept", 1, book.size()); + assertNotNull("the fresh one survived", book.take(fresh, TTL + 1)); + } + + // Someone who was never invited has nothing to take — no entry, no crash. + private static void strangerHasNothingToTake() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + assertNull("never invited", book.take(UUID.randomUUID(), 0L)); + } + + // A click off a superseded card is checked against what is pending before it is spent, + // so peek must report the live invite without consuming it — spending it there would + // leave the player holding a card they can no longer answer. Expiry still applies. + private static void peekLooksWithoutSpending() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + UUID invitee = UUID.randomUUID(); + book.put(invitee, UUID.randomUUID(), "survival", 0L); + assertEq("peek reads it", "survival", book.peek(invitee, 1L).server()); + assertEq("peek reads it twice", "survival", book.peek(invitee, 1L).server()); + assertNotNull("and it is still spendable", book.take(invitee, 1L)); + assertNull("gone once spent", book.peek(invitee, 1L)); + + book.put(invitee, UUID.randomUUID(), "survival", 0L); + assertNull("lapsed is not visible", book.peek(invitee, TTL + 1)); + assertNull("stranger has nothing to peek at", book.peek(UUID.randomUUID(), 0L)); + } + + // The cooldown is the only thing standing between /invite and "make a chat card appear + // on any player, repeatedly", so the boundary is asserted rather than assumed: it must + // be charged by the send, must actually expire, and must not fire one tick early. + private static void cooldownStartsOnSendAndRunsOut() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + UUID from = UUID.randomUUID(); + assertEq("a sender who never sent is free", 0L, book.cooldownRemaining(from, 0L)); + + book.put(UUID.randomUUID(), from, "survival", 0L); + assertEq("charged in full on send", COOLDOWN, book.cooldownRemaining(from, 0L)); + assertEq("counts down", 1L, book.cooldownRemaining(from, COOLDOWN - 1)); + assertEq("free at the deadline", 0L, book.cooldownRemaining(from, COOLDOWN)); + assertEq("and stays free after", 0L, book.cooldownRemaining(from, COOLDOWN + 5_000)); + + // Sending again re-arms it, so it is a rate limit and not a one-off toll. + book.put(UUID.randomUUID(), from, "survival", COOLDOWN); + assertEq("re-armed by the next send", COOLDOWN, book.cooldownRemaining(from, COOLDOWN)); + } + + // Deliberately global per sender: the abuse being stopped is one player papering the + // whole proxy, which a per-(sender, invitee) key would wave straight through. One + // sender's cooldown must not touch anybody else's. + private static void cooldownIsPerSenderNotPerInvitee() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + UUID alex = UUID.randomUUID(); + UUID steve = UUID.randomUUID(); + book.put(UUID.randomUUID(), alex, "survival", 0L); + assertEq("held off for a different invitee too", COOLDOWN, book.cooldownRemaining(alex, 0L)); + assertEq("someone else is unaffected", 0L, book.cooldownRemaining(steve, 0L)); + } + + // Stamps are swept by the next send like the prompts are, so the map tracks senders on + // cooldown rather than every sender the proxy has ever seen. + private static void spentCooldownsArePruned() { + InviteBook book = new InviteBook(TTL, COOLDOWN); + for (int i = 0; i < 5; i++) { + book.put(UUID.randomUUID(), UUID.randomUUID(), "survival", 0L); + } + assertEq("five stamps held", 5, book.cooldownSize()); + UUID fresh = UUID.randomUUID(); + book.put(UUID.randomUUID(), fresh, "creative", COOLDOWN); + assertEq("spent stamps swept, the new one kept", 1, book.cooldownSize()); + assertEq("and it is the live one", COOLDOWN, book.cooldownRemaining(fresh, COOLDOWN)); + } + + // ---- harness ---- + + private static void assertEq(String what, Object want, Object got) { + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + " = " + got + ", want " + want); + } + checks++; + } + + private static void assertNull(String what, Object got) { + if (got != null) { + throw new AssertionError(what + " = " + got + ", want null"); + } + checks++; + } + + private static void assertNotNull(String what, Object got) { + if (got == null) { + throw new AssertionError(what + " = null, want an invite"); + } + checks++; + } +} diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/InviteCardTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/InviteCardTest.java new file mode 100644 index 0000000..b6553d4 --- /dev/null +++ b/plugins/velocity/test/best/lolicon/felis/velocity/InviteCardTest.java @@ -0,0 +1,178 @@ +package best.lolicon.felis.velocity; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; + +import java.util.List; + +/** + * InviteCardTest checks the one thing the /invite feature was actually asked for: a chat + * prompt naming who invites you where, a GREEN clickable Accept and a RED clickable Deny. + * Colour and click target are the requirement, so they are asserted rather than eyeballed + * — a proxy console cannot render either, and a screenshot is not a check. + * + *

Hermetic and framework-free like {@link InviteBookTest}: it lives outside + * {@code src/main/java} so it never ships in the plugin jar, needs only adventure-api on + * the classpath, and a failed assertion throws so the process exits non-zero. + * + *

Run: {@code javac -cp -d + * velocity/src/main/java/best/lolicon/felis/velocity/InviteCard.java + * velocity/test/best/lolicon/felis/velocity/InviteCardTest.java && java -cp + * ;;; + * best.lolicon.felis.velocity.InviteCardTest}. + */ +public final class InviteCardTest { + + private static int checks; + + public static void main(String[] args) { + cardNamesWhoAndWhere(); + acceptIsGreenAndRunsAccept(); + denyIsRedAndRunsDeny(); + buttonsCarryAHoverTip(); + footerStatesTheDeadline(); + englishCardIsTheSameShape(); + buttonsArePinnedToTheirOwnServer(); + System.out.println("InviteCardTest OK (" + checks + " checks)"); + } + + // The headline has to answer "who wants me, and where" — both names, in the + // invitee's language. + private static void cardNamesWhoAndWhere() { + List card = InviteCard.lines("Steve", "survival", true, 120); + assertEq("three lines", 3, card.size()); + String head = plain(card.get(0)); + assertTrue("names the inviter", head.contains("Steve")); + assertTrue("names the server", head.contains("survival")); + assertEq("headline colour", NamedTextColor.AQUA, card.get(0).color()); + } + + // Green, clickable, and pointed at the command that actually accepts. A button of the + // right colour wired to the wrong command is the failure this catches. + private static void acceptIsGreenAndRunsAccept() { + Component accept = button(InviteCard.lines("Steve", "survival", true, 120), 0); + assertEq("accept is green", NamedTextColor.GREEN, accept.color()); + assertTrue("accept is labelled", plain(accept).contains("接受")); + ClickEvent click = accept.clickEvent(); + assertNotNull("accept is clickable", click); + assertEq("accept runs a command", ClickEvent.Action.RUN_COMMAND, click.action()); + // Compared whole rather than by the getter: ClickEvent#value() is deprecated in + // adventure 4.26, and equality covers action and payload in one assertion. + assertEq("accept target", ClickEvent.runCommand("/invite accept survival"), click); + } + + private static void denyIsRedAndRunsDeny() { + Component deny = button(InviteCard.lines("Steve", "survival", true, 120), 2); + assertEq("deny is red", NamedTextColor.RED, deny.color()); + assertTrue("deny is labelled", plain(deny).contains("拒绝")); + ClickEvent click = deny.clickEvent(); + assertNotNull("deny is clickable", click); + assertEq("deny runs a command", ClickEvent.Action.RUN_COMMAND, click.action()); + assertEq("deny target", ClickEvent.runCommand("/invite deny survival"), click); + } + + // A player whose client has chat clicks disabled still needs a way in, so the hover + // spells out the command to type. + private static void buttonsCarryAHoverTip() { + List card = InviteCard.lines("Steve", "survival", false, 120); + assertNotNull("accept has a tip", button(card, 0).hoverEvent()); + assertNotNull("deny has a tip", button(card, 2).hoverEvent()); + assertTrue("tip spells the command", + plainDeep(button(card, 0).hoverEvent().value()).contains("/invite accept survival")); + } + + private static void footerStatesTheDeadline() { + String footer = plain(InviteCard.lines("Steve", "survival", true, 120).get(2)); + assertTrue("footer states the ttl", footer.contains("120")); + assertEq("footer colour", NamedTextColor.GRAY, + InviteCard.lines("Steve", "survival", true, 120).get(2).color()); + } + + // The English card is not a second implementation: same three lines, same colours, + // same click targets, only the words change. + private static void englishCardIsTheSameShape() { + List card = InviteCard.lines("Alex", "creative", false, 90); + assertEq("three lines", 3, card.size()); + assertTrue("names the inviter", plain(card.get(0)).contains("Alex")); + assertTrue("names the server", plain(card.get(0)).contains("creative")); + assertEq("accept is green", NamedTextColor.GREEN, button(card, 0).color()); + assertEq("accept target", ClickEvent.runCommand("/invite accept creative"), button(card, 0).clickEvent()); + assertEq("deny is red", NamedTextColor.RED, button(card, 2).color()); + assertEq("deny target", ClickEvent.runCommand("/invite deny creative"), button(card, 2).clickEvent()); + assertTrue("english labels", plain(button(card, 0)).contains("Accept")); + assertTrue("english labels", plain(button(card, 2)).contains("Deny")); + assertTrue("footer states the ttl", plain(card.get(2)).contains("90")); + } + + // Chat scrollback keeps every card clickable forever while only the newest invite is + // live, so two cards MUST NOT click to the same command — otherwise last week's button + // silently answers today's invite and sends the player to a server they never agreed + // to. Different server in, different click target out. + private static void buttonsArePinnedToTheirOwnServer() { + List old = InviteCard.lines("Steve", "survival", true, 120); + List fresh = InviteCard.lines("Alex", "creative", true, 120); + assertTrue("accept targets differ per server", + !button(old, 0).clickEvent().equals(button(fresh, 0).clickEvent())); + assertTrue("deny targets differ per server", + !button(old, 2).clickEvent().equals(button(fresh, 2).clickEvent())); + assertEq("the stale card still names its own server", + ClickEvent.runCommand("/invite accept survival"), button(old, 0).clickEvent()); + } + + // ---- harness ---- + + /** button pulls the nth child off the button row: 0 = Accept, 1 = spacer, 2 = Deny. */ + private static Component button(List card, int index) { + List row = card.get(1).children(); + assertEq("button row shape", 3, row.size()); + return row.get(index); + } + + private static String plain(Component c) { + return c instanceof TextComponent ? ((TextComponent) c).content() : c.toString(); + } + + /** plainDeep flattens a component and its children — hover text may be nested. */ + private static String plainDeep(Object value) { + if (!(value instanceof Component)) { + return String.valueOf(value); + } + Component c = (Component) value; + StringBuilder sb = new StringBuilder(plain(c)); + for (Component child : c.children()) { + sb.append(plainDeep(child)); + } + return sb.toString(); + } + + private static void assertEq(String what, Object want, Object got) { + if (want instanceof TextColor && got instanceof TextColor) { + if (((TextColor) want).value() != ((TextColor) got).value()) { + throw new AssertionError(what + " = " + got + ", want " + want); + } + checks++; + return; + } + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + " = " + got + ", want " + want); + } + checks++; + } + + private static void assertTrue(String what, boolean got) { + if (!got) { + throw new AssertionError(what + " = false, want true"); + } + checks++; + } + + private static void assertNotNull(String what, Object got) { + if (got == null) { + throw new AssertionError(what + " = null, want a value"); + } + checks++; + } +}