feat(invite): let a player bring a friend to the server they're on
/invite <player> posts a chat card to the invitee with a green [Accept] and a red [Deny] button, and Accept walks them to the server the inviter is standing on. It is a UX wrapper over `/felis go` and nothing more: the accept runs the same doGo path on the ACCEPTING player's own verified uuid, so a stored invite carries a server name and never an identity to act as, and the prompt needs no unguessable token. Why it can be this simple: an invite can only name the server its sender is currently on, so the target is running by construction, and a running felis server already admits any linked player through <name>.<root-domain> on the link check alone (WaitingRouter.onServerPreConnect) — no wake, no autostartPolicy consultation. Hence enqueueFromInvite: a READY backend is joined directly, and only the not-ready case falls through to the policy-gated wake path unchanged. Routing an accept through wakeAndWaitLinked would have asked the API to wake a server that needs no waking, and autostartPolicy defaults to ownerOnly, so the API would answer 403 and the green button would do nothing for exactly the people you would invite. It is not consequence-free, and the inviter is told so at send time rather than in a comment only we read. Landing on a felis server records the player in its allowlist (onServerConnected -> join-event -> RecordJoin); on an autostartPolicy=allowlist server that row is what lets them come back and START the thing later. The same row they would earn by walking in unaided — the invite shortened the walk, it did not widen the door — but it outlives the invite, so accessNotice says so. The wording follows the policy: only under allowlist does it claim they will be able to start the server themselves, because under the ownerOnly default (and the empty string the API reports for an unset field) that row grants no waking and the claim would be a lie. InviteBook also holds a 30s per-sender cooldown, because the one capability /invite genuinely adds is "make a chat card appear on any online player", and unrated that is a way to follow someone around their own chat log. It is charged in put() rather than at the top of the command, so an invite refused for an offline name or a player already on the server costs the sender nothing, and the gate sits after every other validation for the same reason. The stamp is global per sender on purpose: a per-(sender, invitee) key would wave through one player papering the whole proxy, which is the thing being limited. The card lives in InviteCard as a pure function so the buttons — the whole point of the feature — can be asserted without a live proxy, and the button clicks are pinned to the server the card named, so a stale card cannot answer a newer invite (checked with peek before the invite is spent, so refusing a superseded card leaves the live one answerable). Verified: production gradle 8.14 + JDK 21 build; jar carries the plugin classes and no test classes; InviteBookTest (36 checks) and InviteCardTest (48 checks); and a live Velocity 3.5.1 that loads the jar, registers /invite <player> and answers /invite accept <server>, with a malformed subcommand as a negative control.
This commit is contained in:
6 files changed
+914
-11
No files matched your search
+313
-8
@@ -68,10 +68,16 @@ import java.util.regex.Pattern;
|
||||
public final class FelisVelocityPlugin {
|
||||
private static final Duration REGISTRATION_REFRESH = Duration.ofSeconds(15);
|
||||
private static final Duration WAIT_POLL = Duration.ofSeconds(2);
|
||||
private static final Duration INVITE_TTL = Duration.ofSeconds(120);
|
||||
// Long enough that spraying cards at a room is tedious, short enough that showing three
|
||||
// friends around one after another is not. Sub-TTL on purpose: a sender may hold several
|
||||
// live invites, they just cannot post them all in one breath.
|
||||
private static final Duration INVITE_COOLDOWN = Duration.ofSeconds(30);
|
||||
|
||||
private final ProxyServer proxy;
|
||||
private final Logger logger;
|
||||
private final Path dataDirectory;
|
||||
private final InviteBook invites = new InviteBook(INVITE_TTL.toMillis(), INVITE_COOLDOWN.toMillis());
|
||||
|
||||
private FelisVelocityConfig config;
|
||||
private LinkClient linkClient;
|
||||
@@ -99,6 +105,7 @@ public final class FelisVelocityPlugin {
|
||||
this.linkClient = new LinkClient(config.linkConfig());
|
||||
registerLinkCommand();
|
||||
registerFelisCommand();
|
||||
registerInviteCommand();
|
||||
|
||||
this.onlineMode = proxy.getConfiguration().isOnlineMode();
|
||||
if (!onlineMode) {
|
||||
@@ -136,7 +143,7 @@ public final class FelisVelocityPlugin {
|
||||
repeating(WAIT_POLL, router::tick);
|
||||
|
||||
this.routingActive = true;
|
||||
logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link and /felis registered.",
|
||||
logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link, /felis and /invite registered.",
|
||||
config.rootDomain(), config.loginServer(), config.lobbyServer());
|
||||
}
|
||||
|
||||
@@ -267,7 +274,7 @@ public final class FelisVelocityPlugin {
|
||||
.then(BrigadierCommand.literalArgumentBuilder("go")
|
||||
.then(BrigadierCommand.requiredArgumentBuilder("server", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
doGo(ctx.getSource(), StringArgumentType.getString(ctx, "server"));
|
||||
doGo(ctx.getSource(), StringArgumentType.getString(ctx, "server"), false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})))
|
||||
.then(BrigadierCommand.literalArgumentBuilder("claim")
|
||||
@@ -414,15 +421,23 @@ public final class FelisVelocityPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private void doGo(CommandSource source, String serverArg) {
|
||||
/**
|
||||
* doGo parks the caller on the server they named. Returns whether the request reached
|
||||
* the waiting queue — false means a guard refused it and told the player why, which is
|
||||
* what {@link #doInviteAnswer} reports back to the inviter instead of guessing.
|
||||
*
|
||||
* <p>{@code joinIfReady} is set only by an accepted invite: see
|
||||
* {@link WaitingRouter#enqueueFromInvite}.
|
||||
*/
|
||||
private boolean doGo(CommandSource source, String serverArg, boolean joinIfReady) {
|
||||
Player player = requirePlayer(source);
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
boolean zh = zh(player);
|
||||
if (!routingActive) {
|
||||
player.sendMessage(routingDisabled(zh));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
String target = serverArg.trim();
|
||||
ServerView match = null;
|
||||
@@ -436,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<ServerConnection> current = player.getCurrentServer();
|
||||
if (current.isPresent() && current.get().getServerInfo().getName().equalsIgnoreCase(match.name())) {
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "你已经在「" + match.name() + "」上了。"
|
||||
: "You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
// Wake + park + transfer through the shared waiting queue; it reports its own
|
||||
// policy-gate (403) and transient refusals to the player.
|
||||
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 <name>.<root-domain> on the link check alone (WaitingRouter
|
||||
// .onServerPreConnect). An invite therefore hands over no access the invitee lacked: at
|
||||
// worst a stale or guessed accept sends you somewhere you could have walked yourself,
|
||||
// which is why a stored invite carries only a server name and never an identity to act
|
||||
// as, and why the prompt needs no unguessable token.
|
||||
//
|
||||
// It is NOT consequence-free, though, and the inviter is told so. Landing on a felis
|
||||
// server records the player in its allowlist (WaitingRouter.onServerConnected -> the
|
||||
// join-event -> RecordJoin), and on an autostartPolicy=allowlist server that record is
|
||||
// what lets them come back later and START the thing. Same record they would earn by
|
||||
// walking in themselves, so this is not an escalation — but it outlives the invite, so
|
||||
// it belongs on screen at send time rather than in a comment only we read.
|
||||
//
|
||||
// The remaining new capability is "make a chat card appear on any online player", which
|
||||
// is rate-limited per sender by InviteBook rather than left to good manners.
|
||||
//
|
||||
// The prompt renders in the INVITEE's language (they are the one being asked) while
|
||||
// the inviter's confirmations follow theirs.
|
||||
|
||||
private void registerInviteCommand() {
|
||||
CommandManager commands = proxy.getCommandManager();
|
||||
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("invite")
|
||||
.executes(ctx -> {
|
||||
sendInviteUsage(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})
|
||||
// The optional <server> is what the card's buttons carry: it pins a click to
|
||||
// the invite that drew it, so an old card cannot answer a newer invite. Typed
|
||||
// bare, both still answer whatever is pending.
|
||||
.then(BrigadierCommand.literalArgumentBuilder("accept")
|
||||
.executes(ctx -> {
|
||||
doInviteAnswer(ctx.getSource(), true, null);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})
|
||||
.then(BrigadierCommand.requiredArgumentBuilder("server", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
doInviteAnswer(ctx.getSource(), true,
|
||||
StringArgumentType.getString(ctx, "server"));
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})))
|
||||
.then(BrigadierCommand.literalArgumentBuilder("deny")
|
||||
.executes(ctx -> {
|
||||
doInviteAnswer(ctx.getSource(), false, null);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})
|
||||
.then(BrigadierCommand.requiredArgumentBuilder("server", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
doInviteAnswer(ctx.getSource(), false,
|
||||
StringArgumentType.getString(ctx, "server"));
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})))
|
||||
// ponytail: Brigadier matches literals before arguments, so a player
|
||||
// actually named "accept"/"deny" cannot be invited by name. They can
|
||||
// still reach the server with /felis go, and renaming the subcommands
|
||||
// would break the click handlers for a case worth less than that.
|
||||
.then(BrigadierCommand.requiredArgumentBuilder("player", StringArgumentType.word())
|
||||
.suggests((ctx, builder) -> {
|
||||
// Brigadier does not filter suggestions for us: without the
|
||||
// prefix test every keystroke re-offers the whole proxy roster.
|
||||
String typed = builder.getRemaining().toLowerCase(Locale.ROOT);
|
||||
UUID self = ctx.getSource() instanceof Player
|
||||
? ((Player) ctx.getSource()).getUniqueId() : null;
|
||||
proxy.getAllPlayers().stream()
|
||||
.filter(p -> !p.getUniqueId().equals(self))
|
||||
.map(Player::getUsername)
|
||||
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(typed))
|
||||
.forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.executes(ctx -> {
|
||||
doInvite(ctx.getSource(), StringArgumentType.getString(ctx, "player"));
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))
|
||||
.build();
|
||||
CommandMeta meta = commands.metaBuilder("invite").plugin(this).build();
|
||||
commands.register(meta, new BrigadierCommand(node));
|
||||
}
|
||||
|
||||
private void sendInviteUsage(CommandSource source) {
|
||||
boolean zh = zh(source);
|
||||
source.sendMessage(Component.text(zh ? "邀请玩家" : "Invite a player", NamedTextColor.AQUA));
|
||||
helpLine(source, "/invite <player>",
|
||||
zh ? "邀请一名在线玩家来你所在的服务器" : "invite an online player to the server you're on");
|
||||
helpLine(source, "/invite accept",
|
||||
zh ? "接受待处理的邀请" : "accept your pending invite");
|
||||
helpLine(source, "/invite deny",
|
||||
zh ? "拒绝待处理的邀请" : "decline your pending invite");
|
||||
}
|
||||
|
||||
private void doInvite(CommandSource source, String playerArg) {
|
||||
Player inviter = requirePlayer(source);
|
||||
if (inviter == null || !ensureOutOfLimbo(inviter)) {
|
||||
return;
|
||||
}
|
||||
boolean zh = zh(inviter);
|
||||
if (!routingActive) {
|
||||
inviter.sendMessage(routingDisabled(zh));
|
||||
return;
|
||||
}
|
||||
// You can only invite someone to where you already are, so the invite names a
|
||||
// server the inviter is demonstrably on rather than any server they can spell.
|
||||
Optional<ServerConnection> current = inviter.getCurrentServer();
|
||||
if (current.isEmpty()
|
||||
|| isSystemServer(current.get().getServerInfo().getName())
|
||||
|| !registry.isManaged(current.get().getServerInfo().getName())) {
|
||||
inviter.sendMessage(Component.text(
|
||||
zh ? "只能邀请别人来你所在的 felis 服务器——你现在不在这样的服务器上。"
|
||||
: "You can only invite someone to a felis server you're on — you aren't on one.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
String server = current.get().getServerInfo().getName();
|
||||
|
||||
String target = playerArg.trim();
|
||||
Optional<Player> found = proxy.getPlayer(target);
|
||||
if (found.isEmpty()) {
|
||||
inviter.sendMessage(Component.text(
|
||||
zh ? "「" + target + "」不在线。" : "« " + target + " » isn't online.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
Player invitee = found.get();
|
||||
if (invitee.getUniqueId().equals(inviter.getUniqueId())) {
|
||||
inviter.sendMessage(Component.text(
|
||||
zh ? "你不用邀请自己。" : "You don't need to invite yourself.", NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
// A player still at the login gate can see the card but not answer it — doInviteAnswer's
|
||||
// own limbo guard would refuse the click. Refuse here instead, so an invite is never a
|
||||
// button that does nothing, and the inviter learns why rather than waiting for silence.
|
||||
Optional<ServerConnection> theirs = invitee.getCurrentServer();
|
||||
if (theirs.isEmpty()
|
||||
|| config.loginServer().equalsIgnoreCase(theirs.get().getServerInfo().getName())) {
|
||||
inviter.sendMessage(Component.text(
|
||||
zh ? "「" + invitee.getUsername() + "」还没完成登录,现在收不了邀请。"
|
||||
: "« " + invitee.getUsername() + " » hasn't finished signing in yet.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
if (theirs.get().getServerInfo().getName().equalsIgnoreCase(server)) {
|
||||
inviter.sendMessage(Component.text(
|
||||
zh ? "「" + invitee.getUsername() + "」已经在「" + server + "」上了。"
|
||||
: "« " + invitee.getUsername() + " » is already on « " + server + " ».",
|
||||
NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
|
||||
// Last gate, so that every invite refused above stays free: the cooldown exists to
|
||||
// stop cards being sprayed at players, and a refusal sends no card.
|
||||
long now = System.currentTimeMillis();
|
||||
long wait = invites.cooldownRemaining(inviter.getUniqueId(), now);
|
||||
if (wait > 0) {
|
||||
long secs = (wait + 999) / 1000; // round up: "0 秒后再试" would be a lie
|
||||
inviter.sendMessage(Component.text(
|
||||
zh ? "邀请发得太快了,请 " + secs + " 秒后再试。"
|
||||
: "Too many invites — try again in " + secs + "s.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
|
||||
invites.put(invitee.getUniqueId(), inviter.getUniqueId(), server, now);
|
||||
sendInviteCard(invitee, inviter.getUsername(), server);
|
||||
inviter.sendMessage(Component.text(
|
||||
zh ? "已邀请「" + invitee.getUsername() + "」前往「" + server + "」。"
|
||||
: "Invited « " + invitee.getUsername() + " » to « " + server + " ».",
|
||||
NamedTextColor.GREEN));
|
||||
inviter.sendMessage(accessNotice(server, zh));
|
||||
}
|
||||
|
||||
/**
|
||||
* accessNotice tells the inviter what the invite costs them, because it is not nothing:
|
||||
* a player who accepts and lands on the server is written into its allowlist by the join
|
||||
* event, exactly as if they had walked in on their own.
|
||||
*
|
||||
* <p>What that record is WORTH depends on the server's autostartPolicy, so the wording
|
||||
* does too. Under {@code allowlist} it is durable authority — that row is what lets them
|
||||
* start the server themselves later — and the inviter is told plainly. Under any other
|
||||
* policy (including the {@code ownerOnly} default, and the empty string the API reports
|
||||
* when the field was never set) the row grants no waking, so claiming it did would be a
|
||||
* lie; there it says only that they were recorded.
|
||||
*/
|
||||
private Component accessNotice(String server, boolean zh) {
|
||||
ServerView view = registry.view(server);
|
||||
boolean gatesOnAllowlist = view != null && "allowlist".equalsIgnoreCase(view.autostartPolicy());
|
||||
return Component.text(
|
||||
gatesOnAllowlist
|
||||
? (zh ? " 提示:TA 接受后会被加入「" + server + "」的白名单,之后可以自行进入并启动这台服务器。"
|
||||
: " Note: accepting adds them to « " + server + " »'s allowlist — they'll then be"
|
||||
+ " able to come back and start it themselves.")
|
||||
: (zh ? " 提示:TA 接受后会被记入「" + server + "」的白名单。"
|
||||
: " Note: accepting records them in « " + server + " »'s allowlist."),
|
||||
NamedTextColor.GRAY);
|
||||
}
|
||||
|
||||
// The card itself lives in InviteCard so the buttons — the whole point of the feature —
|
||||
// can be asserted without a live proxy. All this does is address it.
|
||||
private void sendInviteCard(Player invitee, String inviterName, String server) {
|
||||
InviteCard.lines(inviterName, server, zh(invitee), INVITE_TTL.toSeconds())
|
||||
.forEach(invitee::sendMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* doInviteAnswer handles both buttons. {@code fromCard} is the server the clicked card
|
||||
* named, or null when the player typed the subcommand bare.
|
||||
*/
|
||||
private void doInviteAnswer(CommandSource source, boolean accept, String fromCard) {
|
||||
Player player = requirePlayer(source);
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
}
|
||||
boolean zh = zh(player);
|
||||
long now = System.currentTimeMillis();
|
||||
InviteBook.Invite pending = invites.peek(player.getUniqueId(), now);
|
||||
if (pending == null) {
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "你没有待处理的邀请(可能已过期)。"
|
||||
: "You have no pending invite (it may have expired).", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
// Checked before consuming: a click on a card a later invite superseded must leave
|
||||
// the live invite alone, so the player can still answer the card that is current.
|
||||
if (fromCard != null && !fromCard.equalsIgnoreCase(pending.server())) {
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "这张邀请卡已被新的邀请取代——你当前的邀请是前往「" + pending.server() + "」。"
|
||||
: "That invite was superseded — your pending one is to « " + pending.server() + " ».",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
// ponytail: peek-then-take is not atomic — an invite landing in that window is
|
||||
// taken instead of the one just validated. "Newest wins" is already the rule the
|
||||
// book enforces, so the outcome is one this player would have got anyway; make it
|
||||
// a computeIfPresent if invites ever arrive fast enough for anyone to notice.
|
||||
InviteBook.Invite invite = invites.take(player.getUniqueId(), now);
|
||||
if (invite == null) {
|
||||
return; // answered by a racing click; that one owns the reply
|
||||
}
|
||||
if (!accept) {
|
||||
notifyInviter(invite, player.getUsername(), Answer.DECLINED);
|
||||
player.sendMessage(Component.text(
|
||||
zh ? "已拒绝邀请。" : "Invite declined.", NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
// Accept IS `/felis go` with the name filled in — doGo re-runs every guard: routing
|
||||
// active, the server still registered and non-system, not already there. It differs
|
||||
// only in joining a server that is already up rather than asking to wake it; see
|
||||
// WaitingRouter.enqueueFromInvite for why that is the difference between a working
|
||||
// button and a 403.
|
||||
//
|
||||
// Reported to the inviter AFTER the handoff, not on the click: telling them "accepted"
|
||||
// while their guest is being turned away is worse than telling them nothing.
|
||||
notifyInviter(invite, player.getUsername(),
|
||||
doGo(player, invite.server(), true) ? Answer.ACCEPTED : Answer.FAILED);
|
||||
}
|
||||
|
||||
private enum Answer { ACCEPTED, DECLINED, FAILED }
|
||||
|
||||
// notifyInviter closes the loop for whoever sent the invite; without it they wait on a
|
||||
// prompt they can never see the answer to. Silently skipped if they left in the meantime.
|
||||
//
|
||||
// ponytail: ACCEPTED means the transfer was handed to the waiting queue, which is as far
|
||||
// as this can see synchronously — a wake that fails later is reported to the guest only.
|
||||
private void notifyInviter(InviteBook.Invite invite, String who, Answer answer) {
|
||||
proxy.getPlayer(invite.from()).ifPresent(p -> {
|
||||
boolean zh = zh(p);
|
||||
switch (answer) {
|
||||
case ACCEPTED -> p.sendMessage(Component.text(
|
||||
zh ? "「" + who + "」接受了你的邀请。" : "« " + who + " » accepted your invite.",
|
||||
NamedTextColor.GREEN));
|
||||
case DECLINED -> p.sendMessage(Component.text(
|
||||
zh ? "「" + who + "」拒绝了你的邀请。" : "« " + who + " » declined your invite.",
|
||||
NamedTextColor.GRAY));
|
||||
case FAILED -> p.sendMessage(Component.text(
|
||||
zh ? "「" + who + "」接受了邀请,但没能过来。"
|
||||
: "« " + who + " » accepted, but couldn't get through.",
|
||||
NamedTextColor.YELLOW));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<UUID, Invite> pending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> 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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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) {}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>It is a pure function of (inviter, server, language, ttl) and holds no Velocity
|
||||
* types, which is the point: the buttons are the whole feature, and this way
|
||||
* {@link InviteCardTest} can assert their colour and their click command without a live
|
||||
* proxy. Sending is left to the caller.
|
||||
*/
|
||||
final class InviteCard {
|
||||
|
||||
static final String ACCEPT_COMMAND = "/invite accept";
|
||||
static final String DENY_COMMAND = "/invite deny";
|
||||
|
||||
private InviteCard() {
|
||||
}
|
||||
|
||||
/**
|
||||
* lines renders the prompt in the INVITEE's language — they are the one being asked.
|
||||
* The two buttons carry a hover tip as well as the click: a player who does not know
|
||||
* chat can be clicked finds out by pointing at it, and one who has clicks disabled at
|
||||
* least sees the command to type.
|
||||
*/
|
||||
static List<Component> lines(String inviterName, String server, boolean zh, long ttlSeconds) {
|
||||
Component headline = Component.text(
|
||||
zh ? inviterName + " 邀请你前往「" + server + "」服务器"
|
||||
: inviterName + " invites you to « " + server + " »",
|
||||
NamedTextColor.AQUA);
|
||||
|
||||
// Each button names the server this card is advertising. Chat scrollback keeps old
|
||||
// cards clickable forever, and a newer invite replaces the pending one, so a bare
|
||||
// "/invite accept" clicked on last week's card would honour today's invite and send
|
||||
// the player somewhere they never agreed to. Naming it makes the click checkable.
|
||||
String accept = ACCEPT_COMMAND + " " + server;
|
||||
String deny = DENY_COMMAND + " " + server;
|
||||
Component buttons = Component.text(" ")
|
||||
.append(button(zh ? "[ 接受 ]" : "[ Accept ]", NamedTextColor.GREEN, accept,
|
||||
zh ? "点击接受(或输入 " + accept + ")"
|
||||
: "Click to accept (or type " + accept + ")"))
|
||||
.append(Component.text(" "))
|
||||
.append(button(zh ? "[ 拒绝 ]" : "[ Deny ]", NamedTextColor.RED, deny,
|
||||
zh ? "点击拒绝(或输入 " + deny + ")"
|
||||
: "Click to decline (or type " + deny + ")"));
|
||||
|
||||
Component footer = Component.text(
|
||||
zh ? " (" + ttlSeconds + " 秒内有效)"
|
||||
: " (valid for " + ttlSeconds + "s)",
|
||||
NamedTextColor.GRAY);
|
||||
|
||||
return List.of(headline, buttons, footer);
|
||||
}
|
||||
|
||||
private static Component button(String label, NamedTextColor colour, String command, String tip) {
|
||||
return Component.text(label, colour)
|
||||
.clickEvent(ClickEvent.runCommand(command))
|
||||
.hoverEvent(HoverEvent.showText(Component.text(tip)));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 <name>.<root-domain>}, 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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<RegisteredServer> backend = registry.registered(serverName);
|
||||
if (view != null && view.ready() && backend.isPresent()) {
|
||||
transfer(player, serverName, backend.get());
|
||||
return;
|
||||
}
|
||||
}
|
||||
wakeAndWaitLinked(player, serverName, fromMenu);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>{@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.
|
||||
*
|
||||
* <p>Run: {@code javac -d <out> velocity/src/main/java/best/lolicon/felis/velocity/InviteBook.java
|
||||
* velocity/test/best/lolicon/felis/velocity/InviteBookTest.java && java -cp <out>
|
||||
* 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++;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Run: {@code javac -cp <adventure-api.jar> -d <out>
|
||||
* velocity/src/main/java/best/lolicon/felis/velocity/InviteCard.java
|
||||
* velocity/test/best/lolicon/felis/velocity/InviteCardTest.java && java -cp
|
||||
* <out>;<adventure-api.jar>;<adventure-key.jar>;<examination-api.jar>
|
||||
* 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<Component> 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<Component> 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<Component> 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<Component> old = InviteCard.lines("Steve", "survival", true, 120);
|
||||
List<Component> 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<Component> card, int index) {
|
||||
List<Component> 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++;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user