fix(velocity): 目标服拒绝玩家时带原因提示并直接放行到大厅,不再在 login 里循环

This commit is contained in:
Lemon-miaow committed 2026-09-26 21:28:19 +08:00
1 parent 4afabc390d
commit df8b09788c
3 files changed
+129 -15

No files matched your search

@@ -10,6 +10,7 @@ import com.velocitypowered.api.event.connection.DisconnectEvent;
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
import com.velocitypowered.api.event.player.ServerConnectedEvent;
import com.velocitypowered.api.event.player.ServerPreConnectEvent;
import com.velocitypowered.api.proxy.ConnectionRequestBuilder;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
@@ -120,10 +121,46 @@ public final class WaitingRouter {
player.createConnectionRequest(lobby.get()).connect().whenComplete((result, err) -> {
if (err != null) {
log.warn("Felis: login release for {} failed: {}", player.getUniqueId(), err.toString());
return;
}
if (result != null && !result.isSuccessful()) {
releaseRefused(player, result);
}
});
}
/**
* releaseRefused handles a login release that did not land. When the pre-connect
* check had redirected it to the player's requested server and that server turned
* them away — banned, not whitelisted, full, the wrong mods — the remembered target
* would send every retry of the gate into the same refusal, silently, until the gate
* gave up two minutes later with "couldn't enter the lobby". Forget the target, show
* the server's own reason, and release the player to the lobby at once.
*/
private void releaseRefused(Player player, ConnectionRequestBuilder.Result result) {
UUID id = player.getUniqueId();
RegisteredServer attempted = result.getAttemptedConnection();
String pending = pendingTargets.get(id);
if (attempted == null || pending == null || !serverNamed(attempted, pending)
|| !pendingTargets.remove(id, pending)) {
log.warn("Felis: login release for {} did not land: {}", id, result.getStatus());
return; // a failed move to the lobby itself: the gate retries
}
boolean zh = FelisVelocityPlugin.zh(player);
Component line = Component.text(
zh ? "「" + pending + "」拒绝了你的连接,已把你送到大厅。"
: "« " + pending + " » turned you away, so you're in the lobby instead.",
NamedTextColor.RED);
Optional<Component> reason = result.getReasonComponent();
if (reason.isPresent()) {
line = line.append(Component.text(zh ? " 原因:" : " Reason: ", NamedTextColor.RED))
.append(reason.get());
}
player.sendMessage(line);
log.info("Felis: {} refused {} on the login release ({})", pending, id, result.getStatus());
releaseFromLogin(player);
}
// linked runs the link check through the gate and notes when the answer came from
// the outage fallback rather than felis-api.
private boolean linked(UUID id) throws LinkException {
@@ -589,11 +626,19 @@ public final class WaitingRouter {
plugin.stats().count(ProxyStats.Event.TRANSFER_FAILED);
log.warn("Felis: transfer of {} to {} failed: {}", player.getUniqueId(), serverName,
err != null ? err.toString() : result.getStatus());
player.sendMessage(Component.text(
FelisVelocityPlugin.zh(player)
? "无法把你连接到「" + serverName + "」。请重试。"
: "Couldn't connect you to « " + serverName + " ». Please try again.",
NamedTextColor.RED));
boolean zh = FelisVelocityPlugin.zh(player);
Component line = Component.text(
zh ? "无法把你连接到「" + serverName + "」。"
: "Couldn't connect you to « " + serverName + " ».",
NamedTextColor.RED);
// A backend that refuses the login says why (ban, whitelist, full, mods);
// "please try again" is wrong advice for all of those.
Optional<Component> reason = result == null ? Optional.empty() : result.getReasonComponent();
line = reason.isPresent()
? line.append(Component.text(zh ? " 原因:" : " Reason: ", NamedTextColor.RED))
.append(reason.get())
: line.append(Component.text(zh ? "请重试。" : " Please try again.", NamedTextColor.RED));
player.sendMessage(line);
}
});
}
@@ -32,6 +32,7 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier;
import java.util.function.Function;
/**
* Fakes are the test doubles the routing tests run the real proxy classes against:
@@ -236,7 +237,17 @@ final class Fakes {
volatile boolean connectSucceeds = true;
volatile String disconnectedWith;
final List<String> messages = Collections.synchronizedList(new ArrayList<>());
/** connects lists every server a connection request was sent to, by name. */
/**
* refusals maps a backend to the kick it answers this player's login with (a ban,
* the whitelist, "server is full"): the connect fails with that reason.
*/
final Map<String, String> refusals = new ConcurrentHashMap<>();
/**
* preConnect stands in for the proxy's ServerPreConnectEvent: it maps the server a
* request asked for to the one it goes to, or null when a listener denied it.
*/
volatile Function<RegisteredServer, RegisteredServer> preConnect = Function.identity();
/** connects lists every server a connection went to (after pre-connect), by name. */
final List<String> connects = Collections.synchronizedList(new ArrayList<>());
/** pluginMessages lists every plugin message sent down any of this player's server connections. */
final List<byte[]> pluginMessages = Collections.synchronizedList(new ArrayList<>());
@@ -308,18 +319,29 @@ final class Fakes {
case "getServer":
return target;
case "connect":
connects.add(target.getServerInfo().getName());
boolean ok = connectSucceeds;
ConnectionRequestBuilder.Status status = ok
? ConnectionRequestBuilder.Status.SUCCESS
: ConnectionRequestBuilder.Status.SERVER_DISCONNECTED;
RegisteredServer dest = preConnect.apply(target);
RegisteredServer attempted = dest == null ? target : dest;
String refusal = dest == null ? null : refusals.get(dest.getServerInfo().getName());
ConnectionRequestBuilder.Status status;
if (dest == null) {
status = ConnectionRequestBuilder.Status.CONNECTION_CANCELLED;
} else {
connects.add(dest.getServerInfo().getName());
status = connectSucceeds && refusal == null
? ConnectionRequestBuilder.Status.SUCCESS
: ConnectionRequestBuilder.Status.SERVER_DISCONNECTED;
}
return CompletableFuture.completedFuture(fake(ConnectionRequestBuilder.Result.class,
(m2, a2) -> {
switch (m2) {
case "getStatus":
return status;
case "getAttemptedConnection":
return target;
return attempted;
case "getReasonComponent":
return refusal == null
? Optional.empty()
: Optional.of(Component.text(refusal));
default:
return UNANSWERED;
}
@@ -419,9 +419,19 @@ public final class WaitingRouterTest {
refused.current = lobby;
refused.connectSucceeds = false;
router.enqueueFromInvite(refused.player, "beta");
Fakes.await("failed transfer counted", () -> plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED) == 1);
assertEq("failed transfer: told", true, refused.said("Couldn't connect you to « beta »"));
// The chat line is the last thing the failure path does; the count comes first.
Fakes.await("failed transfer: told", () -> refused.said("Couldn't connect you to « beta »"));
assertEq("failed transfer counted", 1L, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED));
assertEq("failed transfer: logged", 1, log.count("WARN", "transfer of " + refused.id + " to beta failed"));
// A backend that kicks the login says why; "please try again" is wrong for a ban.
Fakes.FakePlayer full = player(null, true);
full.current = lobby;
full.refusals.put("beta", "The server is full");
router.enqueueFromInvite(full.player, "beta");
Fakes.await("refused transfer: told the reason", () -> full.said("Couldn't connect you to « beta ». Reason: The server is full"));
assertEq("refused transfer counted", 2L, plugin.stats().total(ProxyStats.Event.TRANSFER_FAILED));
assertEq("refused transfer: no retry advice", false, full.said("try again"));
}
private static void disconnectAndRelease() {
@@ -439,6 +449,29 @@ public final class WaitingRouterTest {
router.releaseFromLogin(released.player);
assertEq("release connects to the lobby", List.of("lobby"), List.copyOf(released.connects));
// A requested server that turns the player away (a ban, the whitelist, full) is
// shown with its own reason and the player goes to the lobby at once: the
// remembered target sent every retry of the gate into the same refusal, silently,
// until the gate gave up two minutes later.
Fakes.FakePlayer banned = player("beta.mc.test", true);
choose(banned);
gated(banned);
banned.refusals.put("beta", "You are banned from this server");
router.releaseFromLogin(banned.player);
assertEq("refused target: tried, then the lobby", List.of("beta", "lobby"), List.copyOf(banned.connects));
assertEq("refused target: told why", true, banned.said(
"« beta » turned you away, so you're in the lobby instead. Reason: You are banned from this server"));
assertEq("refused target: forgotten", "lobby", allowedTo(release(banned)));
// A lobby that refuses is the gate's to retry: one attempt, no release loop.
Fakes.FakePlayer bounced = player(null, true);
gated(bounced);
bounced.refusals.put("lobby", "Server is full");
router.releaseFromLogin(bounced.player);
assertEq("refused lobby: one attempt", List.of("lobby"), List.copyOf(bounced.connects));
assertEq("refused lobby: nothing said", 0, bounced.messages.size());
assertEq("refused lobby: logged", 1, log.count("WARN", "login release for " + bounced.id + " did not land"));
net.remove("lobby");
Fakes.FakePlayer early = player(null, true);
router.releaseFromLogin(early.player);
@@ -479,7 +512,21 @@ public final class WaitingRouterTest {
// router's async part run to completion the way Velocity's event manager would.
private static ServerPreConnectEvent release(Fakes.FakePlayer p) {
p.current = login;
ServerPreConnectEvent e = new ServerPreConnectEvent(p.player, lobby, login);
return preConnect(p, lobby);
}
// gated puts the player on the login gate and runs every connection request it
// makes through the router's pre-connect check, as the proxy does.
private static void gated(Fakes.FakePlayer p) {
p.current = login;
p.preConnect = target -> {
ServerPreConnectEvent e = preConnect(p, target);
return e.getResult().isAllowed() ? e.getResult().getServer().orElse(target) : null;
};
}
private static ServerPreConnectEvent preConnect(Fakes.FakePlayer p, RegisteredServer target) {
ServerPreConnectEvent e = new ServerPreConnectEvent(p.player, target, p.current);
EventTask task = router.onServerPreConnect(e);
if (task != null) {
task.execute(new Continuation() {