feat(plugins): add Velocity proxy and Fabric/Forge/NeoForge/Paper integration mods
Server-side integration plugins: the Velocity proxy plugin plus Fabric, Forge, NeoForge, and Paper mods with a shared module. Gradle build output is not tracked.
This commit is contained in:
53 files changed
+4832
No files matched your search
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'best.lolicon.felis'
|
||||
version = '0.1.0'
|
||||
|
||||
// JDK 17 is the ceiling the whole plugin suite targets (Velocity 3.3.0 is a
|
||||
// Java-17 line); we run Gradle on JDK 17 and compile to 17 bytecode rather than
|
||||
// provisioning a separate toolchain.
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
name = 'papermc'
|
||||
url = 'https://repo.papermc.io/repository/maven-public/'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// velocity-api is compile-only (the proxy provides it at runtime); the
|
||||
// annotation processor turns @Plugin into the generated velocity-plugin.json.
|
||||
compileOnly 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
|
||||
annotationProcessor 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
|
||||
}
|
||||
|
||||
// The platform-agnostic link core lives in ../shared and is compiled straight
|
||||
// into this jar. The core has zero third-party dependencies, so source-sharing
|
||||
// keeps every loader self-contained with nothing to shade.
|
||||
sourceSets {
|
||||
main {
|
||||
java {
|
||||
srcDir '../shared/src/main/java'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'felis-velocity'
|
||||
@@ -0,0 +1,207 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.Control;
|
||||
import best.lolicon.felis.link.ControlFrame;
|
||||
import best.lolicon.felis.link.FelisApiClient;
|
||||
import best.lolicon.felis.link.LinkException;
|
||||
import best.lolicon.felis.link.MenuStatus;
|
||||
|
||||
import com.velocitypowered.api.event.Subscribe;
|
||||
import com.velocitypowered.api.event.connection.PluginMessageEvent;
|
||||
import com.velocitypowered.api.proxy.Player;
|
||||
import com.velocitypowered.api.proxy.ProxyServer;
|
||||
import com.velocitypowered.api.proxy.ServerConnection;
|
||||
import com.velocitypowered.api.proxy.messages.ChannelIdentifier;
|
||||
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* ControlChannel is the proxy end of the {@code felis:control} plugin-message channel
|
||||
* (spec §12): the bridge between the felis-paper lobby's {@code /menu} GUI and the
|
||||
* routing core. The lobby is a pure UI face — it holds no felis-api token and keeps
|
||||
* no queue — so every menu action arrives here as a {@link ControlFrame}, this class
|
||||
* drives the felis-api internal endpoints and the shared waiting queue, and answers
|
||||
* downstream with another frame. It is the §27 scenario-10 path: {@code /menu →
|
||||
* plugin msg → velocity → api → 共用等待队列 → ready 后 Connect}.
|
||||
*
|
||||
* <p><b>Anti-spoof (spec §14).</b> The acting identity is taken from the
|
||||
* {@link ServerConnection} the message arrived on — {@code source.getPlayer()} — and
|
||||
* never from the frame's {@code player} field, so a compromised backend cannot drive
|
||||
* an action as another player. A frame whose source is not a backend server (e.g. a
|
||||
* client) is consumed and dropped. The frame's {@code server} field is data, not
|
||||
* identity: it names which backend the player asked for, and the autostartPolicy /
|
||||
* ownership gates server-side decide whether this UUID may act on it.
|
||||
*
|
||||
* <p><b>Threading.</b> {@code felis:control} frames arrive on a Velocity event
|
||||
* thread, but every felis-api call below blocks on HTTP. So each handler does the
|
||||
* non-blocking work synchronously — decode, derive identity, and
|
||||
* {@code setResult(handled())} to stop the frame being forwarded — then hands the
|
||||
* blocking call to {@link FelisVelocityPlugin#async}, sending any downstream frame
|
||||
* from inside that callback. {@code setResult} must run before the handler returns;
|
||||
* it cannot be set from the async hop.
|
||||
*
|
||||
* <p>The three upstream frames map onto the menu's buttons (spec §12): a
|
||||
* {@code StatusQuery} refreshes a tile ({@code StatusUpdate} back); a
|
||||
* {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest}
|
||||
* (ownerless server) runs the two-rule split — claim asserts ownership/quota, then
|
||||
* the wake applies the autostartPolicy gate — and parks on success. Refusals come
|
||||
* back as {@code Error}; readiness as {@code TransferReady} just before the proxy
|
||||
* Connects the player (via {@link WaitingRouter.MenuTransferListener}).
|
||||
*/
|
||||
public final class ControlChannel implements WaitingRouter.MenuTransferListener {
|
||||
|
||||
/** The namespaced channel both ends register; shared with the codec's name. */
|
||||
static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.from(Control.CHANNEL);
|
||||
|
||||
private final ProxyServer proxy;
|
||||
private final Logger log;
|
||||
private final FelisApiClient api;
|
||||
private final WaitingRouter router;
|
||||
private final FelisVelocityPlugin plugin;
|
||||
|
||||
ControlChannel(ProxyServer proxy, Logger log, FelisApiClient api,
|
||||
WaitingRouter router, FelisVelocityPlugin plugin) {
|
||||
this.proxy = proxy;
|
||||
this.log = log;
|
||||
this.api = api;
|
||||
this.router = router;
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* register opens the channel and wires this instance as the waiting queue's
|
||||
* menu-transfer listener. The caller still registers it as an event subscriber.
|
||||
*/
|
||||
void register() {
|
||||
proxy.getChannelRegistrar().register(CHANNEL);
|
||||
router.setMenuTransferListener(this);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onPluginMessage(PluginMessageEvent event) {
|
||||
if (!CHANNEL.getId().equals(event.getIdentifier().getId())) {
|
||||
return; // not ours → leave Velocity's default handling alone
|
||||
}
|
||||
// We own this channel end to end: a felis:control frame is never relayed to
|
||||
// the other side, whatever its source. Consume it before doing anything else.
|
||||
event.setResult(PluginMessageEvent.ForwardResult.handled());
|
||||
|
||||
// Identity comes from the connection, never the frame (spec §14). A frame from
|
||||
// anything but a backend server (e.g. a client) is not a legitimate lobby
|
||||
// action — drop it.
|
||||
if (!(event.getSource() instanceof ServerConnection)) {
|
||||
return;
|
||||
}
|
||||
ServerConnection source = (ServerConnection) event.getSource();
|
||||
Player player = source.getPlayer();
|
||||
|
||||
ControlFrame frame;
|
||||
try {
|
||||
frame = Control.decode(event.getData());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug("Felis: dropping malformed felis:control frame from {}: {}",
|
||||
source.getServerInfo().getName(), e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
switch (frame.type()) {
|
||||
case ControlFrame.STATUS_QUERY:
|
||||
handleStatusQuery(source, frame.server());
|
||||
break;
|
||||
case ControlFrame.WAKE_REQUEST:
|
||||
handleWake(source, player, frame.server());
|
||||
break;
|
||||
case ControlFrame.CLAIM_REQUEST:
|
||||
handleClaim(source, player, frame.server());
|
||||
break;
|
||||
default:
|
||||
// Downstream-only types (StatusUpdate/TransferReady/Error) are not
|
||||
// actionable arriving upstream; a well-behaved lobby never sends them.
|
||||
log.debug("Felis: ignoring non-actionable felis:control frame '{}' from {}",
|
||||
frame.type(), player.getUsername());
|
||||
}
|
||||
}
|
||||
|
||||
// A StatusQuery refreshes one tile: read the menu projection and answer with a
|
||||
// StatusUpdate, or an Error if felis-api refuses (e.g. 404 unknown server).
|
||||
private void handleStatusQuery(ServerConnection source, String server) {
|
||||
if (isBlank(server)) {
|
||||
return; // nothing to look up
|
||||
}
|
||||
plugin.async(() -> {
|
||||
try {
|
||||
MenuStatus s = api.menuStatus(server);
|
||||
send(source, ControlFrame.statusUpdate(
|
||||
s.name(), s.phase(), s.ready(), s.playersOnline(), s.playersMax(), s.claimable()));
|
||||
} catch (LinkException e) {
|
||||
send(source, errorFrame(e, server));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// A WakeRequest is the menu's Join/Wake button on a server the player owns: wake
|
||||
// it and park them in the shared queue. enqueueFromMenu does the HTTP off-thread
|
||||
// and reports its own refusals to the player; nothing to await here.
|
||||
private void handleWake(ServerConnection source, Player player, String server) {
|
||||
if (isBlank(server)) {
|
||||
send(source, ControlFrame.error("bad_request", "wake without a server", null));
|
||||
return;
|
||||
}
|
||||
router.enqueueFromMenu(player, server);
|
||||
}
|
||||
|
||||
// A ClaimRequest is the menu's Claim & Start on an ownerless server: the two-rule
|
||||
// split. Claim first (ownership + quota); only on success wake-and-park (the
|
||||
// autostartPolicy gate). A claim refusal answers with Error and never wakes.
|
||||
private void handleClaim(ServerConnection source, Player player, String server) {
|
||||
if (isBlank(server)) {
|
||||
send(source, ControlFrame.error("bad_request", "claim without a server", null));
|
||||
return;
|
||||
}
|
||||
UUID id = player.getUniqueId();
|
||||
plugin.async(() -> {
|
||||
try {
|
||||
api.claim(server, id);
|
||||
} catch (LinkException e) {
|
||||
send(source, errorFrame(e, server));
|
||||
return; // claim refused → do not wake a server the player doesn't own
|
||||
}
|
||||
// Owned now → run the second rule. enqueueFromMenu spawns its own async
|
||||
// hop for the wake, which is fine from here.
|
||||
router.enqueueFromMenu(player, server);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* onReady fires when a menu-parked player's backend goes ready, just before the
|
||||
* proxy Connects them. The lobby uses {@code TransferReady} to react (close the
|
||||
* menu / show "joining"); the actual move is the proxy's Connect, not this frame.
|
||||
*/
|
||||
@Override
|
||||
public void onReady(Player player, String serverName) {
|
||||
player.getCurrentServer().ifPresent(sc ->
|
||||
send(sc, ControlFrame.transferReady(player.getUsername(), serverName)));
|
||||
}
|
||||
|
||||
private void send(ServerConnection connection, ControlFrame frame) {
|
||||
connection.sendPluginMessage(CHANNEL, Control.encode(frame));
|
||||
}
|
||||
|
||||
// errorFrame turns a LinkException into a downstream Error. felis-api's structured
|
||||
// errors carry user-safe text (the same {code,message} the external API returns),
|
||||
// but a transport failure (statusCode 0) carries internal IO detail — host names,
|
||||
// refused ports — that must not reach a player's screen, so it is generalized.
|
||||
private static ControlFrame errorFrame(LinkException e, String server) {
|
||||
String code = e.errorCode() != null ? e.errorCode() : "error";
|
||||
String message = e.statusCode() == 0
|
||||
? "felis is temporarily unavailable — please try again."
|
||||
: e.getMessage();
|
||||
return ControlFrame.error(code, message, server);
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.LinkConfig;
|
||||
import best.lolicon.felis.link.LinkConfigLoader;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* FelisVelocityConfig extends the shared link config with the two inputs only the
|
||||
* full proxy needs: the {@code root-domain} the deployment serves (the zone
|
||||
* subdomains are carved from) and the {@code lobby-server} waiters are parked in
|
||||
* while their backend wakes. It reuses {@link LinkConfigLoader} for the API base
|
||||
* URL + service token (and its first-run template), so {@code /link} keeps working
|
||||
* exactly as before; these extra keys are read from the same properties file (or
|
||||
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOBBY_SERVER}).
|
||||
*
|
||||
* <p>Both extras are optional at load time and the routing layer degrades rather
|
||||
* than crashing: a missing {@code root-domain} disables routing (with a clear log
|
||||
* line) while {@code /link} still runs, and a missing {@code lobby-server} means
|
||||
* the proxy has nowhere to hold waiters, so it refuses the join with a "reconnect
|
||||
* shortly" message instead of dropping the player onto a not-yet-ready backend.
|
||||
* The root domain is the only place the deployment zone enters the proxy — it is
|
||||
* never compiled in (CI red line).
|
||||
*/
|
||||
final class FelisVelocityConfig {
|
||||
static final String ENV_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN";
|
||||
static final String ENV_LOBBY = "FELIS_LOBBY_SERVER";
|
||||
private static final String KEY_ROOT_DOMAIN = "root-domain";
|
||||
private static final String KEY_LOBBY = "lobby-server";
|
||||
|
||||
private final LinkConfig linkConfig;
|
||||
private final String rootDomain; // null → routing disabled
|
||||
private final String lobbyServer; // null → no lobby to park waiters in
|
||||
|
||||
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain, String lobbyServer) {
|
||||
this.linkConfig = linkConfig;
|
||||
this.rootDomain = rootDomain;
|
||||
this.lobbyServer = lobbyServer;
|
||||
}
|
||||
|
||||
static FelisVelocityConfig load(Path file) throws IOException {
|
||||
LinkConfig link = LinkConfigLoader.load(file); // url + token (required) + template + validate
|
||||
Properties props = new Properties();
|
||||
if (Files.exists(file)) {
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
props.load(in);
|
||||
}
|
||||
}
|
||||
String root = trimToNull(firstNonBlank(System.getenv(ENV_ROOT_DOMAIN), props.getProperty(KEY_ROOT_DOMAIN)));
|
||||
String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY)));
|
||||
return new FelisVelocityConfig(link, root == null ? null : root.toLowerCase(Locale.ROOT), lobby);
|
||||
}
|
||||
|
||||
LinkConfig linkConfig() {
|
||||
return linkConfig;
|
||||
}
|
||||
|
||||
/** rootDomain is the deployment zone, or null when routing should stay disabled. */
|
||||
String rootDomain() {
|
||||
return rootDomain;
|
||||
}
|
||||
|
||||
boolean routingEnabled() {
|
||||
return rootDomain != null;
|
||||
}
|
||||
|
||||
/** lobbyServer is the velocity.toml server name waiters are parked in, or null. */
|
||||
String lobbyServer() {
|
||||
return lobbyServer;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
if (a != null && !a.trim().isEmpty()) {
|
||||
return a;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
private static String trimToNull(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.FelisApiClient;
|
||||
import best.lolicon.felis.link.LinkClient;
|
||||
import best.lolicon.felis.link.LinkCode;
|
||||
import best.lolicon.felis.link.LinkException;
|
||||
import best.lolicon.felis.link.ServerView;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.mojang.brigadier.Command;
|
||||
import com.mojang.brigadier.tree.LiteralCommandNode;
|
||||
import com.velocitypowered.api.command.BrigadierCommand;
|
||||
import com.velocitypowered.api.command.CommandManager;
|
||||
import com.velocitypowered.api.command.CommandMeta;
|
||||
import com.velocitypowered.api.command.CommandSource;
|
||||
import com.velocitypowered.api.event.Subscribe;
|
||||
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
|
||||
import com.velocitypowered.api.plugin.Plugin;
|
||||
import com.velocitypowered.api.plugin.annotation.DataDirectory;
|
||||
import com.velocitypowered.api.proxy.Player;
|
||||
import com.velocitypowered.api.proxy.ProxyServer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* FelisVelocityPlugin is the proxy-side of Felis (spec §10 account-link + §11
|
||||
* domain-autostart routing). Velocity sits on the player-facing edge, off-cluster,
|
||||
* and is where two responsibilities naturally live:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>{@code /link}</b> — mints a one-time account-link code from the player's
|
||||
* online-mode-verified UUID (the first leg of §10), unchanged.</li>
|
||||
* <li><b>Domain-autostart routing</b> — recognizes each server's subdomain,
|
||||
* registers backends dynamically, routes joins, wakes a sleeping target and
|
||||
* holds the player in a lobby until it is ready, and reports real joins so
|
||||
* the reaper and allowlist see them (§11, driving §9).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Routing has two hard preconditions, each fails safe: the proxy must run in
|
||||
* online mode (verified UUIDs are the whole basis of the autostartPolicy and
|
||||
* allowlist gates — under offline mode routing is refused while {@code /link}
|
||||
* keeps working), and a {@code root-domain} must be configured (the only place the
|
||||
* deployment zone enters the proxy; never compiled in). With routing active the
|
||||
* {@code felis:control} plugin-message channel (spec §12) is also opened: the
|
||||
* felis-paper lobby's {@code /menu} drives the same waiting queue through
|
||||
* {@link ControlChannel}, deriving identity from the backend connection rather than
|
||||
* the frame so a compromised lobby cannot act as another player (spec §14).
|
||||
*/
|
||||
@Plugin(
|
||||
id = "felis-link",
|
||||
name = "Felis Velocity",
|
||||
version = "0.2.0",
|
||||
description = "In-game /link plus domain-autostart routing: recognizes server subdomains, "
|
||||
+ "registers backends, wakes sleeping servers and holds players until ready.",
|
||||
authors = {"Felis"}
|
||||
)
|
||||
public final class FelisVelocityPlugin {
|
||||
private static final Duration REGISTRATION_REFRESH = Duration.ofSeconds(15);
|
||||
private static final Duration WAIT_POLL = Duration.ofSeconds(2);
|
||||
|
||||
private final ProxyServer proxy;
|
||||
private final Logger logger;
|
||||
private final Path dataDirectory;
|
||||
|
||||
private FelisVelocityConfig config;
|
||||
private LinkClient linkClient;
|
||||
private FelisApiClient apiClient;
|
||||
private ServerRegistry registry;
|
||||
private boolean onlineMode;
|
||||
private boolean routingActive;
|
||||
|
||||
@Inject
|
||||
public FelisVelocityPlugin(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) {
|
||||
this.proxy = proxy;
|
||||
this.logger = logger;
|
||||
this.dataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onProxyInitialize(ProxyInitializeEvent event) {
|
||||
try {
|
||||
this.config = FelisVelocityConfig.load(dataDirectory.resolve("felis-link.properties"));
|
||||
} catch (Exception e) {
|
||||
logger.error("Felis disabled: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
this.linkClient = new LinkClient(config.linkConfig());
|
||||
registerLinkCommand();
|
||||
registerFelisCommand();
|
||||
|
||||
this.onlineMode = proxy.getConfiguration().isOnlineMode();
|
||||
if (!onlineMode) {
|
||||
logger.error("Felis routing DISABLED: the proxy is in offline mode (online-mode=false). "
|
||||
+ "Domain autostart and the allowlist trust Mojang-verified UUIDs; refusing to route on "
|
||||
+ "spoofable identities. /link remains available. Set online-mode=true to enable routing.");
|
||||
return;
|
||||
}
|
||||
if (!config.routingEnabled()) {
|
||||
logger.warn("Felis routing DISABLED: no root-domain configured. Add 'root-domain=' to "
|
||||
+ "felis-link.properties (or set " + FelisVelocityConfig.ENV_ROOT_DOMAIN + ") to enable "
|
||||
+ "host-based routing. /link remains available.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.apiClient = new FelisApiClient(config.linkConfig());
|
||||
this.registry = new ServerRegistry(proxy, logger, config.rootDomain());
|
||||
WaitingRouter router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer());
|
||||
MotdResponder motd = new MotdResponder(registry);
|
||||
proxy.getEventManager().register(this, router);
|
||||
proxy.getEventManager().register(this, motd);
|
||||
|
||||
// The felis:control face (spec §12): the felis-paper lobby's /menu drives the
|
||||
// same waiting queue through this channel. Opened only with routing active —
|
||||
// it depends on the same online-mode + root-domain guards, and its claim/wake
|
||||
// identity is the verified UUID off the backend connection (spec §14).
|
||||
ControlChannel control = new ControlChannel(proxy, logger, apiClient, router, this);
|
||||
control.register();
|
||||
proxy.getEventManager().register(this, control);
|
||||
|
||||
// Prime registrations immediately, then keep them fresh; drain the queue often.
|
||||
refreshRegistrations();
|
||||
repeating(REGISTRATION_REFRESH, this::refreshRegistrations);
|
||||
repeating(WAIT_POLL, router::tick);
|
||||
|
||||
this.routingActive = true;
|
||||
if (config.lobbyServer() == null) {
|
||||
logger.warn("Felis routing active without a lobby-server: a player whose target is asleep will be "
|
||||
+ "asked to reconnect rather than parked. Set 'lobby-server=' to enable the waiting queue.");
|
||||
}
|
||||
logger.info("Felis routing ready: rootDomain={}, lobby={}. /link and /felis registered.",
|
||||
config.rootDomain(), config.lobbyServer() == null ? "<none>" : config.lobbyServer());
|
||||
}
|
||||
|
||||
/** async runs a task on Velocity's scheduler so felis-api I/O never blocks the proxy thread. */
|
||||
void async(Runnable task) {
|
||||
proxy.getScheduler().buildTask(this, task).schedule();
|
||||
}
|
||||
|
||||
private void repeating(Duration interval, Runnable task) {
|
||||
proxy.getScheduler().buildTask(this, task).delay(interval).repeat(interval).schedule();
|
||||
}
|
||||
|
||||
private void refreshRegistrations() {
|
||||
try {
|
||||
List<ServerView> servers = apiClient.listServers();
|
||||
registry.refresh(servers);
|
||||
} catch (LinkException e) {
|
||||
// Keep existing registrations on a control-plane blip (spec §11): a
|
||||
// transient failure must never deregister live backends.
|
||||
logger.warn("Felis: server list refresh failed (status={}): {}; keeping current registrations.",
|
||||
e.statusCode(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- /link (spec §10 first leg) ----
|
||||
|
||||
private void registerLinkCommand() {
|
||||
CommandManager commands = proxy.getCommandManager();
|
||||
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("link")
|
||||
.executes(ctx -> {
|
||||
CommandSource source = ctx.getSource();
|
||||
if (!(source instanceof Player)) {
|
||||
source.sendMessage(Component.text("/link can only be run by a player.", NamedTextColor.RED));
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}
|
||||
requestAndReply((Player) source);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})
|
||||
.build();
|
||||
CommandMeta meta = commands.metaBuilder("link").plugin(this).build();
|
||||
commands.register(meta, new BrigadierCommand(node));
|
||||
}
|
||||
|
||||
private void requestAndReply(Player player) {
|
||||
player.sendMessage(Component.text("Requesting a link code…", NamedTextColor.GRAY));
|
||||
async(() -> {
|
||||
try {
|
||||
LinkCode code = linkClient.requestCode(player.getUniqueId());
|
||||
player.sendMessage(Component.text("Your link code: ", NamedTextColor.GREEN)
|
||||
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
|
||||
player.sendMessage(Component.text(
|
||||
"Enter it on the web panel → Account to finish linking (valid a few minutes).",
|
||||
NamedTextColor.GRAY));
|
||||
} catch (LinkException e) {
|
||||
logger.warn("link code request failed for {} (status={}, code={}): {}",
|
||||
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
|
||||
player.sendMessage(Component.text(
|
||||
"Couldn't get a link code right now. Please try again in a moment.",
|
||||
NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- /felis (operator status) ----
|
||||
|
||||
private void registerFelisCommand() {
|
||||
CommandManager commands = proxy.getCommandManager();
|
||||
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("felis")
|
||||
.executes(ctx -> {
|
||||
sendSummary(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})
|
||||
.then(BrigadierCommand.literalArgumentBuilder("list")
|
||||
.executes(ctx -> {
|
||||
sendList(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))
|
||||
.build();
|
||||
CommandMeta meta = commands.metaBuilder("felis").plugin(this).build();
|
||||
commands.register(meta, new BrigadierCommand(node));
|
||||
}
|
||||
|
||||
private void sendSummary(CommandSource source) {
|
||||
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
|
||||
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
|
||||
if (!routingActive) {
|
||||
source.sendMessage(Component.text(
|
||||
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
source.sendMessage(field("root-domain", config.rootDomain()));
|
||||
source.sendMessage(field("lobby", config.lobbyServer() == null ? "<none>" : config.lobbyServer()));
|
||||
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
|
||||
}
|
||||
|
||||
private void sendList(CommandSource source) {
|
||||
if (!routingActive) {
|
||||
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
Collection<ServerView> servers = registry.all();
|
||||
if (servers.isEmpty()) {
|
||||
source.sendMessage(Component.text("No felis servers known yet.", NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
source.sendMessage(Component.text("Felis servers:", NamedTextColor.AQUA));
|
||||
for (ServerView v : servers) {
|
||||
String phase = v.phase() == null ? "?" : v.phase();
|
||||
source.sendMessage(Component.text(" " + v.name() + " ", NamedTextColor.WHITE)
|
||||
.append(Component.text("[" + phase + (v.ready() ? ", ready" : "") + "]",
|
||||
v.ready() ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Component field(String key, String value) {
|
||||
return Component.text(" " + key + ": ", NamedTextColor.GRAY)
|
||||
.append(Component.text(value == null ? "<unset>" : value, NamedTextColor.WHITE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.ServerView;
|
||||
|
||||
import com.velocitypowered.api.event.Subscribe;
|
||||
import com.velocitypowered.api.event.proxy.ProxyPingEvent;
|
||||
import com.velocitypowered.api.proxy.server.ServerPing;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* MotdResponder answers the server-list ping for a felis subdomain from cache, so
|
||||
* a player sees the right server in their list — and whether it is awake — without
|
||||
* the ping itself waking anything (spec §11 "ping MOTD:只读缓存,后台刷新"). The
|
||||
* cache it reads is the {@link ServerRegistry}'s lifecycle view, refreshed in the
|
||||
* background by the plugin's registration poll; the ping handler never touches the
|
||||
* backend or the wake lever.
|
||||
*
|
||||
* <p>This is the read-only, phase-aware subset of the responsibility: the MOTD is
|
||||
* synthesized from the server's lifecycle (online / starting / sleeping) and its
|
||||
* cached player counts. Mirroring each backend's <em>own</em> MOTD string (by
|
||||
* pinging ready servers in the background and caching the result) is a richer
|
||||
* variant deferred to a later slice; nothing here ever pings a sleeping backend.
|
||||
*/
|
||||
public final class MotdResponder {
|
||||
private final ServerRegistry registry;
|
||||
|
||||
MotdResponder(ServerRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onProxyPing(ProxyPingEvent event) {
|
||||
Optional<InetSocketAddress> vh = event.getConnection().getVirtualHost();
|
||||
if (vh.isEmpty()) {
|
||||
return; // no SRV host → leave the proxy's own MOTD
|
||||
}
|
||||
Optional<ServerView> viewOpt = registry.resolveByHost(vh.get().getHostString());
|
||||
if (viewOpt.isEmpty()) {
|
||||
return; // not a felis subdomain → leave the proxy's own MOTD
|
||||
}
|
||||
ServerView v = viewOpt.get();
|
||||
|
||||
ServerPing.Builder b = event.getPing().asBuilder();
|
||||
b.description(Component.text("« " + v.name() + " » ", NamedTextColor.AQUA)
|
||||
.append(Component.text(statusLine(v), statusColor(v))));
|
||||
if (v.ready()) {
|
||||
b.onlinePlayers(v.playersOnline());
|
||||
b.maximumPlayers(Math.max(v.playersMax(), v.playersOnline()));
|
||||
}
|
||||
event.setPing(b.build());
|
||||
}
|
||||
|
||||
private static String statusLine(ServerView v) {
|
||||
if (v.ready()) {
|
||||
return "online";
|
||||
}
|
||||
if ("Running".equals(v.desiredState())) {
|
||||
return "starting…";
|
||||
}
|
||||
return "sleeping — join to wake";
|
||||
}
|
||||
|
||||
private static NamedTextColor statusColor(ServerView v) {
|
||||
if (v.ready()) {
|
||||
return NamedTextColor.GREEN;
|
||||
}
|
||||
if ("Running".equals(v.desiredState())) {
|
||||
return NamedTextColor.YELLOW;
|
||||
}
|
||||
return NamedTextColor.GRAY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.ServerView;
|
||||
|
||||
import com.velocitypowered.api.proxy.ProxyServer;
|
||||
import com.velocitypowered.api.proxy.server.RegisteredServer;
|
||||
import com.velocitypowered.api.proxy.server.ServerInfo;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* ServerRegistry mirrors the felis server set into Velocity's dynamic server
|
||||
* registry and indexes it for host-based routing (spec §11). It owns two things:
|
||||
* the felis lifecycle views keyed by name + subdomain, and the registration of
|
||||
* each server's backend address as a Velocity {@link RegisteredServer}.
|
||||
*
|
||||
* <p>{@link #refresh(Collection)} is called only on a <em>successful</em> fetch
|
||||
* from felis-api, so a name's absence is a genuine removal. On an API failure the
|
||||
* caller skips the refresh entirely and the previous registrations survive
|
||||
* untouched (spec §11 keep-old-on-failure) — a transient control-plane blip must
|
||||
* never deregister live backends out from under connected players.
|
||||
*
|
||||
* <p>Only felis-managed servers live in this registry; servers defined statically
|
||||
* in {@code velocity.toml} (notably the lobby) are never added here and so are
|
||||
* never deregistered by a refresh. Static server names must therefore not collide
|
||||
* with felis server names.
|
||||
*/
|
||||
final class ServerRegistry {
|
||||
private static final int DEFAULT_PORT = 25565;
|
||||
|
||||
private final ProxyServer proxy;
|
||||
private final Logger log;
|
||||
private final String rootDomain;
|
||||
|
||||
private final Map<String, ServerView> byName = new ConcurrentHashMap<>();
|
||||
private final Map<String, String> subdomainToName = new ConcurrentHashMap<>();
|
||||
|
||||
ServerRegistry(ProxyServer proxy, Logger log, String rootDomain) {
|
||||
this.proxy = proxy;
|
||||
this.log = log;
|
||||
this.rootDomain = rootDomain.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** refresh reconciles registrations against a freshly fetched server list. */
|
||||
void refresh(Collection<ServerView> servers) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (ServerView v : servers) {
|
||||
String name = v.name();
|
||||
if (name == null || name.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
seen.add(name);
|
||||
byName.put(name, v);
|
||||
String sub = v.subdomain();
|
||||
if (sub != null && !sub.isEmpty()) {
|
||||
subdomainToName.put(sub.toLowerCase(Locale.ROOT), name);
|
||||
}
|
||||
ensureRegistered(v);
|
||||
}
|
||||
// Drop servers that vanished from a successful fetch (iterate a snapshot so
|
||||
// deregister can mutate byName underneath us).
|
||||
for (String name : new ArrayList<>(byName.keySet())) {
|
||||
if (!seen.contains(name)) {
|
||||
deregister(name);
|
||||
}
|
||||
}
|
||||
subdomainToName.values().removeIf(n -> !seen.contains(n));
|
||||
}
|
||||
|
||||
private void ensureRegistered(ServerView v) {
|
||||
String addr = v.endpointAddress();
|
||||
if (addr == null || addr.isEmpty()) {
|
||||
return; // no backend address yet (server never started) → nothing to register
|
||||
}
|
||||
InetSocketAddress target = parseAddress(addr);
|
||||
Optional<RegisteredServer> existing = proxy.getServer(v.name());
|
||||
if (existing.isPresent()) {
|
||||
if (existing.get().getServerInfo().getAddress().equals(target)) {
|
||||
return; // already registered at this address
|
||||
}
|
||||
proxy.unregisterServer(existing.get().getServerInfo()); // address changed → re-register
|
||||
}
|
||||
proxy.registerServer(new ServerInfo(v.name(), target));
|
||||
log.info("Felis: registered backend {} -> {}", v.name(), addr);
|
||||
}
|
||||
|
||||
private void deregister(String name) {
|
||||
byName.remove(name);
|
||||
proxy.getServer(name).ifPresent(rs -> {
|
||||
proxy.unregisterServer(rs.getServerInfo());
|
||||
log.info("Felis: deregistered backend {}", name);
|
||||
});
|
||||
}
|
||||
|
||||
/** resolveByHost maps {@code subdomain.<root_domain>} to its current view. */
|
||||
Optional<ServerView> resolveByHost(String host) {
|
||||
if (host == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String h = host.toLowerCase(Locale.ROOT);
|
||||
String suffix = "." + rootDomain;
|
||||
if (!h.endsWith(suffix)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String sub = h.substring(0, h.length() - suffix.length());
|
||||
String name = subdomainToName.get(sub);
|
||||
return name == null ? Optional.empty() : Optional.ofNullable(byName.get(name));
|
||||
}
|
||||
|
||||
ServerView view(String name) {
|
||||
return name == null ? null : byName.get(name);
|
||||
}
|
||||
|
||||
boolean isManaged(String name) {
|
||||
return name != null && byName.containsKey(name);
|
||||
}
|
||||
|
||||
Optional<RegisteredServer> registered(String name) {
|
||||
return proxy.getServer(name);
|
||||
}
|
||||
|
||||
Collection<ServerView> all() {
|
||||
return new ArrayList<>(byName.values());
|
||||
}
|
||||
|
||||
/** parseAddress splits {@code host[:port]} into an unresolved socket address. */
|
||||
static InetSocketAddress parseAddress(String addr) {
|
||||
int idx = addr.lastIndexOf(':');
|
||||
if (idx > 0 && idx < addr.length() - 1) {
|
||||
try {
|
||||
int port = Integer.parseInt(addr.substring(idx + 1));
|
||||
// Unresolved: the backend's DNS (a K8s Service) may not resolve yet
|
||||
// while the server is asleep; Velocity resolves at connect time.
|
||||
return InetSocketAddress.createUnresolved(addr.substring(0, idx), port);
|
||||
} catch (NumberFormatException ignored) {
|
||||
// not host:port → fall through to the default Minecraft port
|
||||
}
|
||||
}
|
||||
return InetSocketAddress.createUnresolved(addr, DEFAULT_PORT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package best.lolicon.felis.velocity;
|
||||
|
||||
import best.lolicon.felis.link.FelisApiClient;
|
||||
import best.lolicon.felis.link.LinkException;
|
||||
import best.lolicon.felis.link.ServerView;
|
||||
|
||||
import com.velocitypowered.api.event.Subscribe;
|
||||
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
|
||||
import com.velocitypowered.api.event.player.ServerConnectedEvent;
|
||||
import com.velocitypowered.api.proxy.Player;
|
||||
import com.velocitypowered.api.proxy.ProxyServer;
|
||||
import com.velocitypowered.api.proxy.server.RegisteredServer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* WaitingRouter implements the §11 domain-autostart routing loop and its waiting
|
||||
* queue. It resolves the virtual host a player connected with to a felis server
|
||||
* and decides what happens next:
|
||||
*
|
||||
* <pre>
|
||||
* host has no felis subdomain → leave Velocity's default routing alone
|
||||
* server ready + registered → set it as the initial server (straight in)
|
||||
* server not ready, lobby configured → park in lobby, wake it, enqueue a transfer
|
||||
* server not ready, no lobby → refuse cleanly ("reconnect shortly"), wake
|
||||
* </pre>
|
||||
*
|
||||
* <p>The queue is drained by {@link #tick()}, scheduled by the plugin on the async
|
||||
* pool. Each tick polls felis-api once per distinct waited-on server and, when one
|
||||
* reports ready, transfers everyone waiting on it. A waiter drops out when it times
|
||||
* out, when the player leaves the proxy, or on a successful transfer.
|
||||
*
|
||||
* <p>The wake is gated server-side by autostartPolicy keyed on the player's
|
||||
* online-mode UUID: a 403 means this player may not start the server (we tell them
|
||||
* and stop), a 429 means a wake is already in flight (we keep waiting). Real joins
|
||||
* to a felis backend are reported back so the reaper sees activity and the player
|
||||
* is auto-added to the allowlist.
|
||||
*/
|
||||
public final class WaitingRouter {
|
||||
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
|
||||
|
||||
private final ProxyServer proxy;
|
||||
private final Logger log;
|
||||
private final FelisApiClient api;
|
||||
private final ServerRegistry registry;
|
||||
private final FelisVelocityPlugin plugin;
|
||||
private final String lobbyServer; // may be null → no lobby
|
||||
|
||||
private final Map<UUID, Waiter> waiting = new ConcurrentHashMap<>();
|
||||
|
||||
// Notified just before a menu-originated waiter is transferred, so the lobby's
|
||||
// felis:control face can tell the player's GUI the backend is ready. Null until
|
||||
// the ControlChannel is wired in at proxy init; set once, read on the tick pool.
|
||||
private volatile MenuTransferListener menuListener;
|
||||
|
||||
WaitingRouter(ProxyServer proxy, Logger log, FelisApiClient api, ServerRegistry registry,
|
||||
FelisVelocityPlugin plugin, String lobbyServer) {
|
||||
this.proxy = proxy;
|
||||
this.log = log;
|
||||
this.api = api;
|
||||
this.registry = registry;
|
||||
this.plugin = plugin;
|
||||
this.lobbyServer = lobbyServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* setMenuTransferListener wires the felis:control face so a menu-driven wait can
|
||||
* notify the lobby when its backend is ready. Called once at proxy init.
|
||||
*/
|
||||
void setMenuTransferListener(MenuTransferListener listener) {
|
||||
this.menuListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* enqueueFromMenu parks a player who is already on the proxy (sitting in the
|
||||
* lobby) on a server they asked for through the felis-paper {@code /menu}, then
|
||||
* wakes it and lets {@link #tick()} transfer them when ready — the same shared
|
||||
* waiting queue used by host-based autostart routing (spec §12, §27 scenario 10).
|
||||
* It differs from initial-server routing only in origin: the player drove it from
|
||||
* a GUI button rather than a connecting virtual host, so the waiter is flagged to
|
||||
* fire {@link MenuTransferListener} on transfer.
|
||||
*/
|
||||
void enqueueFromMenu(Player player, String serverName) {
|
||||
wakeAndWait(player, serverName, true);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onChooseInitialServer(PlayerChooseInitialServerEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Optional<String> host = virtualHost(player);
|
||||
if (host.isEmpty()) {
|
||||
return; // direct connect / no SRV host → leave default routing
|
||||
}
|
||||
Optional<ServerView> targetOpt = registry.resolveByHost(host.get());
|
||||
if (targetOpt.isEmpty()) {
|
||||
return; // host is not a felis subdomain → leave default routing
|
||||
}
|
||||
ServerView target = targetOpt.get();
|
||||
Optional<RegisteredServer> backend = registry.registered(target.name());
|
||||
if (target.ready() && backend.isPresent()) {
|
||||
event.setInitialServer(backend.get()); // ready → straight in
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<RegisteredServer> lobby = lobby();
|
||||
if (lobby.isEmpty()) {
|
||||
// Nowhere to hold the player while the backend wakes: refuse cleanly so
|
||||
// they reconnect onto a ready server, rather than dropping them onto a
|
||||
// backend that is still starting. Still fire the wake so the reconnect
|
||||
// lands faster.
|
||||
player.disconnect(Component.text(
|
||||
"« " + target.name() + " » is starting up — please reconnect in a moment.",
|
||||
NamedTextColor.YELLOW));
|
||||
fireWake(player.getUniqueId(), target.name());
|
||||
return;
|
||||
}
|
||||
event.setInitialServer(lobby.get()); // park in lobby
|
||||
wakeAndWait(player, target.name(), false);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onServerConnected(ServerConnectedEvent event) {
|
||||
String name = event.getServer().getServerInfo().getName();
|
||||
if (!registry.isManaged(name)) {
|
||||
return; // lobby / static server → not a felis backend, nothing to report
|
||||
}
|
||||
UUID id = event.getPlayer().getUniqueId();
|
||||
plugin.async(() -> {
|
||||
try {
|
||||
api.reportJoin(name, id);
|
||||
} catch (LinkException e) {
|
||||
log.debug("Felis: join-event {} failed (status={}): {}", name, e.statusCode(), e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** tick drains the waiting queue; the plugin schedules it on the async pool. */
|
||||
void tick() {
|
||||
if (waiting.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
Map<String, Boolean> readyCache = new HashMap<>(); // one status poll per distinct server
|
||||
for (Map.Entry<UUID, Waiter> e : new ArrayList<>(waiting.entrySet())) {
|
||||
UUID id = e.getKey();
|
||||
Waiter w = e.getValue();
|
||||
Optional<Player> po = proxy.getPlayer(id);
|
||||
if (po.isEmpty()) {
|
||||
waiting.remove(id); // player left the proxy
|
||||
continue;
|
||||
}
|
||||
Player player = po.get();
|
||||
if (now > w.deadlineMillis) {
|
||||
waiting.remove(id);
|
||||
player.sendMessage(Component.text(
|
||||
"« " + w.serverName + " » is taking longer than expected to start. "
|
||||
+ "You can keep waiting in the lobby or try again later.", NamedTextColor.YELLOW));
|
||||
continue;
|
||||
}
|
||||
Boolean ready = readyCache.get(w.serverName);
|
||||
if (ready == null) {
|
||||
try {
|
||||
ready = api.serverStatus(w.serverName).ready();
|
||||
} catch (LinkException ex) {
|
||||
ready = Boolean.FALSE; // transient → keep waiting until the deadline
|
||||
}
|
||||
readyCache.put(w.serverName, ready);
|
||||
}
|
||||
if (!ready) {
|
||||
continue;
|
||||
}
|
||||
Optional<RegisteredServer> backend = registry.registered(w.serverName);
|
||||
if (backend.isEmpty()) {
|
||||
continue; // ready but not yet registered → next tick
|
||||
}
|
||||
waiting.remove(id);
|
||||
player.sendMessage(Component.text(
|
||||
"« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
|
||||
// Tell a menu-driven lobby its tile is live before we pull the player off
|
||||
// it; the proxy still performs the actual Connect just below.
|
||||
MenuTransferListener listener = menuListener;
|
||||
if (w.fromMenu && listener != null) {
|
||||
listener.onReady(player, w.serverName);
|
||||
}
|
||||
transfer(player, w.serverName, backend.get());
|
||||
}
|
||||
}
|
||||
|
||||
private void wakeAndWait(Player player, String serverName, boolean fromMenu) {
|
||||
UUID id = player.getUniqueId();
|
||||
plugin.async(() -> {
|
||||
try {
|
||||
api.wake(serverName, id);
|
||||
} catch (LinkException e) {
|
||||
switch (e.statusCode()) {
|
||||
case 403:
|
||||
player.sendMessage(Component.text(
|
||||
"You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
|
||||
return; // policy gate refused → do not enqueue
|
||||
case 429:
|
||||
break; // a wake is already in flight → fall through to waiting
|
||||
default:
|
||||
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
|
||||
player.sendMessage(Component.text(
|
||||
"Couldn't start « " + serverName + " » right now. Try again shortly.",
|
||||
NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
}
|
||||
player.sendMessage(Component.text(
|
||||
"Starting « " + serverName + " » — you'll be moved in automatically.",
|
||||
NamedTextColor.GRAY));
|
||||
waiting.put(id, new Waiter(serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
|
||||
});
|
||||
}
|
||||
|
||||
private void fireWake(UUID id, String serverName) {
|
||||
plugin.async(() -> {
|
||||
try {
|
||||
api.wake(serverName, id);
|
||||
} catch (LinkException e) {
|
||||
if (e.statusCode() != 429 && e.statusCode() != 403) {
|
||||
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void transfer(Player player, String serverName, RegisteredServer backend) {
|
||||
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
|
||||
if (err != null || (result != null && !result.isSuccessful())) {
|
||||
player.sendMessage(Component.text(
|
||||
"Couldn't connect you to « " + serverName + " ». Please try again.",
|
||||
NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<RegisteredServer> lobby() {
|
||||
return lobbyServer == null ? Optional.empty() : proxy.getServer(lobbyServer);
|
||||
}
|
||||
|
||||
private static Optional<String> virtualHost(Player player) {
|
||||
return player.getVirtualHost()
|
||||
.map(InetSocketAddress::getHostString)
|
||||
.map(s -> s.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
private static final class Waiter {
|
||||
final String serverName;
|
||||
final long deadlineMillis;
|
||||
final boolean fromMenu; // true → notify the felis:control face on transfer
|
||||
|
||||
Waiter(String serverName, long deadlineMillis, boolean fromMenu) {
|
||||
this.serverName = serverName;
|
||||
this.deadlineMillis = deadlineMillis;
|
||||
this.fromMenu = fromMenu;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MenuTransferListener bridges the shared waiting queue to the felis:control face
|
||||
* without {@link WaitingRouter} depending on the wire codec: it is told a
|
||||
* menu-originated player's backend is ready, and the implementation owns encoding
|
||||
* and sending the {@code TransferReady} frame. The proxy still performs the
|
||||
* Connect itself ({@link #transfer}); this is only the lobby-UI notification.
|
||||
*/
|
||||
interface MenuTransferListener {
|
||||
void onReady(Player player, String serverName);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user