diff --git a/.eslintrc.js b/.eslintrc.js index 0764886b..3cf36d34 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -288,6 +288,7 @@ module.exports = { ":": "before", "||": "before", "&&": "before", + "??": "before", }, }], "padded-blocks": "off", diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 436efe1b..a975fd09 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -599,7 +599,9 @@ export default class Host extends lib.Link { let instanceConnection = this.instanceConnections.get(instanceId); this.send( new lib.InstanceStatusChangedEvent( - instanceId, instanceConnection ? instanceConnection.instance.status : "stopped", undefined + instanceId, + instanceConnection ? instanceConnection.instance.status : "stopped", + instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId), ) ); @@ -607,6 +609,10 @@ export default class Host extends lib.Link { await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); // save a copy of the instance config + await this.saveInstanceConfig(instanceInfo); + } + + async saveInstanceConfig(instanceInfo: { path: string, config: lib.InstanceConfig }) { let warnedOutput = { _warning: "Changes to this file will be overwritten by the controller's copy.", ...instanceInfo.config.toJSON(), @@ -647,6 +653,50 @@ export default class Host extends lib.Link { return instanceInfo; } + /** + * Retrieved assigned port of the given instance + * @param instanceId - ID of the instance to get the assigned game port of: + * @returns Assigned game port or undefined if it does not exist. + */ + gamePort(instanceId: number) { + const instance = this.discoveredInstanceInfos.get(instanceId); + if (!instance) { + return undefined; + } + return ( + instance.config.get("factorio.game_port") + ?? instance.config.get("factorio.host_assigned_game_port") + ?? undefined + ); + } + + assignGamePort(instanceId: number) { + const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2**16 - 1); + for (const [id, instance] of this.instanceInfos) { + if (id === instanceId) { + continue; + } + const port = ( + instance.config.get("factorio.game_port") + ?? instance.config.get("factorio.host_assigned_game_port") + ); + if (port !== null) { + availablePorts.delete(port); + } + } + const instanceToAssign = this.instanceInfos.get(instanceId)!; + const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port"); + if (assignedPort !== null && availablePorts.has(assignedPort)) { + return assignedPort; + } + if (!availablePorts.size) { + throw new lib.RequestError("No available port left to assign from host.factorio_port_range"); + } + const [newPort] = availablePorts; + instanceToAssign.config.set("factorio.host_assigned_game_port", newPort); + return newPort; + } + /** * Initialize and connect an unloaded instance * @@ -923,7 +973,7 @@ export default class Host extends lib.Link { list.push(new lib.HostInstanceUpdate( instanceInfo.config.toRemote("controller"), instanceConnection ? instanceConnection.instance.status : "stopped", - instanceConnection ? instanceConnection.instance.server.gamePort : undefined, + instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId), )); } await this.send(new lib.InstancesUpdateRequest(list)); @@ -986,6 +1036,10 @@ export default class Host extends lib.Link { logger.info("Saving config"); await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t")); + for (const instanceInfo of this.instanceInfos.values()) { + // Save in case host_assigned_game_port changed in the config. + await this.saveInstanceConfig(instanceInfo); + } try { // Clear silly interval in pidfile library. diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 09acb34c..3fda98ec 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -169,7 +169,7 @@ export default class Instance extends lib.Link { let serverOptions = { logger: this.logger, version: this.config.get("factorio.version"), - gamePort: this.config.get("factorio.game_port") ?? undefined, + gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id), rconPort: this.config.get("factorio.rcon_port") ?? undefined, rconPassword: this.config.get("factorio.rcon_password") ?? undefined, enableWhitelist: this.config.get("factorio.enable_whitelist"), @@ -449,7 +449,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); new lib.InstanceStatusChangedEvent( this.id, status, - status !== "stopped" ? this.server.gamePort : undefined, + this.server.gamePort, ), ); } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index eb5dbb51..fd691557 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -158,6 +158,7 @@ export interface HostConfigFields { "host.controller_token": string; "host.tls_ca": string | null; "host.public_address": string; + "host.factorio_port_range": string; "host.max_reconnect_delay": number; } @@ -220,6 +221,14 @@ export class HostConfig extends classes.Config { type: "string", initialValue: "localhost", }, + "host.factorio_port_range": { + title: "Factorio port range", + description: + "Range of UDP ports to use for game connections. Supports both comma separated values and " + + "ranges separated with a dash.", + type: "string", + initialValue: "34100-34199", + }, "host.max_reconnect_delay": { title: "Max Reconnect Delay", description: "Maximum delay to wait before attempting to reconnect WebSocket", @@ -237,6 +246,7 @@ export interface InstanceConfigFields { "factorio.version": string; "factorio.game_port": number | null; + "factorio.host_assigned_game_port": number | null; "factorio.rcon_port": number | null; "factorio.rcon_password": string | null; "factorio.player_online_autosave_slots": number; @@ -286,11 +296,16 @@ export class InstanceConfig extends classes.Config { initialValue: "latest", }, "factorio.game_port": { - description: "UDP port to run game on, uses a random port if null", + description: "UDP port to run game on, uses a port in host.factorio_port_range if null", restartRequired: true, type: "number", optional: true, }, + "factorio.host_assigned_game_port": { + access: ["host"], + type: "number", + optional: true, + }, "factorio.rcon_port": { description: "TCP port to run RCON on, uses a random port if null", restartRequired: true, diff --git a/test/host/Instance.js b/test/host/Instance.js index 9af3921d..723bd776 100644 --- a/test/host/Instance.js +++ b/test/host/Instance.js @@ -20,7 +20,7 @@ describe("class Instance", function() { instanceConfig.set("instance.name", "foo"); src = addr({ instanceId: instanceConfig.get("instance.id") }); connector = new MockConnector(src, dst); - instance = new Instance({}, connector, "dir", "factorioDir", instanceConfig); + instance = new Instance({ assignGamePort: () => 1 }, connector, "dir", "factorioDir", instanceConfig); instance.server = new MockServer(); });