Files
factorio-clusterio/test/mock.js
T
Danielv123 3ae486143d 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.
2026-10-29 20:54:04 +01:00

289 lines
6.8 KiB
JavaScript

import events from "node:events";
import http from "node:http";
import express from "express";
import * as lib from "@clusterio/lib";
import { User, UserManager, UserRecord, InstanceManager, ControllerHooks } from "@clusterio/controller";
import { HostHooks, InstanceHooks } from "@clusterio/host";
const addr = lib.Address.fromShorthand;
export class MockLogger {
child() { return this; }
fatal() { }
error() { }
warn() { }
audit() { }
info() { }
server() { }
verbose() { }
}
export class MockSocket {
constructor() {
this.sentMessages = [];
this.events = new Map();
}
send(message) {
this.sentMessages.push(message);
}
on(event, fn) {
this.events.set(event, fn);
}
/* eslint-disable accessor-pairs */
set onclose(fn) {
this.on("close", (code, reason) => fn({ code, reason }));
}
set onerror(fn) {
this.on("error", fn);
}
set onopen(fn) {
this.on("open", fn);
}
set onmessage(fn) {
this.on("message", (data) => fn({ data }));
}
terminate() {
this.terminateCalled = true;
}
close() {
this.closeCalled = true;
}
}
export class MockConnector extends lib.BaseConnector {
constructor(src, dst) {
super(src, dst);
this.valid = true;
this.connected = true;
this.hasSession = true;
this.sentMessages = [];
}
send(message) {
this.sentMessages.push(message);
setImmediate(() => this.emit("send", message));
}
sendDisconnectReady() {
this.send(new lib.MessageDisconnect("ready"));
}
}
export class MockServer extends events.EventEmitter {
constructor() {
super();
this.reset();
}
reset() {
this.rconCommands = [];
this.rconCommandResults = new Map();
this.udpMessages = [];
}
async sendUdp(message) {
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);
if (!result) {
result = { response: "" };
}
if (result instanceof Error || typeof result === "string") {
result = { response: result };
}
if (result.time) {
await lib.wait(result.time);
}
if (result.response instanceof Error) {
throw result.response;
}
return result.response || "";
}
}
export class MockInstance extends lib.Link {
constructor() {
super(new MockConnector(addr({ instanceId: 7357 }), addr({ hostId: 1 })));
this.logger = new MockLogger();
this.server = new MockServer();
this.name = "test";
this.id = 7357;
this.status = "running";
this.autoPaused = false;
this.hooks = new InstanceHooks(this.logger);
this.loadedPlugins = new Set();
this.mockConfigEntries = new Map([
["instance.id", 7357],
["factorio.enable_save_patching", true],
["factorio.enable_script_commands", true],
]);
this.config = {
get: (name) => {
if (this.mockConfigEntries.has(name)) {
return this.mockConfigEntries.get(name);
}
throw Error(`mock for field ${name} is not implemented`);
},
};
}
async sendRcon(command, expectEmpty, plugin) {
return await this.server.sendRcon(command);
}
async sendUdp(message) {
await this.server.sendUdp(message);
}
}
export class MockHost extends lib.Link {
constructor() {
super(new MockConnector(addr({ hostId: 1 }), addr("controller")));
this.hooks = new HostHooks(new MockLogger());
this.loadedPlugins = new Set();
}
}
export class MockControl extends lib.Link { }
export class MockController {
constructor() {
this.app = express();
this.app.locals.controller = this;
this.app.locals.streams = new Map();
this.mockConfigEntries = new Map([
["controller.public_url", "test"],
["controller.auth_secret", "TestSecretDoNotUse"],
["controller.proxy_stream_timeout", 1],
["controller.metrics_timeout", 1],
]);
this.config = {
get: (name) => {
if (this.mockConfigEntries.has(name)) {
return this.mockConfigEntries.get(name);
}
throw Error(`mock for field ${name} is not implemented`);
},
};
const roleProvider = new lib.MemoryDatastoreProvider(new Map([
[0, lib.Role.fromJSON({ id: 0, name: "Admin", description: "admin", permissions: ["core.admin"] })],
[1, lib.Role.fromJSON({ id: 1, name: "Player", description: "player", permissions: [] })],
]));
this.roles = new lib.SubscribableDatastore(roleProvider, roleProvider.value);
this.roles.get(1).grantDefaultPermissions(); // Todo replace "core.admin" above with grantAdminPermissions
const userProvider = new lib.MemoryDatastoreProvider(new Map([
["test", UserRecord.fromJSON({ name: "test", roles: [0, 1] })],
["player", UserRecord.fromJSON({ name: "player", roles: [1] })],
]));
this.users = new UserManager(
new lib.SubscribableDatastore(userProvider, userProvider.value),
this.roles, this.config
);
this.instances = new InstanceManager(new lib.KeyValueDatastore(), this);
this.hosts = new lib.KeyValueDatastore();
this.handles = new Map();
this.hooks = new ControllerHooks(new MockLogger());
this.loadedPlugins = new Set();
this.wsServer = { hostConnections: new Map() };
}
get authSecret() {
return Buffer.from(this.config.get("controller.auth_secret"), "base64");
}
handle(eventClass, handler) {
this.handles.set(eventClass, handler);
}
deleteNote() { }
getControllerUrl() {
return "http://controller.example/";
}
async startServer() {
this.server = http.createServer(this.app);
await new Promise(resolve => {
this.server.listen(0, "127.0.0.1", resolve);
});
let address = this.server.address();
return `http://127.0.0.1:${address.port}`;
}
async stopServer() {
if (this.server) {
this.server.close();
this.server.unref();
}
}
}
export async function createControllerPlugin(ControllerPluginClass, info) {
let controller = new MockController();
let metrics = {};
let logger = new MockLogger();
let plugin = new ControllerPluginClass(info, controller, metrics, logger);
await plugin.init();
return plugin;
}
export async function createInstancePlugin(InstancePluginClass, info) {
let instance = new MockInstance();
let host = new MockHost();
let plugin = new InstancePluginClass(info, instance, host, new MockLogger());
await plugin.init();
return plugin;
}
export async function loadControllerPlugin(entrypoint, info) {
const controller = new MockController();
await entrypoint({ controller, metrics: {}, logger: new MockLogger(), plugin: info });
return controller;
}
export async function loadInstancePlugin(entrypoint, info) {
const instance = new MockInstance();
const host = new MockHost();
await entrypoint({ instance, host, logger: new MockLogger(), plugin: info });
return { instance, host };
}
export function getHandler(link, Class) {
if (link instanceof MockController) {
return link.handles.get(Class);
}
return link._requestHandlers.get(Class) ?? link._eventHandlers.get(Class);
}