Files
factorio-expcluster/exp_server_list/test/controller.test.js
T
bbassieandClaude Opus 5.5 4d136853c9 Add exp_server_list to replace expcore/external
The controller keeps a record per instance with its name, mod pack,
Factorio version, players online, status and address, and the instance
plugin pushes the list into lua over rcon. The readme and the connect
commands read it through modules/exp_server_list, so expcore/external
and its hardcoded server fallback are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:35:33 +00:00

189 lines
7.6 KiB
JavaScript

import t from "tap";
import * as lib from "@clusterio/lib";
import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller";
import entrypoint, { ControllerPlugin } from "../dist/node/controller.js";
import { plugin as pluginDeclaration } from "../dist/node/index.js";
import * as messages from "../dist/node/messages.js";
// The plugin's config fields must be defined before an InstanceConfig can set them
lib.addPluginConfigFields([pluginDeclaration]);
for (const Message of pluginDeclaration.messages) {
lib.Link.register(Message);
}
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
/** Build the plugin around a real controller, which is side effect free while not started. */
async function startPlugin(t2, setup = () => {}) {
const controllerConfig = new lib.ControllerConfig("controller", {
"controller.database_directory": t2.testdir(),
});
const controller = new Controller(logger, [], controllerConfig);
setup(controller);
const broadcasts = [];
controller.subscriptions.broadcast = event => {
if (event instanceof messages.ServerUpdatesEvent) {
broadcasts.push(event);
}
};
const context = { controller, logger, plugin: pluginDeclaration, metrics: undefined };
const plugin = await ControllerPlugin.fromContext(context);
return { plugin, controller, broadcasts };
}
function addHost(controller, id, publicAddress = "") {
controller.hosts.set(new HostRecord(id, `host-${id}`, "2.0.0", new Map(), true, "", publicAddress));
}
function addInstance(controller, id, { hostId = 1, status = "running", gamePort, factorioVersion, fields = {} } = {}) {
const config = new lib.InstanceConfig("controller", {
"instance.id": id,
"instance.name": `instance-${id}`,
"instance.assigned_host": hostId,
...fields,
});
const record = new InstanceRecord(config, status, gamePort, factorioVersion);
controller.instances.records.set(record);
controller.instances.addInstanceHooks(record);
return record;
}
const subscription = since => new lib.SubscriptionRequest("exp_server_list:ServerUpdatesEvent", "subscribe", since);
const join = (controller, name, instanceId) => controller.users.getOrCreateUser(name).notifyJoin(instanceId);
t.test("entrypoint", async t2 => {
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
const controller = new Controller(logger, [], controllerConfig);
await entrypoint({ controller, logger, plugin: pluginDeclaration, metrics: undefined });
const hooks = [
"instanceStatusChanged",
"instanceConfigFieldChanged",
"controllerConfigFieldChanged",
"playerEvent",
"modPacksUpdated",
];
for (const hook of hooks) {
t2.ok([...controller.hooks[hook].attached].includes("exp_server_list"), `attached to ${hook}`);
}
t2.ok(ControllerPlugin.get(controller), "other plugins can find it");
});
t.test("class ControllerPlugin", t2 => {
t2.test("builds a record for every instance on start", async t3 => {
const { plugin } = await startPlugin(t3, controller => {
addHost(controller, 1, "example.com");
addInstance(controller, 10, {
gamePort: 34197,
factorioVersion: "2.1.14",
fields: {
"exp_server_list.description": "Weekly reset",
"exp_server_list.hidden": true,
},
});
addInstance(controller, 11, {
status: "stopped",
factorioVersion: "latest",
fields: { "exp_server_list.short_name": "S11" },
});
});
const server = plugin.servers.get(10);
t3.equal(server.name, "instance-10");
t3.equal(server.shortName, "instance-10", "short name falls back to the name");
t3.equal(server.description, "Weekly reset");
t3.equal(server.hidden, true);
t3.equal(server.factorioVersion, "2.1.14");
t3.equal(server.address, "example.com:34197", "public address of the host and the game port");
t3.equal(server.status, "running");
const stopped = plugin.servers.get(11);
t3.equal(stopped.shortName, "S11");
t3.equal(stopped.factorioVersion, "", "the latest target is not a version");
t3.equal(stopped.address, "example.com", "host address alone without a game port");
});
t2.test("uses the mod pack of the instance or the default", async t3 => {
const { plugin, controller } = await startPlugin(t3, c => {
addHost(c, 1);
c.modPacks.setMany(lib.ModPack.defaultModPacks.map(pack => lib.ModPack.fromJSON(pack.toJSON())));
const packs = [...c.modPacks.values()];
c.config.set("controller.default_mod_pack_id", packs[0].id);
addInstance(c, 10);
addInstance(c, 11, { fields: { "factorio.mod_pack_id": packs[1].id } });
});
const packs = [...controller.modPacks.values()];
t3.equal(plugin.servers.get(10).modPackName, packs[0].name, "the default mod pack");
t3.equal(plugin.servers.get(11).modPackName, packs[1].name, "the instance mod pack");
t3.equal(plugin.servers.get(10).address, "", "no address while the host has none");
});
t2.test("counts players only while running", async t3 => {
const { plugin, controller, broadcasts } = await startPlugin(t3, c => {
addHost(c, 1);
addInstance(c, 10);
});
const instance = controller.instances.get(10);
join(controller, "alice", 10);
join(controller, "bob", 10);
await controller.hooks.playerEvent.invoke(instance, { type: "join", name: "bob" });
t3.equal(plugin.servers.get(10).playerCount, 2);
t3.equal(broadcasts.at(-1).updates[0].playerCount, 2, "the change is broadcast");
instance.status = "stopped";
await controller.hooks.instanceStatusChanged.invoke(instance, "running");
t3.equal(plugin.servers.get(10).playerCount, 0, "players left online by a crash are not counted");
});
t2.test("follows config, host and status changes", async t3 => {
const { plugin, controller, broadcasts } = await startPlugin(t3, c => {
addHost(c, 1);
addInstance(c, 10, { gamePort: 34197 });
});
const instance = controller.instances.getMutable(10);
instance.config.set("instance.name", "Weekly");
t3.equal(plugin.servers.get(10).name, "Weekly", "renames are picked up");
instance.config.set("exp_server_list.welcome", "Hello");
t3.equal(plugin.servers.get(10).welcome, "Hello", "plugin fields are picked up");
addHost(controller, 1, "example.com");
t3.equal(plugin.servers.get(10).address, "example.com:34197", "the public address once the host sends it");
const count = broadcasts.length;
addHost(controller, 1, "example.com");
t3.equal(broadcasts.length, count, "nothing is broadcast when nothing changed");
// What deleteInstance does once the host has deleted it
controller.instances.records.delete(instance);
await controller.hooks.instanceStatusChanged.invoke(instance, "running");
t3.equal(plugin.servers.get(10), undefined, "deleted instances are removed");
t3.equal(broadcasts.at(-1).updates[0].isDeleted, true, "and broadcast as deleted");
});
t2.test(".handleServerSubscription() sends servers updated since the last request", async t3 => {
const { plugin } = await startPlugin(t3, controller => {
addHost(controller, 1);
addInstance(controller, 10);
});
const all = await plugin.handleServerSubscription(subscription(0));
t3.equal(all.updates.length, 1);
const none = await plugin.handleServerSubscription(subscription(Date.now() + 1000));
t3.equal(none, null);
t3.equal((await plugin.handleServerListRequest()).length, 1);
});
t2.end();
});
t.test("ServerRecord round trips through json", async t2 => {
const record = new messages.ServerRecord(
1, "a", "b", "c", "d", "e", true, "Base", "2.1.14", 3, "running", "localhost:1", 5, true,
);
t2.same(messages.ServerRecord.fromJSON(record.toJSON()), record);
t2.ok(record.sameAs(messages.ServerRecord.fromJSON(record.toJSON())));
});