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++;
+ }
+}