diff --git a/CHANGELOG.md b/CHANGELOG.md index 39225c5d..115a8cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,12 +36,13 @@ Many thanks to the following for contributing to this release: ### 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. It listens on port 34197 by default. 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. +- 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, creating the lobby on a host with that version if there is none, and can list the cluster and individual instances, including stopped ones, in the Factorio server browser. ### Features - Added the `chooseSave` instance hook for plugins to pick or create the save an instance starts with, and `setSaveScenario` in `@clusterio/host` to make the save patcher stop loading the freeplay scenario of a save. -- Added the `playerProxyRoute` controller hook for plugins to decide which instance a player connecting through the player proxy goes to. +- Added the `playerProxyRoute` controller hook for plugins to decide which instance a player connecting through the player proxy goes to, given the name, Factorio version and mods of the player. +- Added `HostFactorioVersionsRequest` for the controller to ask a host which Factorio versions it has installed and whether it can download others. ### Changes diff --git a/docs/player-proxy.md b/docs/player-proxy.md index dd4333ce..493e3a45 100644 --- a/docs/player-proxy.md +++ b/docs/player-proxy.md @@ -29,7 +29,7 @@ If the port is taken, for example by an instance of a host on the same machine, ## 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: +The proxy answers the first message itself with the version of the client, waits for the name and list of enabled mods, 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. @@ -57,6 +57,7 @@ This lets each instance have its own address while still being started on demand 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. + Lobbies are created on demand on a host with that Factorio version, so different versions can be run behind the same port without configuring anything. 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. diff --git a/packages/controller/src/PlayerProxyServer.ts b/packages/controller/src/PlayerProxyServer.ts index 40e0193a..16007c6f 100644 --- a/packages/controller/src/PlayerProxyServer.ts +++ b/packages/controller/src/PlayerProxyServer.ts @@ -28,6 +28,8 @@ export interface PlayerProxyRouteRequest { listenPort: number; /** Instance the proxy port is dedicated to, if any */ portInstanceId?: number; + /** Mods enabled in the client, including built in mods like base, or undefined if they couldn't be read */ + mods?: protocol.ModId[]; } export interface PlayerProxyRoute { @@ -56,6 +58,7 @@ type ClientSession = { /** Fragments of the client's ConnectionRequestReplyConfirm by fragment number */ confirmFragments: Map; confirmMessageId?: number; + confirmLastFragment?: number; confirmServerRequestIdOffset?: number; state: "handshake" | "routing" | "connecting" | "connected" | "failed"; route?: PlayerProxyRoute; @@ -367,6 +370,7 @@ export default class PlayerProxyServer { if (session.confirmMessageId !== undefined && session.confirmMessageId !== header.messageId) { // The client is sending a different confirm message, start over with it session.confirmFragments.clear(); + session.confirmLastFragment = undefined; } session.confirmMessageId = header.messageId; session.confirmServerRequestIdOffset = confirm.serverRequestIdOffset; @@ -384,8 +388,17 @@ export default class PlayerProxyServer { if (session.confirmFragments.size < MAX_PENDING_PACKETS) { session.confirmFragments.set(fragmentNumber, message); } + if (!header.fragmented || header.lastFragment) { + session.confirmLastFragment = fragmentNumber; + } - if (session.state === "handshake" && session.username !== undefined && session.request) { + // Routing waits for the whole message as plugins may use the mods of the client listed in it + if ( + session.state === "handshake" + && session.username !== undefined + && session.request + && this._confirmComplete(session) + ) { session.state = "routing"; this._routeSession(session).catch(err => { this._logger.error(`Unexpected error routing player ${session.username}:\n${err.stack}`); @@ -394,6 +407,28 @@ export default class PlayerProxyServer { } } + private _confirmComplete(session: ClientSession) { + if (session.confirmLastFragment === undefined) { + return false; + } + for (let i = 0; i <= session.confirmLastFragment; i++) { + if (!session.confirmFragments.has(i)) { + return false; + } + } + return true; + } + + private _confirmMods(session: ClientSession) { + const fragments = [...session.confirmFragments].sort(([a], [b]) => a - b).map(([, fragment]) => fragment); + try { + return protocol.parseConfirmMods(protocol.joinPayload(fragments)); + } catch (err: any) { + this._logger.verbose(`Unable to read mods of ${session.username}: ${err.message}`); + return undefined; + } + } + private _patchConfirm(session: ClientSession, message: Buffer, fragmentNumber: number) { if (fragmentNumber !== 0 || session.confirmServerRequestIdOffset === undefined) { return message; @@ -411,6 +446,7 @@ export default class PlayerProxyServer { clientPort: session.clientPort, listenPort: session.listener.port, portInstanceId: session.listener.instanceId, + mods: this._confirmMods(session), }; const instanceId = await this._controller.resolvePlayerRoute(request); diff --git a/packages/controller/src/proxy_protocol.ts b/packages/controller/src/proxy_protocol.ts index d166c622..7b419478 100644 --- a/packages/controller/src/proxy_protocol.ts +++ b/packages/controller/src/proxy_protocol.ts @@ -267,6 +267,54 @@ export function parseConnectionRequestReplyConfirm(buffer: Buffer): ConnectionRe return { clientRequestId, serverRequestId, serverRequestIdOffset, username }; } +/** Mod enabled in a Factorio client */ +export interface ModId { + name: string; + version: string; +} + +/** + * Join the payloads of the fragments of a message + * + * @param fragments - + * Fragments of the message in order of fragment number, or the message + * if it's not fragmented. + * @returns the payload of the message. + */ +export function joinPayload(fragments: Buffer[]) { + return Buffer.concat(fragments.map(fragment => fragment.subarray(parseHeader(fragment).payloadOffset))); +} + +/** + * Read the mods enabled in the client from a ConnectionRequestReplyConfirm + * + * @param payload - Complete payload of the message, see {@link joinPayload}. + * @returns name and version of each mod. + */ +export function parseConfirmMods(payload: Buffer): ModId[] { + const reader = new BufferReader(payload); + reader.readUInt32LE(); // connectionRequestIDGeneratedOnClient + reader.readUInt32LE(); // connectionRequestIDGeneratedOnServer + reader.readUInt32LE(); // instanceID + reader.readString(); // username + reader.readString(); // password + reader.readString(); // serverKey + reader.readString(); // serverKeyTimestamp + reader.readUInt32LE(); // coreChecksum + reader.readUInt32LE(); // prototypeListChecksum + const count = reader.readSpaceOptimizedUInt32(); + const mods: ModId[] = []; + for (let i = 0; i < count; i++) { + const name = reader.readString(); + const version = [ + reader.readSpaceOptimizedUInt16(), reader.readSpaceOptimizedUInt16(), reader.readSpaceOptimizedUInt16(), + ].join("."); + reader.readUInt32LE(); // crc + mods.push({ name, version }); + } + return mods; +} + export function encodeConnectionRequest(version: ApplicationVersion, clientRequestId: number, messageId = 0) { const writer = new BufferWriter(); writer.writeUInt8(MessageType.ConnectionRequest); diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index aca92062..70294a67 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -15,6 +15,7 @@ import { logger } from "@clusterio/lib"; import type { HostConnector } from "../host.js"; import Instance from "./Instance.js"; import InstanceConnection from "./InstanceConnection.js"; +import { listFactorioVersions } from "./server.js"; import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js"; @@ -462,6 +463,7 @@ export default class Host extends lib.Link { this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this)); this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this)); this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this)); + this.handle(lib.HostFactorioVersionsRequest, this.handleHostFactorioVersionsRequest.bind(this)); this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this)); this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this)); this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this)); @@ -1020,6 +1022,23 @@ export default class Host extends lib.Link { return null; } + async handleHostFactorioVersionsRequest() { + let installed: Awaited>; + try { + installed = await listFactorioVersions(this.config.get("host.factorio_directory")); + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } + installed = { direct: false, versions: new Set() }; + } + return { + versions: [...installed.versions], + // Matches the conditions for downloads in FactorioServer.checkForUpdates + canDownload: !installed.direct && process.platform === "linux", + }; + } + async handleHostMetricsRequest() { let requests: Promise>[] = []; for (let instanceConnection of this.instanceConnections.values()) { diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index df3e85f7..3f30da92 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) { * @param factorioDir - Path to Factorio installation dirs. * @returns Set of installed factorio versions */ -async function listFactorioVersions(factorioDir: string) { +export async function listFactorioVersions(factorioDir: string) { const versions = new Set(); const directVersion = await getFactorioVersion(factorioDir); diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index 41e8e107..400fc0ee 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -36,6 +36,19 @@ export class HostUpdateRequest { static permission = "core.host.update" as const; } +/** Factorio versions installed on a host, used by the controller to place instances */ +export class HostFactorioVersionsRequest { + declare ["constructor"]: typeof HostFactorioVersionsRequest; + static type = "request" as const; + static src = "controller" as const; + static dst = "host" as const; + static Response = plainJson(Type.Object({ + "versions": Type.Array(Type.String()), + /** True if the host downloads versions that are not installed when an instance needs them */ + "canDownload": Type.Boolean(), + })); +} + export class HostConfigGetRequest { declare ["constructor"]: typeof HostConfigGetRequest; static type = "request" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index c4d95dcb..7b47449f 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -56,6 +56,7 @@ export const dataClasses: (RequestClass | EventClass) host.HostInfoUpdateEvent, host.HostUpdatesEvent, host.HostMetricsRequest, + host.HostFactorioVersionsRequest, host.ControllerConnectionEvent, host.PrepareControllerDisconnectRequest, host.SyncUserListsEvent, diff --git a/plugins/lobby/README.md b/plugins/lobby/README.md index 116ec079..30d3c32e 100644 --- a/plugins/lobby/README.md +++ b/plugins/lobby/README.md @@ -4,7 +4,9 @@ Lobby servers where players pick the instance to join, and listing of the cluste 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`. + The lobby shows a list of the instances in the cluster running that version, picking one moves the player there with `LuaPlayer.connect_to_server`. + Instances on other versions can be shown greyed out with a checkbox. +- If there's no lobby for the version and built in mods of a player, one is created on a host that has that version of Factorio installed, or can download it. - 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. - If the [Player Auth](../player_auth/README.md) plugin is loaded the list has a button for logging into the web interface, which does the same as the `/web-login` command. @@ -26,6 +28,13 @@ Substitute clusteriocontroller with clusteriohost or clusterioctl if this a dedi ## Setting up a lobby +Lobbies are created automatically the first time a player connects with a Factorio version and set of built in mods (base, Space Age and so on) no lobby runs. +The lobby is created on a connected host that has the version installed, or a Linux host with a versioned `host.factorio_directory` that can download it, with a mod pack that has the same built in mods as the player. +It's named after the version, has `instance.auto_start` set and is kept running. +Players with mods that are not built in are asked by Factorio to sync their mods to the lobby, and again to the instance they pick. +On a fast host the lobby is up before the Factorio client gives up on the connection, otherwise the first player has to connect again once it's running. +Set `lobby.auto_create` to false to only use lobbies you create yourself: + 1. Make sure the player proxy is enabled, it listens on `controller.player_proxy_port` which defaults to 34197. 2. Set `controller.player_proxy_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, and what's published in the server browser. @@ -54,6 +63,12 @@ Players joining it while it's stopped wait in a lobby running their version unti ## Controller Configuration +### lobby.auto_create + +Create a lobby when a player connects to the player proxy with a Factorio version and built in mods no lobby runs. + +Defaults to true. + ### lobby.server_browser_listing List the cluster in the Factorio server browser as one game for each Factorio version a lobby is running. diff --git a/plugins/lobby/controller.ts b/plugins/lobby/controller.ts index 0f004d5b..c80e5426 100644 --- a/plugins/lobby/controller.ts +++ b/plugins/lobby/controller.ts @@ -115,14 +115,42 @@ export class LobbyController { return servers.sort((a, b) => a.name.localeCompare(b.name)); } + /** + * Built in mods besides base enabled in a mod list, as a comparable key + * + * @param version - Factorio version the mods are for. + * @param mods - Mods to check. + * @returns sorted comma separated names. + */ + static builtinModsKey(version: string, mods: Iterable<{ name: string, enabled?: boolean }>) { + const builtin = new Set(lib.ModPack.getBuiltinModNames(version as lib.PartialVersion)); + builtin.delete("base"); + return [...mods] + .filter(mod => mod.enabled !== false && builtin.has(mod.name)) + .map(mod => mod.name) + .sort() + .join(","); + } + + /** Mod pack an instance runs with */ + instanceModPack(instance: Readonly) { + const id = instance.config.get("factorio.mod_pack_id") + ?? this.controller.config.get("controller.default_mod_pack_id"); + return id === null ? undefined : this.controller.modPacks.get(id); + } + /** * Find a lobby running the given Factorio version * * @param version - Version of the client. + * @param mods - + * Mods enabled in the client, if known the lobby must have the same + * built in mods enabled for the client to be able to join it. * @returns a running lobby if there is one, otherwise a stopped one configured for this version. */ - pickLobby(version: ApplicationVersion) { + pickLobby(version: ApplicationVersion, mods?: proxyProtocol.ModId[]) { const wanted = formatVersion(version); + const wantedMods = mods ? LobbyController.builtinModsKey(wanted, mods) : undefined; let stopped: Readonly | undefined; for (const instance of this.controller.instances.values()) { if (!LobbyController.isRoutable(instance) || !LobbyController.isLobby(instance)) { @@ -132,6 +160,12 @@ export class LobbyController { if (seen && formatVersion(seen) === wanted && seen.build !== version.build) { continue; } + if (wantedMods !== undefined) { + const modPack = this.instanceModPack(instance); + if (modPack && LobbyController.builtinModsKey(wanted, modPack.mods.values()) !== wantedMods) { + continue; + } + } if (instance.status === "running" && instance.factorioVersion === wanted) { return instance; } @@ -142,6 +176,151 @@ export class LobbyController { return stopped; } + private _hostVersions = new Map, canDownload: boolean, fetchedAtMs: number }>(); + + /** Factorio versions installed on a host, cached for a minute */ + async hostVersions(hostId: number) { + const cached = this._hostVersions.get(hostId); + if (cached && cached.fetchedAtMs > Date.now() - 60_000) { + return cached; + } + const response: { versions: string[], canDownload: boolean } = await this.controller.sendTo( + { hostId }, new lib.HostFactorioVersionsRequest() + ); + const result = { + versions: new Set(response.versions), + canDownload: response.canDownload, + fetchedAtMs: Date.now(), + }; + this._hostVersions.set(hostId, result); + return result; + } + + /** + * Pick a connected host that can run the given Factorio version + * + * @param version - Full Factorio version. + * @returns ID of a host with the version installed, or one that can download it. + */ + async pickHost(version: string) { + let downloadHost: number | undefined; + for (const host of this.controller.hosts.values()) { + if (!host.connected || host.isDeleted) { + continue; + } + let versions; + try { + versions = await this.hostVersions(host.id); + } catch (err: any) { + this.logger.verbose(`Unable to get Factorio versions of host ${host.id}: ${err.message}`); + continue; + } + if (versions.versions.has(version)) { + return host.id; + } + if (versions.canDownload && downloadHost === undefined) { + downloadHost = host.id; + } + } + if (downloadHost !== undefined) { + const releases = await this.controller.factorioVersions.get().catch(() => []); + if (releases.some(release => release.version === version)) { + return downloadHost; + } + } + return undefined; + } + + /** + * Find a mod pack for the version with the given built in mods, creating one if needed + * + * @param version - Full Factorio version. + * @param modsKey - Built in mods as given by {@link LobbyController.builtinModsKey}. + * @returns the mod pack. + */ + lobbyModPack(version: string, modsKey: string) { + const [major, minor] = version.split("."); + const majorMinor = `${major}.${minor}`; + const wanted = new Set(modsKey ? modsKey.split(",") : []); + const matches = (pack: Readonly) => ( + (pack.factorioVersion === version || pack.factorioVersion === majorMinor) + && LobbyController.builtinModsKey(version, pack.mods.values()) === modsKey + // Lobbies get only built in mods, players with other mods are asked by Factorio to sync to them + && [...pack.mods.values()].every(mod => !mod.enabled || mod.name === "base" || wanted.has(mod.name)) + ); + for (const pack of this.controller.modPacks.values()) { + if (!pack.isDeleted && matches(pack)) { + return pack; + } + } + const builtin = lib.ModPack.getBuiltinMods(majorMinor as lib.PartialVersion) + .filter(mod => mod.name !== "base"); + const pack = lib.ModPack.fromJSON({ + name: `Lobby ${majorMinor}${wanted.size ? ` (${[...wanted].join(", ")})` : ""}`, + description: "Created by the lobby plugin for lobbies of players with these built in mods.", + factorio_version: majorMinor, + mods: builtin.map(mod => ({ ...mod, enabled: wanted.has(mod.name) })), + } as any); + this.controller.modPacks.set(pack); + this.logger.info(`Created mod pack ${pack.name} for lobbies`); + return pack; + } + + private _creatingLobbies = new Map>(); + + /** + * Create a lobby for a Factorio version and set of built in mods + * + * Concurrent calls for the same version and mods share one lobby. + * + * @param version - Version of the client. + * @param mods - Mods enabled in the client. + * @returns ID of the lobby created, or undefined if no host can run the version. + */ + async createLobby(version: ApplicationVersion, mods?: proxyProtocol.ModId[]) { + const full = formatVersion(version); + // Without the mod list assume all built in mods, as the expansion is the most common setup + const modsKey = LobbyController.builtinModsKey( + full, mods ?? lib.ModPack.getBuiltinMods(full as lib.PartialVersion).map(mod => ({ name: mod.name })), + ); + const key = `${full}|${modsKey}`; + let creating = this._creatingLobbies.get(key); + if (!creating) { + creating = this._createLobby(full, modsKey).finally(() => this._creatingLobbies.delete(key)); + this._creatingLobbies.set(key, creating); + } + return await creating; + } + + private async _createLobby(version: string, modsKey: string) { + const hostId = await this.pickHost(version); + if (hostId === undefined) { + this.logger.verbose(`No host can run Factorio ${version}, not creating a lobby for it`); + return undefined; + } + const modPack = this.lobbyModPack(version, modsKey); + const names = new Set([...this.controller.instances.values()].map(i => i.config.get("instance.name"))); + const base = modsKey.includes("space-age") ? `Lobby ${version}` : `Lobby ${version} (${modsKey || "base"})`; + let name = base; + for (let i = 2; names.has(name); i++) { + name = `${base} ${i}`; + } + + const config = new lib.InstanceConfig("controller", { + "instance.name": name, + "instance.auto_start": true, + "factorio.version": version, + "factorio.mod_pack_id": modPack.id, + // The cluster is listed in the server browser by this plugin, not each lobby + "factorio.settings": { visibility: { public: false, lan: false } }, + "lobby.is_lobby": true, + }); + const instance = await this.controller.instances.createInstance(config); + await this.controller.instances.assignInstance(instance.id, hostId); + this.logger.info(`Created lobby ${name} on host ${hostId} for players with Factorio ${version}`); + return instance.id; + } + /** Address players use to connect to an instance, through the proxy if it's enabled */ connectAddress(instance: Readonly) { if (this.proxy) { @@ -281,7 +460,7 @@ export class LobbyController { return transfer.instanceId; } if (transfer?.state === "starting") { - return transfer.lobbyId ?? this.pickLobby(request.version)?.id; + return transfer.lobbyId ?? await this.lobbyFor(request); } if (request.portInstanceId !== undefined) { @@ -290,15 +469,27 @@ export class LobbyController { 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) { + const lobbyId = await this.lobbyFor(request); + if (lobbyId === undefined) { return undefined; } - this.startTransfer(request.username, target.id, lobby.id); - return lobby.id; + this.startTransfer(request.username, target.id, lobbyId); + return lobbyId; } - return this.pickLobby(request.version)?.id; + return await this.lobbyFor(request); + } + + /** Lobby for the version and mods of a player, created if needed and lobby.auto_create is enabled */ + async lobbyFor(request: PlayerProxyRouteRequest) { + const lobby = this.pickLobby(request.version, request.mods); + if (lobby) { + return lobby.id; + } + if (!this.controller.config.get("lobby.auto_create")) { + return undefined; + } + return await this.createLobby(request.version, request.mods); } /** Build the server browser listings that should exist */ diff --git a/plugins/lobby/index.ts b/plugins/lobby/index.ts index 9d99f048..bc3d46a1 100644 --- a/plugins/lobby/index.ts +++ b/plugins/lobby/index.ts @@ -4,6 +4,7 @@ import * as messages from "./messages.js"; declare module "@clusterio/lib" { export interface ControllerConfigFields { + "lobby.auto_create": boolean; "lobby.server_browser_listing": boolean; "lobby.server_browser_name": string | null; "lobby.server_browser_description": string; @@ -27,6 +28,14 @@ export const plugin: lib.PluginDeclaration = { instanceEntrypoint: "dist/node/instance.js", controllerConfigFields: { + "lobby.auto_create": { + title: "Create Lobbies", + description: + "Create a lobby when a player connects to the player proxy with a Factorio version and built in " + + "mods no lobby runs, on a connected host that has that version installed or can download it.", + type: "boolean", + initialValue: true, + }, "lobby.server_browser_listing": { title: "Server Browser Listing", description: diff --git a/plugins/lobby/module/lobby.lua b/plugins/lobby/module/lobby.lua index 06d45773..8360aedc 100644 --- a/plugins/lobby/module/lobby.lua +++ b/plugins/lobby/module/lobby.lua @@ -15,6 +15,7 @@ local mod_gui = require("mod-gui") --- @field web_login boolean Show button for logging into the web interface with the player_auth plugin --- @field servers LobbyServer[] --- @field status table Status text by player name +--- @field show_other_versions table Players listing servers on other Factorio versions local status_captions = { running = "Online", @@ -35,6 +36,7 @@ local function get_script_data() end data.servers = data.servers or {} data.status = data.status or {} + data.show_other_versions = data.show_other_versions or {} if data.is_lobby == nil then data.is_lobby = false end @@ -59,8 +61,16 @@ local function unjoinable_reason(data, server) return nil end +--- True if the server runs a different Factorio version than this one +--- @param server LobbyServer +--- @return boolean +local function other_version(server) + return server.version ~= nil and server.version ~= compat.active_mods["base"] +end + --- @param player LuaPlayer --- @param data LobbyScriptData +--- @param content LuaGuiElement local function build_content(player, data, content) content.clear() local status = data.status[player.name] @@ -71,28 +81,65 @@ local function build_content(player, data, content) 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 + -- Servers on other versions can't be joined from here, so they are only listed if asked for + local show_other = data.show_other_versions[player.name] == true + local shown = {} + local other_count = 0 + for _, server in ipairs(data.servers) do + if not other_version(server) then + shown[#shown + 1] = server + else + other_count = other_count + 1 + end + end + if show_other then + for _, server in ipairs(data.servers) do + if other_version(server) then + shown[#shown + 1] = server + end + end 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 }, + if #shown == 0 then + content.add { + type = "label", + caption = "There are no servers running Factorio " .. compat.active_mods["base"] .. " to join right now.", } + else + local servers = content.add { + type = "table", name = "lobby_servers", column_count = 5, style = "bordered_table", + } + servers.style.minimal_width = 480 + for _, caption in ipairs({ "Server", "Version", "Players", "Status", "" }) do + servers.add { type = "label", caption = caption, style = "caption_label" } + end + for _, server in ipairs(shown) do + local reason = unjoinable_reason(data, server) + local name = servers.add { type = "label", caption = server.name } + servers.add { type = "label", caption = server.version or "" } + servers.add { type = "label", caption = tostring(server.players) } + servers.add { type = "label", caption = status_captions[server.status] or server.status } + 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 }, + } + if other_version(server) then + name.style.font_color = { 0.5, 0.5, 0.5 } + end + end + end + + if other_count > 0 then + local checkbox = content.add { + type = "checkbox", + name = "lobby_show_other_versions", + caption = "Show " .. other_count .. " servers on other Factorio versions", + state = show_other, + } + checkbox.style.top_margin = 8 end end @@ -216,6 +263,16 @@ lobby_module.events[defines.events.on_gui_click] = function(event) end end +lobby_module.events[defines.events.on_gui_checked_state_changed] = function(event) + local element = event.element + if not element.valid or element.name ~= "lobby_show_other_versions" then + return + end + local player = game.get_player(event.player_index) + get_script_data().show_other_versions[player.name] = element.state or nil + open_browser(player) +end + -- Interface used by the instance plugin through RCON lobby = {} diff --git a/plugins/lobby/test/plugin.js b/plugins/lobby/test/plugin.js index 892da542..2c5364bb 100644 --- a/plugins/lobby/test/plugin.js +++ b/plugins/lobby/test/plugin.js @@ -327,6 +327,107 @@ describe("lobby plugin", function () { controller.playerProxyServer = null; assert.equal((await lobby.desiredListings()).size, 0); }); + + describe("lobby creation", function () { + const spaceAge = ["base", "elevated-rails", "quality", "space-age"] + .map(name => ({ name, version: "2.0.77" })); + const vanilla = [{ name: "base", version: "2.0.77" }, { name: "some-mod", version: "1.0.0" }]; + const v2_0_77 = { major: 2, minor: 0, sub: 77, build: 83500 }; + let hostVersions; + let assigned; + + beforeEach(function () { + for (const pack of lib.ModPack.defaultModPacks) { + controller.modPacks.set(lib.ModPack.fromJSON(pack.toJSON())); + } + hostVersions = new Map([[1, { versions: ["2.1.19", "2.0.77"], canDownload: false }]]); + controller.hosts.set(new HostRecord(2, "Linux", "2.0.0", new Map(), true, "10.0.0.3")); + hostVersions.set(2, { versions: [], canDownload: true }); + const sendTo = controller.sendTo; + controller.sendTo = async (dst, message) => { + if (message instanceof lib.HostFactorioVersionsRequest) { + return hostVersions.get(dst.hostId); + } + return await sendTo(dst, message); + }; + controller.factorioVersions.get = async () => [{ version: "2.0.60" }]; + assigned = []; + controller.instances.assignInstance = async (instanceId, hostId) => { + assigned.push([instanceId, hostId]); + const instance = controller.instances.getMutable(instanceId); + instance.config.set("instance.assigned_host", hostId); + instance.status = "stopped"; + }; + }); + + it("should create a lobby on a host with the version", async function () { + const id = await route({ version: v2_0_77, mods: spaceAge }); + const instance = controller.instances.get(id); + assert.equal(instance.config.get("instance.name"), "Lobby 2.0.77"); + assert.equal(instance.config.get("lobby.is_lobby"), true); + assert.equal(instance.config.get("factorio.version"), "2.0.77"); + assert.equal(instance.config.get("instance.auto_start"), true); + assert.equal(instance.config.get("factorio.settings").visibility.public, false); + assert.deepEqual(assigned, [[id, 1]]); + const pack = controller.modPacks.get(instance.config.get("factorio.mod_pack_id")); + assert.equal(pack.name, "Space Age 2.0", "should reuse the default mod pack"); + + assert.equal(await route({ version: v2_0_77, mods: spaceAge }), id, "should reuse the lobby"); + }); + + it("should create one lobby for players connecting at the same time", async function () { + const ids = await Promise.all([ + route({ version: v2_0_77, mods: spaceAge }), + route({ username: "Other", version: v2_0_77, mods: spaceAge }), + ]); + assert.equal(ids[0], ids[1]); + assert.equal(assigned.length, 1); + }); + + it("should create a separate lobby for other built in mods", async function () { + const expansion = await route({ version: v2_0_77, mods: spaceAge }); + const base = await route({ version: v2_0_77, mods: vanilla }); + assert.notEqual(base, expansion); + const instance = controller.instances.get(base); + assert.equal(instance.config.get("instance.name"), "Lobby 2.0.77 (base)"); + const pack = controller.modPacks.get(instance.config.get("factorio.mod_pack_id")); + assert.equal(pack.name, "Base Game 2.0"); + }); + + it("should create a mod pack if none matches", async function () { + const quality = [{ name: "base", version: "2.0.77" }, { name: "quality", version: "2.0.77" }]; + const id = await route({ version: v2_0_77, mods: quality }); + const pack = controller.modPacks.get(controller.instances.get(id).config.get("factorio.mod_pack_id")); + assert.equal(pack.name, "Lobby 2.0 (quality)"); + assert.equal(pack.factorioVersion, "2.0"); + assert.deepEqual( + [...pack.mods.values()].filter(mod => mod.enabled && mod.name !== "base").map(mod => mod.name), + ["quality"], + ); + }); + + it("should use a host that can download the version", async function () { + const id = await route({ version: { major: 2, minor: 0, sub: 60, build: 1 }, mods: [] }); + assert.deepEqual(assigned, [[id, 2]]); + assert.equal(await route({ version: { major: 2, minor: 0, sub: 1, build: 1 } }), undefined); + }); + + it("should not create lobbies when disabled", async function () { + controller.config.set("lobby.auto_create", false); + assert.equal(await route({ version: v2_0_77, mods: spaceAge }), undefined); + assert.equal(assigned.length, 0); + }); + + it("should only pick lobbies with the same built in mods", async function () { + controller.config.set("controller.default_mod_pack_id", [...controller.modPacks.values()] + .find(pack => pack.name === "Space Age 2.1").id); + const mods = ["base", "elevated-rails", "quality", "recycler", "space-age"] + .map(name => ({ name, version: "2.1.19" })); + assert.equal(await route({ mods }), 1); + controller.config.set("lobby.auto_create", false); + assert.equal(await route({ mods: [{ name: "base", version: "2.1.19" }] }), undefined); + }); + }); }); describe("instance plugin", function () { diff --git a/test/controller/PlayerProxyServer.test.js b/test/controller/PlayerProxyServer.test.js index b38d4ff3..4dff4c7a 100644 --- a/test/controller/PlayerProxyServer.test.js +++ b/test/controller/PlayerProxyServer.test.js @@ -7,6 +7,11 @@ import { Controller, HostRecord, InstanceRecord, PlayerProxyServer, proxyProtoco const { MessageType } = proxyProtocol; const clientVersion = { major: 2, minor: 0, sub: 72, build: 84292 }; +const defaultMods = [ + { name: "base", version: "2.0.72" }, + { name: "space-age", version: "2.0.72" }, + { name: "big-mod", version: "1.300.2" }, +]; /** * Encode a ConnectionRequestReplyConfirm the way a Factorio client does @@ -14,18 +19,37 @@ const clientVersion = { major: 2, minor: 0, sub: 72, build: 84292 }; * @param {object} options - fields of the message. * @returns {Buffer[]} the message split into the given number of fragments. */ -function encodeConfirm({ clientRequestId, serverRequestId, username, messageId = 1, fragments = 1, padding = 40 }) { +function encodeConfirm({ + clientRequestId, serverRequestId, username, messageId = 1, fragments = 1, mods = defaultMods, +}) { const payload = new proxyProtocol.BufferWriter(); + const writeString = text => { + const bytes = Buffer.from(text); + payload.writeUInt8(bytes.length); + for (const byte of bytes) { + payload.writeUInt8(byte); + } + }; payload.writeUInt32LE(clientRequestId); payload.writeUInt32LE(serverRequestId); payload.writeUInt32LE(1234); // instanceID - const name = Buffer.from(username); - payload.writeUInt8(name.length); - for (const byte of name) { - payload.writeUInt8(byte); + writeString(username); + writeString(""); // password + writeString("server-key"); + writeString("1700000000"); + payload.writeUInt32LE(0x11111111); // coreChecksum + payload.writeUInt32LE(0x22222222); // prototypeListChecksum + payload.writeUInt8(mods.length); + for (const mod of mods) { + writeString(mod.name); + for (const part of mod.version.split(".")) { + payload.writeSpaceOptimizedUInt16(Number(part)); + } + payload.writeUInt32LE(0x33333333); // crc } - for (let i = 0; i < padding; i++) { - payload.writeUInt8(i); // stands in for the password, keys, checksums and mods + // Stands in for the startup mod settings + for (let i = 0; i < 40; i++) { + payload.writeUInt8(i); } const body = payload.toBuffer(); @@ -193,6 +217,11 @@ describe("controller/proxy_protocol", function () { assert.throws(() => proxyProtocol.parseConnectionRequestReplyConfirm(second), /first fragment/); }); + it("should read the mods from a fragmented confirm", function () { + const fragments = encodeConfirm({ clientRequestId: 1, serverRequestId: 2, username: "Player", fragments: 3 }); + assert.deepEqual(proxyProtocol.parseConfirmMods(proxyProtocol.joinPayload(fragments)), defaultMods); + }); + it("should reject truncated messages", function () { assert.throws(() => proxyProtocol.parseConnectionRequest(Buffer.from([MessageType.ConnectionRequest, 0]))); }); @@ -267,6 +296,28 @@ describe("controller/PlayerProxyServer", function () { assert.equal(proxy.serverVersions.get(1).build, clientVersion.build); }); + it("should route once the whole confirm has arrived", async function () { + const requests = []; + controller.hooks.playerProxyRoute.attach("test", request => { + requests.push(request); + return 1; + }); + const client = await newClient(); + client.send(proxyProtocol.encodeConnectionRequest(clientVersion, 7)); + const reply = proxyProtocol.parseConnectionRequestReply(await client.receive()); + const [first, second, third] = encodeConfirm({ + clientRequestId: 7, serverRequestId: reply.serverRequestId, username: "Player", messageId: 7, fragments: 3, + }); + client.send(third); + client.send(first); + await lib.wait(50); + assert.equal(requests.length, 0, "routed before all fragments arrived"); + client.send(second); + assert.equal((await client.receive())[0], MessageType.ConnectionAcceptOrDeny); + assert.deepEqual(requests[0].mods, defaultMods); + assert.equal(servers[0].confirms().length, 3); + }); + it("should route by the playerProxyRoute hook", async function () { controller.hooks.playerProxyRoute.attach("test", request => { assert.deepEqual(request.version, clientVersion);