From 3ae486143db5e46b91054124ac0195d67fcd0400 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Sun, 18 Oct 2026 22:40:35 +0200 Subject: [PATCH] Add lobby plugin with server browser listing Players connecting through the player proxy are sent to a lobby instance running their Factorio version, where an in-game list shows the instances in the cluster. Picking one starts it if needed and moves the player there with LuaPlayer.connect_to_server once it's running, while the proxy keeps routing the player to it until they have joined, covering the second connection Factorio makes to fetch a user key. Players connecting to the dedicated port of a stopped instance wait in a lobby while it starts instead of timing out. The cluster can be listed in the Factorio server browser as one game per lobby version with the players online in the whole cluster, and instances with a dedicated proxy port can be listed on their own, including while stopped. The listed build comes from probing the instance through the proxy, as the browser only shows games with the exact version and build of the client. The controller now creates the player proxy before loading plugins so they can use it from their entrypoints. --- CHANGELOG.md | 15 + README.md | 1 + docs/player-proxy.md | 68 ++++ packages/controller/src/Controller.ts | 7 +- packages/create/create.js | 1 + plugins/lobby/README.md | 113 +++++++ plugins/lobby/controller.ts | 451 ++++++++++++++++++++++++++ plugins/lobby/index.ts | 110 +++++++ plugins/lobby/instance.ts | 100 ++++++ plugins/lobby/matching_server.ts | 270 +++++++++++++++ plugins/lobby/messages.ts | 116 +++++++ plugins/lobby/module/lobby.lua | 261 +++++++++++++++ plugins/lobby/module/module.json | 4 + plugins/lobby/package.json | 64 ++++ plugins/lobby/test/plugin.js | 375 +++++++++++++++++++++ plugins/lobby/tsconfig.browser.json | 8 + plugins/lobby/tsconfig.json | 7 + plugins/lobby/tsconfig.node.json | 10 + plugins/lobby/web/index.tsx | 0 plugins/lobby/webpack.config.cjs | 28 ++ test/mock.js | 6 + tsconfig.json | 1 + 22 files changed, 2013 insertions(+), 3 deletions(-) create mode 100644 docs/player-proxy.md create mode 100644 plugins/lobby/README.md create mode 100644 plugins/lobby/controller.ts create mode 100644 plugins/lobby/index.ts create mode 100644 plugins/lobby/instance.ts create mode 100644 plugins/lobby/matching_server.ts create mode 100644 plugins/lobby/messages.ts create mode 100644 plugins/lobby/module/lobby.lua create mode 100644 plugins/lobby/module/module.json create mode 100644 plugins/lobby/package.json create mode 100644 plugins/lobby/test/plugin.js create mode 100644 plugins/lobby/tsconfig.browser.json create mode 100644 plugins/lobby/tsconfig.json create mode 100644 plugins/lobby/tsconfig.node.json create mode 100644 plugins/lobby/web/index.tsx create mode 100644 plugins/lobby/webpack.config.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a45b8b0..ca553297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,21 @@ Many thanks to the following for contributing to this release: [@username](https://github.com/username) --> +## Unreleased + +### Major Features + +- Added a player proxy on the controller that accepts Factorio clients on a single UDP port, sends each player to an instance picked by plugins or the instance they were last on, and starts stopped instances on demand. Instances can also get a dedicated proxy port with `instance.player_proxy_port`. See [Player proxy](/docs/player-proxy.md). +- Added the Lobby plugin, which sends players connecting through the proxy to a lobby running their Factorio version where they pick the instance to join, and can list the cluster and individual instances, including stopped ones, in the Factorio server browser. + +### Features + +- Added the `playerProxyRoute` controller hook for plugins to decide which instance a player connecting through the player proxy goes to. + +### Fixes + +- Fixed map positions after the first one in exchange strings being decoded relative to the wrong previous position. + ## Version 2.0.3 ### Features diff --git a/README.md b/README.md index 52ac01bc..ab32fc27 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Plugins add functionality to Factorio servers, Clusterio itself or both. These are the plugins supported and maintained by the Clusterio developers: - [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers. +- [Lobby](/plugins/lobby/README.md): lobby servers where players pick the server to join, and listing of the cluster in the Factorio server browser. - [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked and the research queue between servers. - [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller. - [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests. diff --git a/docs/player-proxy.md b/docs/player-proxy.md new file mode 100644 index 00000000..605f88bf --- /dev/null +++ b/docs/player-proxy.md @@ -0,0 +1,68 @@ +# Player Proxy + +The controller can accept connections from Factorio clients on a single UDP port and forward each player to an instance in the cluster. +Players then only need to know one address, and instances that are stopped are started when someone connects to them. + + +## Contents + +- [Enabling the proxy](#enabling-the-proxy) +- [How players are routed](#how-players-are-routed) +- [Dedicated instance ports](#dedicated-instance-ports) +- [Lobbies and the server browser](#lobbies-and-the-server-browser) +- [Limitations](#limitations) + + +## Enabling the proxy + +Set [controller.player_proxy_port](configuration.md#controllerplayer_proxy_port) to the UDP port to accept players on and restart the controller: + + npx clusteriocontroller config set controller.player_proxy_port 34197 + +Make sure the port is reachable from the Internet and is not in the `host.factorio_port_range` of a host running on the same machine as the controller. +The controller connects to the game port of the instances from the address the host connected to the controller from, so the game ports of the instances must be reachable from the controller. +Instances behind the proxy should have `visibility.public` turned off in their `factorio.settings`, otherwise they are listed in the server browser with their own address in addition to the proxy. + + +## How players are routed + +A Factorio client starts a connection by sending its version, and only sends the name of the player after the server has answered. +The proxy answers the first message itself with the version of the client, waits for the name, and then picks an instance in this order: + +1. The first instance returned by a plugin from the `playerProxyRoute` controller hook, see [Writing plugins](writing-plugins.md#defining-the-plugin-entrypoint). +2. The instance the port the player connected to is dedicated to, see below. +3. The instance the player was last on. +4. [controller.player_proxy_default_instance_id](configuration.md#controllerplayer_proxy_default_instance_id). +5. Any running instance. + +If the chosen instance is stopped it is started, and the player is connected once it's running. +Factorio clients give up after roughly 10 seconds, so the first attempt to join an instance that takes longer to start fails, but trying again once it's up works. +The lobby plugin avoids this by letting players wait in a lobby instead. + +The version of the client is not checked against the instance. +Factorio clients only check the version in the answer to their first message, which the proxy sends, so a player sent to an instance running a different version does not get Factorio's version mismatch message and fails later in the handshake instead. +The proxy logs a warning when this happens. + + +## Dedicated instance ports + +[instance.player_proxy_port](configuration.md#instanceplayer_proxy_port) makes the proxy listen on another port that always sends players to that instance. +This lets each instance have its own address while still being started on demand. + + +## Lobbies and the server browser + +The [lobby plugin](../plugins/lobby/README.md) builds on the proxy: + +- Players connecting to the proxy go to a lobby instance running their Factorio version, where an in-game list lets them pick the instance to join. + Picking a stopped instance starts it, and the player is moved there once it's running. +- The cluster is listed as one game in the Factorio server browser, with the players online in the whole cluster. +- Instances can be listed as their own games in the server browser on a dedicated port, also while they are stopped. + Players joining a stopped instance from the server browser wait in a lobby while it starts. + + +## Limitations + +- Every player reaches the instances from the address of the controller, so bans by IP address and limits on connection attempts per address in Factorio apply to all players behind the proxy together. +- Factorio's network protocol is not stable between versions, the proxy is written for Factorio 2.0 and 2.1. +- All game traffic passes through the controller, which adds latency if the controller and the instances are far apart. diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index f51574dd..097e87fe 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -436,8 +436,7 @@ export default class Controller { this.app.locals.staticRoot = this.config.get("controller.static_url"); // Load plugins - await this.loadPlugins(); - + // Created before plugins load so they can use it, but only started after they have attached their hooks const playerProxyPort = this.config.get("controller.player_proxy_port"); if (playerProxyPort !== null) { this.playerProxyServer = new PlayerProxyServer( @@ -445,9 +444,11 @@ export default class Controller { logger.child({ component: "player_proxy" }), playerProxyPort, ); - await this.playerProxyServer.start(); } + await this.loadPlugins(); + await this.playerProxyServer?.start(); + // Log all express errors from middleware or routes (app.use is missing the type overload) this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => { const url = lib.redactUrl(req.originalUrl); diff --git a/packages/create/create.js b/packages/create/create.js index 342fa6a6..3300f685 100755 --- a/packages/create/create.js +++ b/packages/create/create.js @@ -30,6 +30,7 @@ if (process.platform === "win32") { const availablePlugins = [ { name: "Global Chat", value: "@clusterio/plugin-global_chat" }, + { name: "Lobby", value: "@clusterio/plugin-lobby" }, { name: "Player Auth", value: "@clusterio/plugin-player_auth" }, { name: "Player Sync", value: "@clusterio/plugin-player_sync" }, { name: "Research Sync", value: "@clusterio/plugin-research_sync" }, diff --git a/plugins/lobby/README.md b/plugins/lobby/README.md new file mode 100644 index 00000000..1ad9b58b --- /dev/null +++ b/plugins/lobby/README.md @@ -0,0 +1,113 @@ +# Clusterio Lobby Plugin + +Lobby servers where players pick the instance to join, and listing of the cluster in the Factorio server browser. +Works together with the [player proxy](../../docs/player-proxy.md) on the controller. + +- Players connecting to the player proxy are sent to a lobby instance running their Factorio version. + The lobby shows a list of the instances in the cluster, picking one moves the player there with `LuaPlayer.connect_to_server`. +- Picking a stopped instance starts it, the player waits in the lobby and is moved once it's running. +- Other instances with the plugin loaded get a Servers button that opens the same list. +- The cluster can be listed in the Factorio server browser as one game per Factorio version a lobby runs, showing the players online in the whole cluster. +- Instances can also be listed in the server browser as their own game on a dedicated proxy port, including while they are stopped. + +The [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) community plugin provides a similar in-game server list without the lobby, proxy and server browser parts. + + +## Installation + +Run the following commands in the folder Clusterio is installed to: + + npm install @clusterio/plugin-lobby + npx clusteriocontroller plugin add @clusterio/plugin-lobby + +Substitute clusteriocontroller with clusteriohost or clusterioctl if this a dedicate host or ctl installation respectively. + + +## Setting up a lobby + +1. Enable the player proxy by setting `controller.player_proxy_port` and restart the controller. +2. Set `lobby.public_address` to the address and port players reach the proxy on, for example `play.example.com:34197`. + This is the address players are sent to when they pick an instance. +3. Create an instance to act as the lobby, set `lobby.is_lobby` to true on it and start it. + Players are made spectators in the lobby, so any map works. + Create one lobby for each Factorio version you want to accept players on. +4. Optionally set `lobby.listed` to false on instances that should not be shown in the list. + +Players who connect to the proxy now end up in the lobby for their version. +If there's no lobby running their version they are routed as if the plugin wasn't loaded. + + +## Server browser listing + +Set `lobby.server_browser_listing` to true to list the cluster in the Factorio server browser. +This needs `controller.factorio_username` and `controller.factorio_token` to be set, and a lobby instance to be running so its exact version and build can be listed. +Factorio only shows games running the exact same version and build as the client. + +To list an instance as its own game, set `instance.player_proxy_port` to a port the proxy should listen on for it and `lobby.server_browser_listing` on the instance to true. +The instance stays listed when it's stopped, as long as it has been started once while the controller was running so its version is known. +Players joining it while it's stopped wait in a lobby running their version until the instance has started. + + +## Controller Configuration + +### lobby.public_address + +Address and port players reach the player proxy on, for example `play.example.com:34197`. +Players are sent here when they pick an instance in a lobby, and the host name is resolved to the address published in the server browser. +If null the host name of `controller.public_url` and `controller.player_proxy_port` is used. +If the player proxy is disabled players are sent straight to the address of the host of the instance instead. + +Defaults to null. + +### lobby.server_browser_listing + +List the cluster in the Factorio server browser as one game for each Factorio version a lobby is running. + +Defaults to false. + +### lobby.server_browser_name + +Name of the cluster in the server browser, uses `controller.name` if null. + +Defaults to null. + +### lobby.server_browser_description + +Description of the cluster in the server browser. + +Defaults to an empty string. + +### lobby.server_browser_tags + +Comma separated tags shown for the games listed in the server browser. + +Defaults to "clusterio". + +### lobby.server_browser_max_players + +Max players shown for the games listed in the server browser, 0 for no limit. +This is only shown, it does not limit how many players can join. + +Defaults to 0. + + +## Instance Configuration + +### lobby.is_lobby + +Players connecting to the player proxy are sent to a running lobby with their Factorio version, where they pick which instance to join. + +Defaults to false. + +### lobby.listed + +Show this instance in the server list of lobbies. + +Defaults to true. + +### lobby.server_browser_listing + +List this instance as its own game in the Factorio server browser, also while it's stopped. +Requires `instance.player_proxy_port` to be set. + +Defaults to false. diff --git a/plugins/lobby/controller.ts b/plugins/lobby/controller.ts new file mode 100644 index 00000000..37028662 --- /dev/null +++ b/plugins/lobby/controller.ts @@ -0,0 +1,451 @@ +import dns from "node:dns/promises"; +import { isIP } from "node:net"; +import path from "node:path"; + +import * as lib from "@clusterio/lib"; +import type { + Controller, ControllerPluginContext, InstanceRecord, PlayerProxyRouteRequest, proxyProtocol, +} from "@clusterio/controller"; + +import { Listing, MatchingServerClient } from "./matching_server.js"; +import { + LobbyServer, ServerListRequest, TransferRequest, TransferState, TransferStatus, TransferStatusEvent, +} from "./messages.js"; + +/** How long a player has to connect to an instance once it's ready */ +const TRANSFER_READY_TIMEOUT_MS = 120_000; +/** How long an instance has to start for a transfer to it */ +const TRANSFER_START_TIMEOUT_MS = 600_000; +const LISTING_INTERVAL_MS = 30_000; + +type ApplicationVersion = proxyProtocol.ApplicationVersion; + +export interface Transfer { + player: string; + instanceId: number; + state: TransferState; + /** Lobby the player is waiting in */ + lobbyId?: number; + expiresAtMs: number; +} + +function formatVersion(version: ApplicationVersion) { + return `${version.major}.${version.minor}.${version.sub}`; +} + +/** Split host:port, supporting bracketed IPv6 addresses */ +export function parseAddress(address: string): { host: string, port?: number } { + const match = /^(?:\[([^\]]+)\]|([^:]+))(?::(\d+))?$/.exec(address.trim()); + if (!match) { + return { host: address.trim() }; + } + return { host: match[1] ?? match[2], port: match[3] !== undefined ? Number(match[3]) : undefined }; +} + +export class LobbyController { + /** Transfers in progress by lowercase player name */ + transfers = new Map(); + private _listingTimer?: ReturnType; + private _listingWarning?: string; + + constructor( + public controller: Controller, + public logger: lib.Logger, + public matchingServer: MatchingServerClient, + ) { } + + get proxy() { + return this.controller.playerProxyServer; + } + + static isLobby(instance: Readonly) { + return Boolean(instance.config.get("lobby.load_plugin") && instance.config.get("lobby.is_lobby")); + } + + static isRoutable(instance: Readonly | undefined): instance is Readonly { + return Boolean(instance && !instance.isDeleted && instance.config.get("instance.assigned_host") !== null); + } + + /** + * Factorio version an instance runs, or ran last time it was seen + * + * @param instance - Instance to check. + * @returns version as major.minor.sub or null if unknown. + */ + instanceVersion(instance: Readonly) { + const seen = this.proxy?.serverVersions.get(instance.id); + if (instance.status === "running" && instance.factorioVersion) { + return instance.factorioVersion; + } + if (seen) { + return formatVersion(seen); + } + const configured = instance.factorioVersion ?? instance.config.get("factorio.version"); + return /^\d+\.\d+\.\d+$/.test(configured) ? configured : null; + } + + playersOnline(instanceId?: number) { + const names: string[] = []; + for (const user of this.controller.users.records.values()) { + if (instanceId === undefined ? user.instances.size : user.instances.has(instanceId)) { + names.push(user.name); + } + } + return names.sort(); + } + + serverList(): LobbyServer[] { + const servers: LobbyServer[] = []; + for (const instance of this.controller.instances.values()) { + if ( + !LobbyController.isRoutable(instance) + || LobbyController.isLobby(instance) + || !instance.config.get("lobby.listed") + ) { + continue; + } + servers.push(new LobbyServer( + instance.id, + instance.config.get("instance.name"), + instance.status, + this.instanceVersion(instance), + this.playersOnline(instance.id).length, + )); + } + return servers.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Find a lobby running the given Factorio version + * + * @param version - Version of the client. + * @returns a running lobby if there is one, otherwise a stopped one configured for this version. + */ + pickLobby(version: ApplicationVersion) { + const wanted = formatVersion(version); + let stopped: Readonly | undefined; + for (const instance of this.controller.instances.values()) { + if (!LobbyController.isRoutable(instance) || !LobbyController.isLobby(instance)) { + continue; + } + const seen = this.proxy?.serverVersions.get(instance.id); + if (seen && formatVersion(seen) === wanted && seen.build !== version.build) { + continue; + } + if (instance.status === "running" && instance.factorioVersion === wanted) { + return instance; + } + if (!stopped && instance.status !== "running" && this.instanceVersion(instance) === wanted) { + stopped = instance; + } + } + return stopped; + } + + /** Address players use to connect to an instance, through the proxy if it's enabled */ + connectAddress(instance: Readonly) { + if (this.proxy) { + const configured = this.controller.config.get("lobby.public_address"); + if (configured) { + return configured; + } + const publicUrl = this.controller.config.get("controller.public_url"); + if (publicUrl) { + return `${new URL(publicUrl).hostname}:${this.proxy.port}`; + } + return undefined; + } + + const hostId = instance.config.get("instance.assigned_host"); + const host = hostId !== null ? this.controller.hosts.get(hostId) : undefined; + const address = host?.publicAddress || host?.remoteAddress; + if (!address || !instance.gamePort) { + return undefined; + } + return `${address}:${instance.gamePort}`; + } + + private _getTransfer(player: string) { + const key = player.toLowerCase(); + const transfer = this.transfers.get(key); + if (transfer && transfer.expiresAtMs < Date.now()) { + this.transfers.delete(key); + return undefined; + } + return transfer; + } + + private _status(transfer: Transfer, message?: string) { + const instance = this.controller.instances.get(transfer.instanceId); + const name = instance?.config.get("instance.name") ?? String(transfer.instanceId); + const address = transfer.state === "ready" && instance ? this.connectAddress(instance) : undefined; + if (transfer.state === "ready" && !address) { + return new TransferStatus("failed", name, undefined, "The address to connect to is not configured"); + } + return new TransferStatus(transfer.state, name, address, message); + } + + private _notify(transfer: Transfer, message?: string) { + if (transfer.lobbyId === undefined) { + return; + } + const lobby = this.controller.instances.get(transfer.lobbyId); + if (lobby?.status !== "running") { + return; + } + this.controller.sendTo( + { instanceId: transfer.lobbyId }, + new TransferStatusEvent(transfer.player, transfer.instanceId, this._status(transfer, message)), + ); + } + + /** + * Start sending a player to an instance + * + * @param player - Name of the player. + * @param instanceId - Instance to send the player to. + * @param lobbyId - Lobby the player is waiting in. + * @returns the transfer created. + */ + startTransfer(player: string, instanceId: number, lobbyId?: number) { + const instance = this.controller.instances.get(instanceId); + if (!LobbyController.isRoutable(instance)) { + throw new lib.RequestError(`Instance ${instanceId} is not available`); + } + const ready = instance.status === "running"; + const transfer: Transfer = { + player, + instanceId, + state: ready ? "ready" : "starting", + lobbyId, + expiresAtMs: Date.now() + (ready ? TRANSFER_READY_TIMEOUT_MS : TRANSFER_START_TIMEOUT_MS), + }; + this.transfers.set(player.toLowerCase(), transfer); + + if (instance.status === "stopped") { + this.logger.info(`Starting instance ${instanceId} for ${player}`); + (async () => { + await this.controller.sendTo({ instanceId }, new lib.InstanceStartRequest()); + })().catch(err => { + this._failTransfers(instanceId, `Failed to start: ${err.message}`); + }); + } + return transfer; + } + + private _failTransfers(instanceId: number, message: string) { + for (const [key, transfer] of this.transfers) { + if (transfer.instanceId === instanceId && transfer.state === "starting") { + transfer.state = "failed"; + this._notify(transfer, message); + this.transfers.delete(key); + } + } + } + + async handleServerListRequest(): Promise { + return this.serverList(); + } + + async handleTransferRequest(request: TransferRequest, src: lib.Address): Promise { + const lobbyId = src.type === lib.Address.instance ? src.id : undefined; + return this._status(this.startTransfer(request.player, request.instanceId, lobbyId)); + } + + async onInstanceStatusChanged(instance: InstanceRecord) { + if (instance.status === "running") { + for (const transfer of this.transfers.values()) { + if (transfer.instanceId === instance.id && transfer.state === "starting") { + transfer.state = "ready"; + transfer.expiresAtMs = Date.now() + TRANSFER_READY_TIMEOUT_MS; + this._notify(transfer); + } + } + } else if (instance.status === "stopped" || instance.isDeleted) { + this._failTransfers(instance.id, "The server stopped while starting"); + } + } + + async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) { + if (event.type !== "join") { + return; + } + const transfer = this._getTransfer(event.name); + if (!transfer) { + return; + } + if (transfer.instanceId === instance.id) { + this.transfers.delete(event.name.toLowerCase()); + } else if (LobbyController.isLobby(instance)) { + // Player is waiting in this lobby for the instance to start + transfer.lobbyId = instance.id; + this._notify(transfer); + } + } + + async onPlayerProxyRoute(request: PlayerProxyRouteRequest): Promise { + const transfer = this._getTransfer(request.username); + if (transfer?.state === "ready") { + return transfer.instanceId; + } + if (transfer?.state === "starting") { + return transfer.lobbyId ?? this.pickLobby(request.version)?.id; + } + + if (request.portInstanceId !== undefined) { + const target = this.controller.instances.get(request.portInstanceId); + if (!LobbyController.isRoutable(target) || target.status === "running") { + return undefined; + } + // Starting can take longer than the client waits, so let the player wait in a lobby + const lobby = this.pickLobby(request.version); + if (!lobby) { + return undefined; + } + this.startTransfer(request.username, target.id, lobby.id); + return lobby.id; + } + + return this.pickLobby(request.version)?.id; + } + + /** Build the server browser listings that should exist */ + async desiredListings() { + const listings = new Map(); + const proxy = this.proxy; + if (!proxy) { + return listings; + } + + const clusterListing = this.controller.config.get("lobby.server_browser_listing"); + const instanceListings = [...this.controller.instances.values()].filter( + instance => LobbyController.isRoutable(instance) + && instance.config.get("lobby.load_plugin") + && instance.config.get("lobby.server_browser_listing") + && instance.config.get("instance.player_proxy_port") !== null + ); + if (!clusterListing && !instanceListings.length) { + return listings; + } + + const configured = this.controller.config.get("lobby.public_address"); + const publicUrl = this.controller.config.get("controller.public_url"); + const { host, port } = configured + ? parseAddress(configured) + : { host: publicUrl ? new URL(publicUrl).hostname : "", port: undefined }; + if (!host) { + this._warnListing("lobby.public_address or controller.public_url must be set to list servers"); + return listings; + } + let hostAddress = host; + if (!isIP(host)) { + try { + hostAddress = (await dns.lookup(host, { family: 4 })).address; + } catch (err: any) { + this._warnListing(`Unable to resolve ${host} for the server browser: ${err.message}`); + return listings; + } + } + + const tags = this.controller.config.get("lobby.server_browser_tags") + .split(",").map(tag => tag.trim()).filter(Boolean); + const common = { + description: this.controller.config.get("lobby.server_browser_description"), + tags, + maxPlayers: this.controller.config.get("lobby.server_browser_max_players"), + hostAddress, + }; + + if (clusterListing) { + const name = this.controller.config.get("lobby.server_browser_name") + ?? this.controller.config.get("controller.name"); + const players = this.playersOnline(); + for (const instance of this.controller.instances.values()) { + if (!LobbyController.isRoutable(instance) || !LobbyController.isLobby(instance)) { + continue; + } + const version = await proxy.instanceVersion(instance.id); + if (!version) { + continue; + } + const key = `cluster:${formatVersion(version)}:${version.build}`; + if (!listings.has(key)) { + listings.set(key, { + ...common, + name, + hostPort: port ?? proxy.port, + version: { version: formatVersion(version), build: version.build }, + players, + }); + } + } + } + + for (const instance of instanceListings) { + const version = await proxy.instanceVersion(instance.id); + if (!version) { + this.logger.verbose(`Version of instance ${instance.id} is not known yet, not listing it`); + continue; + } + listings.set(`instance:${instance.id}`, { + ...common, + name: instance.config.get("instance.name"), + hostPort: instance.config.get("instance.player_proxy_port")!, + version: { version: formatVersion(version), build: version.build }, + players: this.playersOnline(instance.id), + }); + } + return listings; + } + + private _warnListing(message: string) { + if (this._listingWarning !== message) { + this._listingWarning = message; + this.logger.warn(message); + } + } + + async updateListings() { + const username = this.controller.config.get("controller.factorio_username"); + const token = this.controller.config.get("controller.factorio_token"); + const credentials = username && token ? { username, token } : undefined; + await this.matchingServer.sync(await this.desiredListings(), credentials); + } + + private _updateListingsSerial = new lib.AsyncSerialMergingCallback(async () => { + try { + await this.updateListings(); + } catch (err: any) { + this.logger.error(`Unexpected error updating server browser listings:\n${err.stack}`); + } + }); + + startListing() { + this._listingTimer = setInterval(() => this._updateListingsSerial.invoke(), LISTING_INTERVAL_MS); + this._listingTimer.unref(); + this._updateListingsSerial.invoke(); + } + + async shutdown() { + clearInterval(this._listingTimer); + await this.matchingServer.removeAll(); + } +} + +export default async function loadControllerPlugin(context: ControllerPluginContext) { + const { controller, logger, plugin } = context; + const statePath = path.join(controller.config.get("controller.database_directory"), "lobby-listings.json"); + const lobby = new LobbyController(controller, logger, new MatchingServerClient(logger, statePath)); + + controller.handle(ServerListRequest, lobby.handleServerListRequest.bind(lobby)); + controller.handle(TransferRequest, lobby.handleTransferRequest.bind(lobby)); + controller.hooks.instanceStatusChanged.attach(plugin.name, lobby.onInstanceStatusChanged.bind(lobby)); + controller.hooks.playerEvent.attach(plugin.name, lobby.onPlayerEvent.bind(lobby)); + controller.hooks.playerProxyRoute.attach(plugin.name, lobby.onPlayerProxyRoute.bind(lobby)); + controller.hooks.shutdown.attach(plugin.name, lobby.shutdown.bind(lobby)); + + if (!controller.config.get("controller.player_proxy_port")) { + logger.warn("controller.player_proxy_port is not set, players can only reach lobbies directly"); + } + lobby.startListing(); +} diff --git a/plugins/lobby/index.ts b/plugins/lobby/index.ts new file mode 100644 index 00000000..adb8acf2 --- /dev/null +++ b/plugins/lobby/index.ts @@ -0,0 +1,110 @@ +import type * as lib from "@clusterio/lib"; + +import * as messages from "./messages.js"; + +declare module "@clusterio/lib" { + export interface ControllerConfigFields { + "lobby.public_address": string | null; + "lobby.server_browser_listing": boolean; + "lobby.server_browser_name": string | null; + "lobby.server_browser_description": string; + "lobby.server_browser_tags": string; + "lobby.server_browser_max_players": number; + } + export interface InstanceConfigFields { + "lobby.is_lobby": boolean; + "lobby.listed": boolean; + "lobby.server_browser_listing": boolean; + } +} + +export const plugin: lib.PluginDeclaration = { + name: "lobby", + title: "Lobby", + description: + "Lobby servers players pick the instance to join from, and listing of the cluster in the Factorio " + + "server browser. Works together with the player proxy on the controller.", + controllerEntrypoint: "dist/node/controller.js", + instanceEntrypoint: "dist/node/instance.js", + + controllerConfigFields: { + "lobby.public_address": { + title: "Public Address", + description: + "Address and port players reach the player proxy on, for example play.example.com:34197. " + + "Players are sent here when they pick an instance in a lobby. If null the host name of " + + "controller.public_url and controller.player_proxy_port is used.", + type: "string", + optional: true, + }, + "lobby.server_browser_listing": { + title: "Server Browser Listing", + description: + "List the cluster as one game in the Factorio server browser for each Factorio version a lobby " + + "instance runs. Uses controller.factorio_username and controller.factorio_token.", + type: "boolean", + initialValue: false, + }, + "lobby.server_browser_name": { + title: "Server Browser Name", + description: "Name of the cluster in the server browser, uses controller.name if null.", + type: "string", + optional: true, + }, + "lobby.server_browser_description": { + title: "Server Browser Description", + description: "Description of the cluster in the server browser.", + type: "string", + initialValue: "", + }, + "lobby.server_browser_tags": { + title: "Server Browser Tags", + description: "Comma separated tags for the server browser listings.", + type: "string", + initialValue: "clusterio", + }, + "lobby.server_browser_max_players": { + title: "Server Browser Max Players", + description: "Max players shown in the server browser listings, 0 for no limit.", + type: "number", + initialValue: 0, + }, + }, + + instanceConfigFields: { + "lobby.is_lobby": { + title: "Is Lobby", + description: + "Players connecting to the player proxy are sent to a lobby instance running their Factorio " + + "version, where they pick which instance to join.", + type: "boolean", + initialValue: false, + }, + "lobby.listed": { + title: "Listed", + description: "Show this instance in the server list of lobbies.", + type: "boolean", + initialValue: true, + }, + "lobby.server_browser_listing": { + title: "Server Browser Listing", + description: + "List this instance as its own game in the Factorio server browser, also while it's stopped. " + + "Requires instance.player_proxy_port to be set, players joining it while it's stopped wait in a " + + "lobby until it has started.", + type: "boolean", + initialValue: false, + }, + }, + + features: [ + "SavePatching", + "ScriptCommands", + ], + + messages: [ + messages.ServerListRequest, + messages.TransferRequest, + messages.TransferStatusEvent, + ], +}; diff --git a/plugins/lobby/instance.ts b/plugins/lobby/instance.ts new file mode 100644 index 00000000..dd576877 --- /dev/null +++ b/plugins/lobby/instance.ts @@ -0,0 +1,100 @@ +import * as lib from "@clusterio/lib"; +import type { InstancePluginContext } from "@clusterio/host"; + +import { ServerListRequest, TransferRequest, TransferStatus, TransferStatusEvent } from "./messages.js"; + +const SERVER_LIST_INTERVAL_MS = 5_000; + +// ./module/lobby.lua +type IpcConnect = { + player: string, + instance_id: number, +}; + +export default async function loadInstancePlugin(context: InstancePluginContext) { + const { instance, logger, plugin } = context; + let serverListTimer: ReturnType | undefined; + let lastServerListJson = ""; + + // Commands are sent one at a time so they execute in order + let rconQueue: Promise = Promise.resolve(); + function sendOrderedRcon(command: string) { + const result = rconQueue.then(() => instance.sendRcon(command, true, plugin.name)); + rconQueue = result.catch(() => {}); + return result; + } + + function running() { + return ["starting", "running"].includes(instance.status); + } + + async function configure() { + const isLobby = instance.config.get("lobby.is_lobby"); + await sendOrderedRcon(`/sc lobby.configure(${isLobby}, ${instance.id})`); + } + + async function updateServerList() { + if (!running()) { + return; + } + const servers = await instance.sendTo("controller", new ServerListRequest()); + const json = JSON.stringify(servers); + if (json === lastServerListJson) { + return; + } + lastServerListJson = json; + await sendOrderedRcon(`/sc lobby.set_servers("${lib.escapeString(json)}")`); + } + + async function applyStatus(player: string, status: TransferStatus) { + if (!running()) { + return; + } + const name = lib.escapeString(player); + if (status.state === "ready" && status.address) { + await sendOrderedRcon( + `/sc lobby.connect_player("${name}", "${lib.escapeString(status.address)}", ` + + `"${lib.escapeString(status.name)}")` + ); + return; + } + const text = status.state === "starting" + ? `Starting ${status.name}, you will be moved there once it's up.` + : `Unable to join ${status.name}: ${status.message ?? "unknown error"}`; + await sendOrderedRcon(`/sc lobby.set_player_status("${name}", "${lib.escapeString(text)}")`); + } + + instance.hooks.start.attach(plugin.name, async () => { + await configure(); + lastServerListJson = ""; + await updateServerList(); + serverListTimer = setInterval(() => { + updateServerList().catch(err => logger.error(`Failed to update server list:\n${err.stack}`)); + }, SERVER_LIST_INTERVAL_MS); + }); + + instance.hooks.stop.attach(plugin.name, async () => { + clearInterval(serverListTimer); + serverListTimer = undefined; + }); + + instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async field => { + if (field === "lobby.is_lobby" && running()) { + await configure(); + } + }); + + instance.server.handle("lobby:connect", async (data: IpcConnect) => { + let status: TransferStatus; + try { + status = await instance.sendTo("controller", new TransferRequest(data.player, data.instance_id)); + } catch (err: any) { + status = new TransferStatus("failed", String(data.instance_id), undefined, err.message); + } + await applyStatus(data.player, status); + }); + + instance.handle(TransferStatusEvent, async event => { + await applyStatus(event.player, event.status); + }); +} diff --git a/plugins/lobby/matching_server.ts b/plugins/lobby/matching_server.ts new file mode 100644 index 00000000..a14f1e60 --- /dev/null +++ b/plugins/lobby/matching_server.ts @@ -0,0 +1,270 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; + +import * as lib from "@clusterio/lib"; + +export const DEFAULT_MATCHING_SERVER_URL = "https://multiplayer.factorio.com"; + +/** Version of a game as listed in the server browser */ +export interface ListingVersion { + /** major.minor.sub */ + version: string; + build: number; +} + +/** Game to list in the Factorio server browser */ +export interface Listing { + name: string; + description: string; + tags: string[]; + maxPlayers: number; + hostAddress: string; + hostPort: number; + version: ListingVersion; + players: string[]; + /** Minutes the game has been running */ + gameTimeElapsed?: number; +} + +/** State of a listing kept between restarts so it can be updated or removed */ +interface StoredListing { + gameId: number; + gameSecret: string; + serverSecret?: string; +} + +interface ActiveListing extends StoredListing { + /** Game details last sent to the matching server */ + payload: string; +} + +export interface MatchingServerCredentials { + username: string; + token: string; +} + +class MatchingServerError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +/** + * Keeps a set of games listed in the Factorio server browser + * + * Talks to the matching server the same way a Factorio server with + * visibility.public set does: create-game with the account credentials, + * update-game when the details change, post-game-heartbeat otherwise, and + * remove-game when a listing is no longer wanted. + */ +export class MatchingServerClient { + private _active = new Map(); + /** Server secrets by key, kept after a listing is removed so it gets the same server_id back */ + private _serverSecrets = new Map(); + private _loaded = false; + + constructor( + private _logger: lib.Logger, + private _statePath?: string, + private _baseUrl = DEFAULT_MATCHING_SERVER_URL, + private _fetch: typeof fetch = fetch, + ) { } + + /** Keys of the listings currently registered */ + get keys() { + return [...this._active.keys()]; + } + + gameId(key: string) { + return this._active.get(key)?.gameId; + } + + private async _request(method: "GET" | "POST", uri: string, body?: object) { + const response = await this._fetch(`${this._baseUrl}/${uri}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(15_000), + }); + const text = await response.text(); + if (!response.ok) { + throw new MatchingServerError(response.status, `${uri} failed with ${response.status}: ${text}`); + } + return text ? JSON.parse(text) : {}; + } + + private static _game(listing: Listing, gameSecret: string, gameId?: number) { + return { + ...(gameId !== undefined ? { game_id: gameId } : {}), + game_secret: gameSecret, + name: listing.name.slice(0, 50), + description: listing.description.slice(0, 5000), + application_version: { + game_version: listing.version.version, + build_version: listing.version.build, + build_mode: "headless", + platform: "linux64", + }, + max_players: listing.maxPlayers, + game_time_elapsed: listing.gameTimeElapsed ?? 0, + has_password: false, + host_address: listing.hostAddress, + host_port: listing.hostPort, + tags: listing.tags, + players: listing.players, + headless_server: true, + }; + } + + private async _loadState() { + if (this._loaded || !this._statePath) { + this._loaded = true; + return new Map(); + } + this._loaded = true; + let stored: Record = {}; + try { + stored = JSON.parse(await fs.readFile(this._statePath, "utf8")); + } catch (err: any) { + if (err.code !== "ENOENT") { + this._logger.warn(`Failed to read server browser listings: ${err.message}`); + } + } + return new Map(Object.entries(stored)); + } + + private async _saveState() { + if (!this._statePath) { + return; + } + const stored: Record = {}; + for (const [key, secret] of this._serverSecrets) { + stored[key] = { gameId: -1, gameSecret: "", serverSecret: secret, removed: true }; + } + for (const [key, { gameId, gameSecret, serverSecret }] of this._active) { + stored[key] = { gameId, gameSecret, serverSecret }; + } + await lib.safeOutputFile(this._statePath, JSON.stringify(stored, null, "\t")); + } + + /** + * Make the registered listings match the given ones + * + * @param listings - Listings that should exist by a stable key. + * @param credentials - Factorio account to create listings with. + */ + async sync(listings: Map, credentials?: MatchingServerCredentials) { + if (!this._loaded) { + // Listings from before a restart are taken over, and updated or removed below + for (const [key, stored] of await this._loadState()) { + if (stored.serverSecret) { + this._serverSecrets.set(key, stored.serverSecret); + } + if (stored.gameId >= 0) { + this._active.set(key, { ...stored, payload: "" }); + } + } + } + + let changed = false; + for (const [key, active] of [...this._active]) { + if (!listings.has(key)) { + this._active.delete(key); + await this._remove(key, active); + changed = true; + } + } + + for (const [key, listing] of listings) { + try { + changed = await this._syncListing(key, listing, credentials) || changed; + } catch (err: any) { + this._logger.warn(`Failed to update server browser listing ${listing.name}: ${err.message}`); + } + } + + if (changed) { + await this._saveState(); + } + } + + private async _syncListing(key: string, listing: Listing, credentials?: MatchingServerCredentials) { + const active = this._active.get(key); + const payload = JSON.stringify(MatchingServerClient._game(listing, "")); + if (!active) { + await this._create(key, listing, payload, credentials); + return true; + } + + try { + if (active.payload !== payload) { + await this._request("POST", `update-game/${active.gameId}`, { + game: MatchingServerClient._game(listing, active.gameSecret, active.gameId), + game_secret: active.gameSecret, + }); + active.payload = payload; + } else { + await this._request("POST", `post-game-heartbeat/${active.gameId}`, { + game_secret: active.gameSecret, + game_patch: { game_time_elapsed: listing.gameTimeElapsed ?? 0 }, + }); + } + } catch (err: any) { + if (err instanceof MatchingServerError && err.status === 404) { + // The matching server forgot about the game, register it again + this._active.delete(key); + await this._create(key, listing, payload, credentials); + return true; + } + throw err; + } + return false; + } + + private async _create( + key: string, + listing: Listing, + payload: string, + credentials?: MatchingServerCredentials, + ) { + if (!credentials) { + throw new Error("controller.factorio_username and controller.factorio_token must be set"); + } + const gameSecret = crypto.randomBytes(16).toString("hex"); + const serverSecret = this._serverSecrets.get(key); + const result = await this._request("POST", "create-game", { + game: MatchingServerClient._game(listing, gameSecret), + username: credentials.username, + token: credentials.token, + ...(serverSecret ? { server_secret: serverSecret } : {}), + }); + if (typeof result.game_id !== "number") { + throw new Error("create-game did not return a game_id"); + } + if (typeof result.server_secret === "string") { + this._serverSecrets.set(key, result.server_secret); + } + this._active.set(key, { + gameId: result.game_id, + gameSecret, + serverSecret: this._serverSecrets.get(key), + payload, + }); + this._logger.info(`Listed ${listing.name} in the server browser as game ${result.game_id}`); + } + + private async _remove(key: string, stored: StoredListing) { + try { + await this._request("POST", `remove-game/${stored.gameId}`, { game_secret: stored.gameSecret }); + } catch (err: any) { + if (!(err instanceof MatchingServerError && err.status === 404)) { + this._logger.warn(`Failed to remove server browser listing ${stored.gameId}: ${err.message}`); + } + } + } + + /** Remove all listings */ + async removeAll() { + await this.sync(new Map()); + } +} diff --git a/plugins/lobby/messages.ts b/plugins/lobby/messages.ts new file mode 100644 index 00000000..28fc5be1 --- /dev/null +++ b/plugins/lobby/messages.ts @@ -0,0 +1,116 @@ +import { Type, Static } from "@sinclair/typebox"; +import * as lib from "@clusterio/lib"; + +/** Instance shown in the server list of a lobby */ +export class LobbyServer { + constructor( + public id: number, + public name: string, + public status: lib.InstanceStatus, + /** Factorio version the instance runs, or null if not known */ + public version: string | null, + public players: number, + ) { + } + + static jsonSchema = Type.Object({ + "id": Type.Integer(), + "name": Type.String(), + "status": lib.InstanceStatus, + "version": Type.Union([Type.String(), Type.Null()]), + "players": Type.Integer(), + }); + + static fromJSON(json: Static): LobbyServer { + return new this(json.id, json.name, json.status, json.version, json.players); + } +} + +export class ServerListRequest { + declare ["constructor"]: typeof ServerListRequest; + static type = "request" as const; + static src = "instance" as const; + static dst = "controller" as const; + static plugin = "lobby" as const; + + static Response = lib.jsonArray(LobbyServer); +} + +export type TransferState = "starting" | "ready" | "failed"; +const TransferStateSchema = Type.Union([Type.Literal("starting"), Type.Literal("ready"), Type.Literal("failed")]); + +export class TransferStatus { + constructor( + public state: TransferState, + /** Name of the instance transferred to */ + public name: string, + /** Address to connect to when ready */ + public address?: string, + /** Reason for a failed transfer */ + public message?: string, + ) { + } + + static jsonSchema = Type.Object({ + "state": TransferStateSchema, + "name": Type.String(), + "address": Type.Optional(Type.String()), + "message": Type.Optional(Type.String()), + }); + + static fromJSON(json: Static): TransferStatus { + return new this(json.state, json.name, json.address, json.message); + } +} + +/** Request from a lobby to send a player to an instance, starting it if needed */ +export class TransferRequest { + declare ["constructor"]: typeof TransferRequest; + static type = "request" as const; + static src = "instance" as const; + static dst = "controller" as const; + static plugin = "lobby" as const; + + constructor( + public player: string, + public instanceId: number, + ) { + } + + static jsonSchema = Type.Object({ + "player": Type.String(), + "instanceId": Type.Integer(), + }); + + static fromJSON(json: Static): TransferRequest { + return new this(json.player, json.instanceId); + } + + static Response = TransferStatus; +} + +/** Update on a transfer of a player sent to the lobby the player is on */ +export class TransferStatusEvent { + declare ["constructor"]: typeof TransferStatusEvent; + static type = "event" as const; + static src = "controller" as const; + static dst = "instance" as const; + static plugin = "lobby" as const; + + constructor( + public player: string, + public instanceId: number, + public status: TransferStatus, + ) { + } + + static jsonSchema = Type.Object({ + "player": Type.String(), + "instanceId": Type.Integer(), + "status": TransferStatus.jsonSchema, + }); + + static fromJSON(json: Static): TransferStatusEvent { + return new this(json.player, json.instanceId, TransferStatus.fromJSON(json.status)); + } +} diff --git a/plugins/lobby/module/lobby.lua b/plugins/lobby/module/lobby.lua new file mode 100644 index 00000000..349213f2 --- /dev/null +++ b/plugins/lobby/module/lobby.lua @@ -0,0 +1,261 @@ +local clusterio_api = require("modules/clusterio/api") +local compat = require("modules/clusterio/compat") +local mod_gui = require("mod-gui") + +--- @class LobbyServer +--- @field id integer +--- @field name string +--- @field status string +--- @field version string? +--- @field players integer + +--- @class LobbyScriptData +--- @field is_lobby boolean +--- @field instance_id integer? +--- @field servers LobbyServer[] +--- @field status table Status text by player name + +local status_captions = { + running = "Online", + stopped = "Offline", + starting = "Starting", + stopping = "Stopping", + creating_save = "Creating save", + exporting_data = "Exporting data", +} + +--- Idempotent accessor for the persistent state of the module +--- @return LobbyScriptData +local function get_script_data() + local data = compat.script_data.lobby + if not data then + data = {} + compat.script_data.lobby = data + end + data.servers = data.servers or {} + data.status = data.status or {} + if data.is_lobby == nil then + data.is_lobby = false + end + return data +end + +--- Reason a server can't be joined from here, or nil if it can +--- @param data LobbyScriptData +--- @param server LobbyServer +--- @return string? +local function unjoinable_reason(data, server) + if server.id == data.instance_id then + return "You are on this server" + end + local version = compat.active_mods["base"] + if server.version and server.version ~= version then + return "Runs Factorio " .. server.version .. ", you have " .. version + end + if not status_captions[server.status] then + return "Not available" + end + return nil +end + +--- @param player LuaPlayer +--- @param data LobbyScriptData +local function build_content(player, data, content) + content.clear() + local status = data.status[player.name] + if status then + local label = content.add { type = "label", caption = status } + label.style.single_line = false + label.style.maximal_width = 500 + label.style.bottom_margin = 8 + end + + if #data.servers == 0 then + content.add { type = "label", caption = "There are no servers to join right now." } + return + end + + local servers = content.add { type = "table", name = "lobby_servers", column_count = 4, style = "bordered_table" } + servers.style.minimal_width = 420 + for _, caption in ipairs({ "Server", "Players", "Status", "" }) do + servers.add { type = "label", caption = caption, style = "caption_label" } + end + for _, server in ipairs(data.servers) do + servers.add { type = "label", caption = server.name } + servers.add { type = "label", caption = tostring(server.players) } + servers.add { type = "label", caption = status_captions[server.status] or server.status } + local reason = unjoinable_reason(data, server) + servers.add { + type = "button", + caption = server.status == "stopped" and "Start and join" or "Join", + tooltip = reason, + enabled = reason == nil, + tags = { lobby_instance_id = server.id }, + } + end +end + +--- @param player LuaPlayer +local function open_browser(player) + local data = get_script_data() + local frame = player.gui.screen.lobby_frame + if not frame then + frame = player.gui.screen.add { type = "frame", name = "lobby_frame", direction = "vertical", + caption = data.is_lobby and "Pick a server to join" or "Servers" } + frame.auto_center = true + local inner = frame.add { type = "frame", name = "content", direction = "vertical", + style = "inside_shallow_frame_with_padding" } + inner.style.horizontally_stretchable = true + if not data.is_lobby then + local buttons = frame.add { type = "flow", direction = "horizontal" } + buttons.style.top_margin = 8 + local filler = buttons.add { type = "empty-widget" } + filler.style.horizontally_stretchable = true + buttons.add { type = "button", name = "lobby_close", caption = "Close" } + end + end + build_content(player, data, frame.content) +end + +--- @param player LuaPlayer +local function close_browser(player) + if player.gui.screen.lobby_frame then + player.gui.screen.lobby_frame.destroy() + end +end + +--- @param player LuaPlayer +local function refresh_player(player) + local data = get_script_data() + local button_flow = mod_gui.get_button_flow(player) + if data.is_lobby then + if button_flow.lobby_toggle then + button_flow.lobby_toggle.destroy() + end + open_browser(player) + else + if not button_flow.lobby_toggle then + button_flow.add { type = "button", name = "lobby_toggle", caption = "Servers", style = mod_gui.button_style } + end + if player.gui.screen.lobby_frame then + open_browser(player) + end + end +end + +local function refresh_all() + for _, player in pairs(game.connected_players) do + refresh_player(player) + end +end + +--- Keep players in a lobby from interacting with its world +--- @param player LuaPlayer +local function make_spectator(player) + if player.controller_type == defines.controllers.cutscene then + player.exit_cutscene() + end + if player.character then + player.character.destroy() + end + if player.controller_type ~= defines.controllers.spectator then + player.set_controller { type = defines.controllers.spectator } + end +end + +local lobby_module = {} +lobby_module.events = {} + +lobby_module.events[clusterio_api.events.on_server_startup] = function() + get_script_data() +end + +lobby_module.events[defines.events.on_player_joined_game] = function(event) + local player = game.get_player(event.player_index) + local data = get_script_data() + data.status[player.name] = nil + if data.is_lobby then + make_spectator(player) + end + refresh_player(player) +end + +lobby_module.events[defines.events.on_gui_click] = function(event) + local element = event.element + if not element.valid then + return + end + local player = game.get_player(event.player_index) + if element.name == "lobby_toggle" then + if player.gui.screen.lobby_frame then + close_browser(player) + else + open_browser(player) + end + elseif element.name == "lobby_close" then + close_browser(player) + elseif element.tags and element.tags.lobby_instance_id then + local data = get_script_data() + local instance_id = element.tags.lobby_instance_id + local name = tostring(instance_id) + for _, server in ipairs(data.servers) do + if server.id == instance_id then + name = server.name + end + end + data.status[player.name] = "Connecting to " .. name .. "..." + clusterio_api.send_json("lobby:connect", { player = player.name, instance_id = instance_id }) + open_browser(player) + end +end + +-- Interface used by the instance plugin through RCON +lobby = {} + +--- @param is_lobby boolean +--- @param instance_id integer +function lobby.configure(is_lobby, instance_id) + local data = get_script_data() + data.is_lobby = is_lobby + data.instance_id = instance_id + if not is_lobby then + for _, player in pairs(game.players) do + close_browser(player) + end + end + refresh_all() +end + +--- @param json string +function lobby.set_servers(json) + local data = get_script_data() + data.servers = compat.json_to_table(json) or {} + refresh_all() +end + +--- @param player_name string +--- @param text string +function lobby.set_player_status(player_name, text) + local data = get_script_data() + data.status[player_name] = text + local player = game.get_player(player_name) + if player and player.connected then + open_browser(player) + end +end + +--- @param player_name string +--- @param address string +--- @param server_name string +function lobby.connect_player(player_name, address, server_name) + local data = get_script_data() + local player = game.get_player(player_name) + if not player or not player.connected then + return + end + data.status[player_name] = "Moving you to " .. server_name .. "..." + open_browser(player) + player.connect_to_server { address = address, name = server_name } +end + +return lobby_module diff --git a/plugins/lobby/module/module.json b/plugins/lobby/module/module.json new file mode 100644 index 00000000..371c34b9 --- /dev/null +++ b/plugins/lobby/module/module.json @@ -0,0 +1,4 @@ +{ + "name": "lobby", + "load": ["lobby.lua"] +} diff --git a/plugins/lobby/package.json b/plugins/lobby/package.json new file mode 100644 index 00000000..6a921fb8 --- /dev/null +++ b/plugins/lobby/package.json @@ -0,0 +1,64 @@ +{ + "name": "@clusterio/plugin-lobby", + "version": "2.0.2", + "description": "Clusterio plugin for a lobby server players pick the server to join from, and listing the cluster in the Factorio server browser", + "homepage": "https://github.com/clusterio/clusterio#readme", + "author": "Danielv123", + "license": "MIT", + + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ], + + "repository": { + "type": "git", + "url": "https://github.com/clusterio/clusterio" + }, + "bugs": { + "url": "https://github.com/clusterio/clusterio/issues" + }, + "publishConfig": { + "access": "public" + }, + + "type": "module", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build && webpack-cli --mode production", + "test": "echo \"Error: run tests from root\" && exit 1" + }, + "engines": { + "node": ">=22" + }, + + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } + }, + "dependencies": { + "@sinclair/typebox": "catalog:" + }, + "devDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^", + "@types/node": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" + } +} diff --git a/plugins/lobby/test/plugin.js b/plugins/lobby/test/plugin.js new file mode 100644 index 00000000..62764495 --- /dev/null +++ b/plugins/lobby/test/plugin.js @@ -0,0 +1,375 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import * as lib from "@clusterio/lib"; +import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller"; + +import * as mock from "../../../test/mock.js"; +import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js"; +import { plugin as info } from "../dist/node/index.js"; +import * as instancePlugin from "../dist/node/instance.js"; +import { LobbyController, parseAddress } from "../dist/node/controller.js"; +import { MatchingServerClient } from "../dist/node/matching_server.js"; +import { + LobbyServer, TransferRequest, TransferStatus, TransferStatusEvent, ServerListRequest, +} from "../dist/node/messages.js"; + +const v2_1_19 = { major: 2, minor: 1, sub: 19, build: 84000 }; +const v2_0_72 = { major: 2, minor: 0, sub: 72, build: 83000 }; + +/** Fake of multiplayer.factorio.com recording the requests made to it */ +function fakeMatchingServer() { + const server = { + games: new Map(), + requests: [], + nextId: 100, + fetch: async (url, options) => { + const uri = new URL(url).pathname.slice(1); + const body = options.body ? JSON.parse(options.body) : undefined; + server.requests.push({ uri, body }); + const [operation, id] = uri.split("/"); + const reply = (status, json) => new Response(JSON.stringify(json), { status }); + if (operation === "create-game") { + const gameId = server.nextId; + server.nextId += 1; + server.games.set(gameId, body.game); + return reply(200, { game_id: gameId, server_id: `id-${gameId}`, server_secret: `secret-${gameId}` }); + } + const game = server.games.get(Number(id)); + if (!game || game.game_secret !== body.game_secret) { + return reply(404, { message: "game not found" }); + } + if (operation === "update-game") { + server.games.set(Number(id), body.game); + } else if (operation === "remove-game") { + server.games.delete(Number(id)); + } + return reply(200, { status: "ok" }); + }, + }; + return server; +} + +const listing = (fields = {}) => ({ + name: "Cluster", + description: "", + tags: [], + maxPlayers: 0, + hostAddress: "203.0.113.1", + hostPort: 34197, + version: { version: "2.1.19", build: 84000 }, + players: [], + ...fields, +}); + +describe("lobby plugin", function () { + before(function () { + lib.addPluginConfigFields([info]); + try { + lib.registerPluginMessages([info]); + } catch (err) { + // Already registered by the full test suite + } + }); + + describe("messages", function () { + it("should round trip LobbyServer", function () { + testRoundTripJsonSerialisable(LobbyServer, testMatrix( + [1], ["Server"], ["running", "stopped"], [null, "2.1.19"], [0, 5], + )); + }); + it("should round trip TransferStatus", function () { + testRoundTripJsonSerialisable(TransferStatus, testMatrix( + ["starting", "ready", "failed"], ["Server"], [undefined, "example.com:34197"], [undefined, "error"], + )); + }); + it("should round trip TransferRequest and TransferStatusEvent", function () { + testRoundTripJsonSerialisable(TransferRequest, testMatrix(["Player"], [1, 2])); + testRoundTripJsonSerialisable(TransferStatusEvent, testMatrix( + ["Player"], [1], [new TransferStatus("ready", "Server", "127.0.0.1:1")], + )); + }); + }); + + describe("parseAddress()", function () { + it("should split host and port", function () { + assert.deepEqual(parseAddress("example.com:34197"), { host: "example.com", port: 34197 }); + assert.deepEqual(parseAddress("1.2.3.4"), { host: "1.2.3.4", port: undefined }); + assert.deepEqual(parseAddress("[::1]:5"), { host: "::1", port: 5 }); + }); + }); + + describe("MatchingServerClient", function () { + let server; + let client; + let statePath; + const credentials = { username: "user", token: "token" }; + + beforeEach(async function () { + server = fakeMatchingServer(); + statePath = path.join("temp", "test", "lobby", `listings-${Date.now()}-${Math.random()}.json`); + client = new MatchingServerClient(new mock.MockLogger(), statePath, "https://matching.test", server.fetch); + }); + + afterEach(async function () { + await fs.rm(statePath, { force: true }); + }); + + it("should create, heartbeat, update and remove games", async function () { + await client.sync(new Map([["a", listing()]]), credentials); + assert.equal(server.games.size, 1); + const [gameId] = server.games.keys(); + assert.equal(server.games.get(gameId).host_port, 34197); + assert.equal(server.requests[0].body.username, "user"); + + await client.sync(new Map([["a", listing()]]), credentials); + assert.equal(server.requests.at(-1).uri, `post-game-heartbeat/${gameId}`); + + await client.sync(new Map([["a", listing({ players: ["alice", "bob"] })]]), credentials); + assert.equal(server.requests.at(-1).uri, `update-game/${gameId}`); + assert.deepEqual(server.games.get(gameId).players, ["alice", "bob"]); + + await client.sync(new Map(), credentials); + assert.equal(server.games.size, 0); + }); + + it("should register a game again if the matching server forgot it", async function () { + await client.sync(new Map([["a", listing()]]), credentials); + server.games.clear(); + await client.sync(new Map([["a", listing()]]), credentials); + assert.equal(server.games.size, 1); + const create = server.requests.filter(r => r.uri === "create-game"); + assert.equal(create.length, 2); + assert.equal(create[1].body.server_secret, "secret-100", "should keep server_id between games"); + }); + + it("should take over listings from before a restart", async function () { + await client.sync(new Map([["a", listing()], ["b", listing()]]), credentials); + const restarted = new MatchingServerClient( + new mock.MockLogger(), statePath, "https://matching.test", server.fetch + ); + server.requests = []; + await restarted.sync(new Map([["a", listing({ name: "Renamed" })]]), credentials); + assert.equal(server.games.size, 1); + assert.deepEqual(server.requests.map(r => r.uri.split("/")[0]).sort(), ["remove-game", "update-game"]); + }); + + it("should not create games without credentials", async function () { + await client.sync(new Map([["a", listing()]])); + assert.equal(server.games.size, 0); + }); + }); + + describe("LobbyController", function () { + let controller; + let lobby; + let started; + let sent; + let proxy; + + function addInstance(id, fields = {}, status = "running", factorioVersion = "2.1.19") { + const config = new lib.InstanceConfig("controller", { + "instance.id": id, + "instance.name": `Instance ${id}`, + "instance.assigned_host": 1, + "lobby.load_plugin": true, + ...fields, + }); + const instance = new InstanceRecord(config, status, 30000 + id, factorioVersion); + controller.instances.records.set(instance); + return instance; + } + + function setOnline(name, instanceId) { + const user = controller.users.getOrCreateUser(name); + user.instances = new Set(instanceId === undefined ? [] : [instanceId]); + controller.users.records.set(user); + } + + beforeEach(function () { + const config = new lib.ControllerConfig("controller", { + "controller.name": "Test Cluster", + "controller.public_url": "https://play.example.com/", + }); + controller = new Controller(lib.logger, [], config); + controller.hosts.set(new HostRecord(1, "Host", "2.0.0", new Map(), true, "10.0.0.2", "198.51.100.2")); + proxy = { + port: 34197, + serverVersions: new Map(), + instanceVersion: async id => proxy.serverVersions.get(id), + }; + controller.playerProxyServer = proxy; + started = []; + sent = []; + controller.sendTo = async (dst, message) => { + sent.push({ dst, message }); + if (message instanceof lib.InstanceStartRequest) { + started.push(dst.instanceId); + } + }; + lobby = new LobbyController(controller, new mock.MockLogger(), new MatchingServerClient( + new mock.MockLogger(), undefined, "https://matching.test", fakeMatchingServer().fetch, + )); + + addInstance(1, { "lobby.is_lobby": true }); + addInstance(2, { "lobby.is_lobby": true }, "running", "2.0.72"); + addInstance(3); + addInstance(4, { "instance.player_proxy_port": 34200 }, "stopped", "latest"); + addInstance(5, { "lobby.listed": false }); + }); + + const route = (fields = {}) => lobby.onPlayerProxyRoute({ + username: "Player", version: v2_1_19, clientAddress: "1.2.3.4", clientPort: 1, listenPort: 34197, ...fields, + }); + + it("should list instances that are not lobbies", function () { + setOnline("alice", 3); + const servers = lobby.serverList(); + assert.deepEqual(servers.map(s => s.id), [3, 4]); + assert.equal(servers[0].players, 1); + assert.equal(servers[0].version, "2.1.19"); + assert.equal(servers[1].version, null, "stopped instance with unknown version"); + }); + + it("should send players to the lobby for their version", async function () { + assert.equal(await route(), 1); + assert.equal(await route({ version: v2_0_72 }), 2); + assert.equal(await route({ version: { ...v2_1_19, sub: 1 } }), undefined); + }); + + it("should skip lobbies running a different build", async function () { + proxy.serverVersions.set(1, { ...v2_1_19, build: 1 }); + assert.equal(await route(), undefined); + }); + + it("should transfer a player to a running instance", async function () { + const status = await lobby.handleTransferRequest( + new TransferRequest("Player", 3), lib.Address.fromShorthand({ instanceId: 1 }) + ); + assert.equal(status.state, "ready"); + assert.equal(status.address, "play.example.com:34197"); + assert.equal(await route(), 3, "both connection attempts should go to the target"); + assert.equal(await route(), 3); + + await lobby.onPlayerEvent(controller.instances.getMutable(3), { type: "join", name: "player" }); + assert.equal(await route(), 1, "should go back to the lobby after joining"); + }); + + it("should start a stopped instance and tell the lobby when it's ready", async function () { + controller.instances.getMutable(4).config.set("factorio.version", "2.1.19"); + const status = await lobby.handleTransferRequest( + new TransferRequest("Player", 4), lib.Address.fromShorthand({ instanceId: 1 }) + ); + assert.equal(status.state, "starting"); + await lib.wait(0); + assert.deepEqual(started, [4]); + assert.equal(await route(), 1, "should wait in the lobby while starting"); + + const instance = controller.instances.getMutable(4); + instance.status = "running"; + await lobby.onInstanceStatusChanged(instance); + const event = sent.find(s => s.message instanceof TransferStatusEvent); + assert.deepEqual(event.dst, { instanceId: 1 }); + assert.equal(event.message.status.state, "ready"); + assert.equal(await route(), 4); + }); + + it("should report a transfer failing when the instance stops", async function () { + await lobby.handleTransferRequest( + new TransferRequest("Player", 4), lib.Address.fromShorthand({ instanceId: 1 }) + ); + await lobby.onInstanceStatusChanged(controller.instances.getMutable(4)); + const event = sent.find(s => s.message instanceof TransferStatusEvent); + assert.equal(event.message.status.state, "failed"); + assert.equal(lobby.transfers.size, 0); + }); + + it("should hold players for a stopped dedicated port instance in the lobby", async function () { + assert.equal(await route({ portInstanceId: 4 }), 1); + await lib.wait(0); + assert.deepEqual(started, [4]); + await lobby.onPlayerEvent(controller.instances.getMutable(1), { type: "join", name: "Player" }); + const event = sent.find(s => s.message instanceof TransferStatusEvent); + assert.equal(event.message.status.state, "starting"); + + assert.equal( + await route({ username: "Other", portInstanceId: 3 }), undefined, + "running instances are routed by the proxy", + ); + }); + + it("should build server browser listings", async function () { + controller.config.set("lobby.server_browser_listing", true); + controller.config.set("lobby.public_address", "203.0.113.5:40000"); + controller.instances.getMutable(4).config.set("lobby.server_browser_listing", true); + proxy.serverVersions.set(1, v2_1_19); + proxy.serverVersions.set(2, v2_0_72); + setOnline("alice", 3); + setOnline("bob", 1); + + let listings = await lobby.desiredListings(); + assert.deepEqual([...listings.keys()], ["cluster:2.1.19:84000", "cluster:2.0.72:83000"]); + const cluster = listings.get("cluster:2.1.19:84000"); + assert.equal(cluster.name, "Test Cluster"); + assert.equal(cluster.hostAddress, "203.0.113.5"); + assert.equal(cluster.hostPort, 40000); + assert.deepEqual(cluster.players, ["alice", "bob"]); + + proxy.serverVersions.set(4, v2_1_19); + listings = await lobby.desiredListings(); + const own = listings.get("instance:4"); + assert.equal(own.hostPort, 34200); + assert.equal(own.name, "Instance 4"); + }); + + it("should not list anything without the proxy", async function () { + controller.config.set("lobby.server_browser_listing", true); + controller.playerProxyServer = null; + assert.equal((await lobby.desiredListings()).size, 0); + }); + }); + + describe("instance plugin", function () { + let instance; + + beforeEach(async function () { + ({ instance } = await mock.loadInstancePlugin(instancePlugin.default, info)); + instance.mockConfigEntries.set("lobby.is_lobby", true); + instance.sendTo = async (dst, message) => { + if (message instanceof ServerListRequest) { + return [new LobbyServer(3, "Instance \"3\"", "running", "2.1.19", 0)]; + } + if (message instanceof TransferRequest) { + return new TransferStatus("ready", "Instance 3", "play.example.com:34197"); + } + throw new Error("unexpected message"); + }; + }); + + afterEach(async function () { + await instance?.hooks.stop.invoke(); + }); + + it("should configure the module and send the server list on start", async function () { + await instance.hooks.start.invoke(); + const commands = instance.server.rconCommands; + assert.equal(commands[0], "/sc lobby.configure(true, 7357)"); + assert(commands[1].startsWith("/sc lobby.set_servers(\""), commands[1]); + assert(commands[1].includes("Instance \\\\\\\"3\\\\\\\""), commands[1]); + }); + + it("should move players once their transfer is ready", async function () { + instance.server.emit("ipc-lobby:connect", { player: "Player", instance_id: 3 }); + await lib.wait(10); + assert.deepEqual(instance.server.rconCommands, [ + "/sc lobby.connect_player(\"Player\", \"play.example.com:34197\", \"Instance 3\")", + ]); + }); + + it("should show the status of a transfer that is starting", async function () { + const handler = mock.getHandler(instance, TransferStatusEvent); + await handler(new TransferStatusEvent("Player", 3, new TransferStatus("starting", "Instance 3"))); + assert.match(instance.server.rconCommands[0], /^\/sc lobby.set_player_status\("Player", "Starting/); + }); + }); +}); diff --git a/plugins/lobby/tsconfig.browser.json b/plugins/lobby/tsconfig.browser.json new file mode 100644 index 00000000..325fd71d --- /dev/null +++ b/plugins/lobby/tsconfig.browser.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.browser.json", + "references": [ + { "path": "../../packages/lib/tsconfig.browser.json" }, + { "path": "../../packages/web_ui/tsconfig.browser.json" }, + ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], +} diff --git a/plugins/lobby/tsconfig.json b/plugins/lobby/tsconfig.json new file mode 100644 index 00000000..e074e85f --- /dev/null +++ b/plugins/lobby/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" }, + ], +} diff --git a/plugins/lobby/tsconfig.node.json b/plugins/lobby/tsconfig.node.json new file mode 100644 index 00000000..55d31e46 --- /dev/null +++ b/plugins/lobby/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.node.json", + "references": [ + { "path": "../../packages/lib/tsconfig.node.json" }, + { "path": "../../packages/host/tsconfig.node.json" }, + { "path": "../../packages/controller/tsconfig.node.json" }, + ], + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/plugins/lobby/web/index.tsx b/plugins/lobby/web/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/plugins/lobby/webpack.config.cjs b/plugins/lobby/webpack.config.cjs new file mode 100644 index 00000000..6bc4de10 --- /dev/null +++ b/plugins/lobby/webpack.config.cjs @@ -0,0 +1,28 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}, argv = {}) => merge(common(env, argv), { + context: __dirname, + entry: "./web/index.tsx", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "lobby", + library: { type: "var", name: "plugin_lobby" }, + exposes: { + "./": "./index.ts", + "./package.json": "./package.json", + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, + }, + }), + ], +}); diff --git a/test/mock.js b/test/mock.js index 035a54fb..89a87086 100644 --- a/test/mock.js +++ b/test/mock.js @@ -102,6 +102,12 @@ export class MockServer extends events.EventEmitter { this.udpMessages.push(message); } + handle(eventName, handler) { + this.on(`ipc-${eventName}`, event => handler(event).catch(err => { + this.emit("error", err); + })); + } + async sendRcon(command) { this.rconCommands.push(command); let result = this.rconCommandResults.get(command); diff --git a/tsconfig.json b/tsconfig.json index 4fde2949..6cb89808 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "./packages/ctl/" }, { "path": "./packages/web_ui/" }, { "path": "./plugins/global_chat/" }, + { "path": "./plugins/lobby/" }, { "path": "./plugins/player_sync/" }, { "path": "./plugins/player_auth/" }, { "path": "./plugins/research_sync/" },