diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index bbd2286d..8e455d7b 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -32,6 +32,8 @@ import HostRecord from "./HostRecord.js"; import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js"; import ControllerRouter from "./ControllerRouter.js"; import InstanceManager from "./InstanceManager.js"; +import PlayerRoutingStore from "./PlayerRoutingStore.js"; +import PlayerProxyServer from "./PlayerProxyServer.js"; const endpointDurationSummary = new Summary( "clusterio_controller_http_endpoint_duration_seconds", @@ -84,6 +86,8 @@ export default class Controller { trustedProxies: BlockList; debugEvents = new events.EventEmitter(); private _events = new events.EventEmitter(); + playerRoutingStore: PlayerRoutingStore; + playerProxyServer: PlayerProxyServer | null = null; /** Event subscription controller */ subscriptions: lib.SubscriptionController; @@ -286,6 +290,9 @@ export default class Controller { this.clusterLogger = clusterLogger; this.pluginInfos = pluginInfos; this.config = config; + this.playerRoutingStore = new PlayerRoutingStore( + path.join(this.config.get("controller.database_directory"), "player-routing.json"), + ); this.app = express(); this.app.locals.controller = this; @@ -437,6 +444,17 @@ export default class Controller { // Load plugins await this.loadPlugins(); + await this.playerRoutingStore.load(); + const playerProxyPort = this.config.get("controller.player_proxy_port"); + if (playerProxyPort !== null) { + this.playerProxyServer = new PlayerProxyServer( + this, + logger.child({ component: "player_proxy" }), + playerProxyPort, + ); + 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); @@ -592,6 +610,11 @@ export default class Controller { await this.hooks.shutdown.invoke(); + if (this.playerProxyServer) { + await this.playerProxyServer.stop(); + this.playerProxyServer = null; + } + await this.wsServer.stop(); let stopTasks = []; @@ -793,6 +816,7 @@ export default class Controller { this.users.records.save(), this.hooks.save.invoke(), this.notes.save(), + this.playerRoutingStore.save(), ]); } @@ -1027,6 +1051,87 @@ export default class Controller { return hosts.length ? new lib.HostUpdatesEvent(hosts) : null; } + resolvePlayerRoute(username: string): { hostId: number; instanceId: number; publicAddress: string; gamePort: number } | null { + const key = username.toLowerCase(); + let route = this.playerRoutingStore.get(key); + let instance: Readonly | undefined; + + if (!route) { + const fallbackId = this.config.get("controller.player_proxy_default_instance_id"); + if (fallbackId !== null) { + instance = this.instances.get(fallbackId); + if (instance) { + route = { + name: username, + instanceId: instance.id, + hostId: instance.config.get("instance.assigned_host") ?? -1, + gamePort: instance.gamePort ?? 0, + timestampMs: Date.now(), + }; + } + } else { + instance = this._chooseFallbackInstance(); + if (instance) { + route = { + name: username, + instanceId: instance.id, + hostId: instance.config.get("instance.assigned_host") ?? -1, + gamePort: instance.gamePort ?? 0, + timestampMs: Date.now(), + }; + } + } + } + + if (!route) { + return null; + } + + instance = instance ?? this.instances.get(route.instanceId); + if (!instance || instance.isDeleted) { + this.playerRoutingStore.delete(key); + return null; + } + + const hostId = instance.config.get("instance.assigned_host"); + if (hostId === null || route.hostId !== hostId) { + this.playerRoutingStore.delete(key); + return null; + } + + const host = this.hosts.get(hostId); + if (!host || !host.connected) { + return null; + } + + const gamePort = instance.gamePort ?? route.gamePort; + const publicAddress = host.publicAddress || host.remoteAddress; + if (!gamePort || !publicAddress) { + return null; + } + + return { + hostId, + instanceId: instance.id, + publicAddress, + gamePort, + }; + } + + private _chooseFallbackInstance() { + for (const instance of this.instances.values()) { + if (instance.status === "running" && !instance.isDeleted) { + return instance; + } + } + for (const instance of this.instances.values()) { + if (!instance.isDeleted && instance.config.get("instance.assigned_host") !== null) { + return instance; + } + } + return undefined; + } + /** * Removes all saves currently stored for the given instance, if any * @param instanceId - Id of Instance to clear saves for. diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 56d5d489..64495e00 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -72,6 +72,7 @@ export default class HostConnection extends BaseConnection { this.info.connected = false; this._controller.hosts.set(this.info); + this._controller.playerRoutingStore.dropHost(this.id); }); this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this)); @@ -81,6 +82,7 @@ export default class HostConnection extends BaseConnection { this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this)); this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this)); this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this)); + this.handle(lib.InstancePlayerRouteUpdateEvent, this.handleInstancePlayerRouteUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this)); @@ -411,6 +413,30 @@ export default class HostConnection extends BaseConnection { }); } + async handleInstancePlayerRouteUpdateEvent(event: lib.InstancePlayerRouteUpdateEvent, src: lib.Address) { + const instanceId = src.id; + if (event.instanceId !== instanceId) { + logger.warn( + `Received route update for instance ${event.instanceId} from ${instanceId}, ignoring mismatched id` + ); + return; + } + if (event.hostId !== this.id) { + logger.warn( + `Received route update for host ${event.hostId} from ${this.id}, ignoring mismatched host` + ); + return; + } + + this._controller.playerRoutingStore.update( + event.name, + instanceId, + this.id, + event.gamePort, + event.timestampMs, + ); + } + async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) { if (src.type !== lib.Address.instance) { return; diff --git a/packages/controller/src/PlayerProxyServer.ts b/packages/controller/src/PlayerProxyServer.ts new file mode 100644 index 00000000..284625f1 --- /dev/null +++ b/packages/controller/src/PlayerProxyServer.ts @@ -0,0 +1,511 @@ +import dgram from "dgram"; + +import * as lib from "@clusterio/lib"; + +import type Controller from "./Controller.js"; + +const TYPE_MASK = 0x1f; +const FLAG_MASK = 0xe0; + +const TYPE_CONNECTION_REQUEST = 2; +const TYPE_CONNECTION_REQUEST_REPLY = 3; +const TYPE_CONNECTION_REQUEST_REPLY_CONFIRM = 4; + +const SESSION_IDLE_TIMEOUT_MS = 30_000; +const MAX_PENDING_PACKETS = 64; +const MAX_PACKET_SIZE = 1400; + +type ClientKey = string; + +type RouteTarget = { + hostId: number; + instanceId: number; + publicAddress: string; + gamePort: number; +}; + +type ClientSession = { + clientAddress: string; + clientPort: number; + lastSeenMs: number; + pending: Buffer[]; + upstream: dgram.Socket | null; + route: RouteTarget | null; + requestBuffer?: Buffer; + requestFlags?: number; + requestMessageIdAndFlags?: number; + requestVersion?: ApplicationVersion; + clientRequestId?: number; + proxyServerId?: number; + pendingConfirm?: Buffer; + confirmServerIdOffset?: number; + serverConnectionId?: number; + username?: string; + activating?: Promise; +}; + +type ApplicationVersion = { + major: number; + minor: number; + sub: number; + build: number; +}; + +class BufferReader { + private _offset = 0; + constructor(private _buffer: Buffer) {} + + get offset() { + return this._offset; + } + + readUInt8() { + const value = this._buffer.readUInt8(this._offset); + this._offset += 1; + return value; + } + + readUInt16LE() { + const value = this._buffer.readUInt16LE(this._offset); + this._offset += 2; + return value; + } + + readUInt32LE() { + const value = this._buffer.readUInt32LE(this._offset); + this._offset += 4; + return value; + } + + readSpaceOptimized() { + const value = this.readUInt8(); + if (value === 0xff) { + return this.readUInt16LE(); + } + return value; + } + + readContainerSize() { + const value = this.readUInt8(); + if (value === 0xff) { + return this.readUInt32LE(); + } + return value; + } + + readBytes(length: number) { + const start = this._offset; + const end = start + length; + if (end > this._buffer.length) { + throw new Error("Buffer too short"); + } + this._offset = end; + return this._buffer.slice(start, end); + } +} + +class BufferWriter { + private _parts: Buffer[] = []; + + writeUInt8(value: number) { + const buf = Buffer.allocUnsafe(1); + buf.writeUInt8(value, 0); + this._parts.push(buf); + } + + writeUInt16LE(value: number) { + const buf = Buffer.allocUnsafe(2); + buf.writeUInt16LE(value, 0); + this._parts.push(buf); + } + + writeUInt32LE(value: number) { + const buf = Buffer.allocUnsafe(4); + buf.writeUInt32LE(value >>> 0, 0); + this._parts.push(buf); + } + + writeSpaceOptimized(value: number) { + if (value >= 0xff) { + this.writeUInt8(0xff); + this.writeUInt16LE(value); + } else { + this.writeUInt8(value); + } + } + + concat() { + return Buffer.concat(this._parts); + } +} + +function clientKey(address: string, port: number): ClientKey { + return `${address}:${port}`; +} + +export default class PlayerProxyServer { + private _socket: dgram.Socket | null = null; + private _sessions = new Map(); + private _cleanupTimer: ReturnType | undefined; + private _closed = false; + + constructor( + private _controller: Controller, + private _logger: lib.Logger, + private _port: number, + ) {} + + async start() { + if (this._socket || this._closed) { + return; + } + + const socket = dgram.createSocket("udp4"); + this._socket = socket; + socket.on("message", this._handleClientMessage.bind(this)); + socket.on("error", err => { + this._logger.error(`Player proxy socket error: ${err.message}`); + this.stop().catch(() => {}); + }); + + await new Promise((resolve, reject) => { + socket.once("listening", resolve); + socket.once("error", reject); + socket.bind(this._port, "0.0.0.0"); + }); + + this._cleanupTimer = setInterval(() => this._cleanupSessions(), SESSION_IDLE_TIMEOUT_MS); + this._cleanupTimer.unref(); + this._logger.info(`Player proxy listening on UDP ${this._port}`); + } + + async stop() { + if (this._closed) { + return; + } + this._closed = true; + + if (this._cleanupTimer) { + clearInterval(this._cleanupTimer); + this._cleanupTimer = undefined; + } + + for (const session of this._sessions.values()) { + session.upstream?.close(); + } + this._sessions.clear(); + + if (this._socket) { + await new Promise(resolve => this._socket!.close(() => resolve())); + this._socket = null; + } + } + + private _handleClientMessage(message: Buffer, rinfo: dgram.RemoteInfo) { + if (this._closed || !this._socket) { + return; + } + + const key = clientKey(rinfo.address, rinfo.port); + let session = this._sessions.get(key); + if (!session) { + session = this._createSession(rinfo.address, rinfo.port); + this._sessions.set(key, session); + } + session.lastSeenMs = Date.now(); + + let type: number; + try { + type = message.readUInt8(0) & TYPE_MASK; + } catch { + return; + } + + if (type === TYPE_CONNECTION_REQUEST) { + this._handleConnectionRequest(session, message); + return; + } + + if (type === TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) { + this._handleConnectionRequestReplyConfirm(session, message); + return; + } + + if (!session.route || !session.upstream) { + this._queuePending(session, message); + return; + } + + this._flushPending(session); + session.upstream.send(message, session.route.gamePort, session.route.publicAddress); + } + + private _createSession(address: string, port: number): ClientSession { + return { + clientAddress: address, + clientPort: port, + lastSeenMs: Date.now(), + pending: [], + upstream: null, + route: null, + }; + } + + private _handleConnectionRequest(session: ClientSession, message: Buffer) { + let parsed: { + flags: number; + messageIdAndFlags: number; + version: ApplicationVersion; + requestId: number; + }; + try { + parsed = this._parseConnectionRequest(message); + } catch (err: any) { + this._logger.warn(`Failed to parse connection request: ${err.message ?? err}`); + return; + } + + session.requestBuffer = message; + session.requestFlags = parsed.flags; + session.requestMessageIdAndFlags = parsed.messageIdAndFlags; + session.requestVersion = parsed.version; + session.clientRequestId = parsed.requestId; + if (!session.proxyServerId) { + session.proxyServerId = Math.floor(Math.random() * 2 ** 32); + } + + const reply = this._buildConnectionRequestReply(session); + this._socket?.send(reply, session.clientPort, session.clientAddress); + } + + private _handleConnectionRequestReplyConfirm(session: ClientSession, message: Buffer) { + let parsed: { username: string; serverIdOffset: number }; + try { + parsed = this._parseConnectionRequestReplyConfirm(message); + } catch (err: any) { + this._logger.warn(`Failed to parse connection confirm: ${err.message ?? err}`); + return; + } + + session.username = parsed.username; + session.pendingConfirm = message; + session.confirmServerIdOffset = parsed.serverIdOffset; + + if (session.route || session.activating) { + return; + } + + session.activating = this._activateRoute(session).finally(() => { + session.activating = undefined; + }); + } + + private _buildConnectionRequestReply(session: ClientSession) { + const flags = session.requestFlags ?? 0; + const version = session.requestVersion ?? { major: 0, minor: 0, sub: 0, build: 0 }; + const requestId = session.clientRequestId ?? 0; + const serverId = session.proxyServerId ?? 0; + + const writer = new BufferWriter(); + writer.writeUInt8((flags & FLAG_MASK) | TYPE_CONNECTION_REQUEST_REPLY); + writer.writeSpaceOptimized(version.major); + writer.writeSpaceOptimized(version.minor); + writer.writeSpaceOptimized(version.sub); + writer.writeUInt32LE(version.build); + writer.writeUInt32LE(requestId); + writer.writeUInt32LE(serverId); + writer.writeUInt32LE(MAX_PACKET_SIZE); + return writer.concat(); + } + + private _parseConnectionRequest(buffer: Buffer) { + const reader = new BufferReader(buffer); + const header = reader.readUInt8(); + const type = header & TYPE_MASK; + if (type !== TYPE_CONNECTION_REQUEST) { + throw new Error("Unexpected message type"); + } + + const flags = header & FLAG_MASK; + const messageIdAndFlags = reader.readUInt16LE(); + const version = { + major: reader.readSpaceOptimized(), + minor: reader.readSpaceOptimized(), + sub: reader.readSpaceOptimized(), + build: reader.readUInt32LE(), + }; + const requestId = reader.readUInt32LE(); + return { flags, messageIdAndFlags, version, requestId }; + } + + private _parseConnectionRequestReplyConfirm(buffer: Buffer) { + const reader = new BufferReader(buffer); + const header = reader.readUInt8(); + const type = header & TYPE_MASK; + if (type !== TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) { + throw new Error("Unexpected message type"); + } + reader.readUInt16LE(); + reader.readUInt32LE(); // client request id + const serverIdOffset = reader.offset; + reader.readUInt32LE(); + reader.readUInt32LE(); // instance id + const nameLength = reader.readContainerSize(); + const nameBuffer = reader.readBytes(nameLength); + const username = nameBuffer.toString("utf8"); + return { username, serverIdOffset }; + } + + private _handleUpstreamMessage(session: ClientSession, data: Buffer) { + if (!this._socket || !session.route) { + return; + } + + let type: number; + try { + type = data.readUInt8(0) & TYPE_MASK; + } catch { + return; + } + + if (type === TYPE_CONNECTION_REQUEST_REPLY && session.serverConnectionId === undefined) { + try { + const serverConnectionId = this._parseConnectionRequestReply(data); + session.serverConnectionId = serverConnectionId; + this._sendPendingConfirm(session); + } catch (err: any) { + this._logger.warn(`Failed to parse upstream reply: ${err.message ?? err}`); + } + return; + } + + this._socket.send(data, session.clientPort, session.clientAddress); + } + + private _parseConnectionRequestReply(buffer: Buffer) { + const reader = new BufferReader(buffer); + const header = reader.readUInt8(); + const type = header & TYPE_MASK; + if (type !== TYPE_CONNECTION_REQUEST_REPLY) { + throw new Error("Unexpected message type"); + } + reader.readSpaceOptimized(); + reader.readSpaceOptimized(); + reader.readSpaceOptimized(); + reader.readUInt32LE(); + reader.readUInt32LE(); // client request id + return reader.readUInt32LE(); + } + + private async _activateRoute(session: ClientSession) { + if (!session.username) { + return; + } + + const route = this._controller.resolvePlayerRoute(session.username); + if (!route) { + this._logger.warn(`No route for ${session.username}, dropping client ${session.clientAddress}:${session.clientPort}`); + return; + } + + const instanceReady = await this._ensureInstanceRunning(route.instanceId); + if (!instanceReady) { + this._logger.warn(`Instance ${route.instanceId} not ready for ${session.username}`); + return; + } + + session.route = route; + if (!session.upstream) { + const upstream = dgram.createSocket("udp4"); + session.upstream = upstream; + upstream.on("message", data => this._handleUpstreamMessage(session, data)); + upstream.on("error", err => { + this._logger.warn( + `Upstream error for ${session.clientAddress}:${session.clientPort}: ${err.message}` + ); + upstream.close(); + this._sessions.delete(clientKey(session.clientAddress, session.clientPort)); + }); + } + + if (session.requestBuffer) { + session.upstream.send(session.requestBuffer, route.gamePort, route.publicAddress); + } + } + + private async _ensureInstanceRunning(instanceId: number) { + const instance = this._controller.instances.get(instanceId); + if (!instance || instance.isDeleted) { + return false; + } + if (instance.status === "running") { + return true; + } + if (instance.config.get("instance.assigned_host") === null) { + return false; + } + + try { + await this._controller.sendTo({ instanceId }, new lib.InstanceStartRequest()); + } catch (err: any) { + this._logger.warn(`Failed to start instance ${instanceId}: ${err.message ?? err}`); + return false; + } + + const deadline = Date.now() + SESSION_IDLE_TIMEOUT_MS; + while (Date.now() < deadline) { + const current = this._controller.instances.get(instanceId); + if (!current || current.isDeleted) { + return false; + } + if (current.status === "running") { + return true; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + return false; + } + + private _sendPendingConfirm(session: ClientSession) { + if (!session.pendingConfirm || session.confirmServerIdOffset === undefined || !session.upstream || !session.route) { + return; + } + + if (session.serverConnectionId === undefined) { + return; + } + + const payload = Buffer.from(session.pendingConfirm); + payload.writeUInt32LE(session.serverConnectionId, session.confirmServerIdOffset); + session.upstream.send(payload, session.route.gamePort, session.route.publicAddress); + session.pendingConfirm = undefined; + this._flushPending(session); + } + + private _queuePending(session: ClientSession, message: Buffer) { + if (session.pending.length >= MAX_PENDING_PACKETS) { + session.pending.shift(); + } + session.pending.push(message); + } + + private _flushPending(session: ClientSession) { + if (!session.route || !session.upstream || !session.pending.length) { + return; + } + for (const pending of session.pending) { + session.upstream.send(pending, session.route.gamePort, session.route.publicAddress); + } + session.pending = []; + } + + private _cleanupSessions() { + const now = Date.now(); + for (const [key, session] of this._sessions) { + if (now - session.lastSeenMs > SESSION_IDLE_TIMEOUT_MS) { + session.upstream?.close(); + this._sessions.delete(key); + } + } + } +} diff --git a/packages/controller/src/PlayerRoutingStore.ts b/packages/controller/src/PlayerRoutingStore.ts new file mode 100644 index 00000000..33fba7bd --- /dev/null +++ b/packages/controller/src/PlayerRoutingStore.ts @@ -0,0 +1,75 @@ +import fs from "node:fs/promises"; + +import * as lib from "@clusterio/lib"; + +export type StoredRoute = { + name: string; + instanceId: number; + hostId: number; + gamePort: number; + timestampMs: number; +}; + +export default class PlayerRoutingStore { + private _routes = new Map(); + private _dirty = false; + + constructor(private _filePath: string) {} + + get(name: string): StoredRoute | undefined { + return this._routes.get(name.toLowerCase()); + } + + update(name: string, instanceId: number, hostId: number, gamePort: number, timestampMs: number) { + const key = name.toLowerCase(); + this._routes.set(key, { name, instanceId, hostId, gamePort, timestampMs }); + this._dirty = true; + } + + delete(name: string) { + const key = name.toLowerCase(); + if (this._routes.delete(key)) { + this._dirty = true; + } + } + + dropHost(hostId: number) { + let removed = false; + for (const [key, route] of this._routes) { + if (route.hostId === hostId) { + this._routes.delete(key); + removed = true; + } + } + if (removed) { + this._dirty = true; + } + } + + async load(): Promise { + try { + const routes = JSON.parse(await fs.readFile(this._filePath, { encoding: "utf8" })) as StoredRoute[]; + this._routes.clear(); + for (const route of routes) { + if (!route || !route.name) { + continue; + } + this._routes.set(route.name.toLowerCase(), route); + } + this._dirty = false; + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } + } + } + + async save(): Promise { + if (!this._dirty) { + return; + } + const payload = JSON.stringify([...this._routes.values()], null, "\t"); + await lib.safeOutputFile(this._filePath, payload); + this._dirty = false; + } +} diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index f70080d3..02bf3470 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -424,6 +424,13 @@ export default class Instance extends lib.Link { if (wasPaused) { this.hooks.autoPauseChanged.invoke(false); } + const hostId = this.config.get("instance.assigned_host"); + if (hostId !== null) { + this.sendTo( + "controller", + new lib.InstancePlayerRouteUpdateEvent(name, this.id, hostId, this.server.gamePort, Date.now()), + ); + } this.hooks.playerEvent.invoke(event); } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 5d19897a..60a76be0 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -31,6 +31,8 @@ export interface ControllerConfigFields { "controller.max_subscription_filters": number; "controller.system_metrics_interval": number; "controller.proxy_stream_timeout": number; + "controller.player_proxy_port": number | null; + "controller.player_proxy_default_instance_id": number | null; "controller.factorio_username": string | null; "controller.factorio_token": string | null; "controller.share_factorio_credentials_with_hosts": boolean; @@ -243,6 +245,20 @@ export class ControllerConfig extends classes.Config { initialValue: 15, validator: validators.greaterThanZero, }, + "controller.player_proxy_port": { + title: "Player Proxy Port", + description: "UDP port to accept player connections for the controller proxy, set to null to disable.", + restartRequired: true, + type: "number", + optional: true, + initialValue: 34197, + }, + "controller.player_proxy_default_instance_id": { + title: "Player Proxy Default Instance", + description: "Instance ID to route players to when no routing history is available.", + type: "number", + optional: true, + }, "controller.factorio_username": { title: "Factorio Username", description: "Username to authenticate with Factorio API with.", diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index d107632c..2d10819c 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -935,3 +935,30 @@ export class InstancePlayerUpdateEvent { return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason); } } + +export class InstancePlayerRouteUpdateEvent { + declare ["constructor"]: typeof InstancePlayerRouteUpdateEvent; + static type = "event" as const; + static src = "instance" as const; + static dst = "controller" as const; + + constructor( + public name: string, + public instanceId: number, + public hostId: number, + public gamePort: number, + public timestampMs: number, + ) { } + + static jsonSchema = Type.Object({ + "name": Type.String(), + "instanceId": Type.Integer(), + "hostId": Type.Integer(), + "gamePort": Type.Integer(), + "timestampMs": Type.Number(), + }); + + static fromJSON(json: Static) { + return new this(json.name, json.instanceId, json.hostId, json.gamePort, json.timestampMs); + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index c4d95dcb..06b08328 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -102,6 +102,7 @@ export const dataClasses: (RequestClass | EventClass) instance.InstanceAdminlistUpdateEvent, instance.InstanceWhitelistUpdateEvent, instance.InstancePlayerUpdateEvent, + instance.InstancePlayerRouteUpdateEvent, mod.ModPackGetRequest, mod.ModPackGetDefaultRequest,