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.
This commit is contained in:
Danielv123 committed 2026-10-29 20:54:04 +01:00
1 parent b4856bdd55
commit 3ae486143d
22 files changed
+2013 -3

No files matched your search

+15
View File
@@ -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
+1
View File
@@ -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.
+68
View File
@@ -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.
+4 -3
View File
@@ -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);
+1
View File
@@ -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" },
+113
View File
@@ -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.
+451
View File
@@ -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<string, Transfer>();
private _listingTimer?: ReturnType<typeof setInterval>;
private _listingWarning?: string;
constructor(
public controller: Controller,
public logger: lib.Logger,
public matchingServer: MatchingServerClient,
) { }
get proxy() {
return this.controller.playerProxyServer;
}
static isLobby(instance: Readonly<InstanceRecord>) {
return Boolean(instance.config.get("lobby.load_plugin") && instance.config.get("lobby.is_lobby"));
}
static isRoutable(instance: Readonly<InstanceRecord> | undefined): instance is Readonly<InstanceRecord> {
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<InstanceRecord>) {
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<InstanceRecord> | 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<InstanceRecord>) {
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<LobbyServer[]> {
return this.serverList();
}
async handleTransferRequest(request: TransferRequest, src: lib.Address): Promise<TransferStatus> {
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<number | undefined> {
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<string, Listing>();
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();
}
+110
View File
@@ -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,
],
};
+100
View File
@@ -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<typeof setInterval> | undefined;
let lastServerListJson = "";
// Commands are sent one at a time so they execute in order
let rconQueue: Promise<unknown> = 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);
});
}
+270
View File
@@ -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<string, ActiveListing>();
/** Server secrets by key, kept after a listing is removed so it gets the same server_id back */
private _serverSecrets = new Map<string, string>();
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<string, StoredListing>();
}
this._loaded = true;
let stored: Record<string, StoredListing> = {};
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<string, StoredListing & { removed?: boolean }> = {};
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<string, Listing>, 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());
}
}
+116
View File
@@ -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<typeof LobbyServer.jsonSchema>): 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<typeof TransferStatus.jsonSchema>): 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<typeof TransferRequest.jsonSchema>): 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<typeof TransferStatusEvent.jsonSchema>): TransferStatusEvent {
return new this(json.player, json.instanceId, TransferStatus.fromJSON(json.status));
}
}
+261
View File
@@ -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<string, string> 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
+4
View File
@@ -0,0 +1,4 @@
{
"name": "lobby",
"load": ["lobby.lua"]
}
+64
View File
@@ -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:"
}
}
+375
View File
@@ -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/);
});
});
});
+8
View File
@@ -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" ],
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" },
],
}
+10
View File
@@ -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/*"],
}
View File
Whitespace-only changes.
+28
View File
@@ -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 },
},
}),
],
});
+6
View File
@@ -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);
+1
View File
@@ -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/" },