Files
factorio-expcluster/exp_scenario/test/instance.test.js
T
bbassieandClaude Opus 5.5 afc1d35668 Load datastores with SubscribableDatastore.fromProvider
clusterio#1129 replaced DatastoreProvider.bootstrap with async factories
and Instance now takes its FactorioServer in the constructor, so the
instance tests pass their server stub there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:43:25 +00:00

232 lines
9.5 KiB
JavaScript

import t from "tap";
import * as lib from "@clusterio/lib";
import { Instance } from "@clusterio/host";
import entrypoint from "../dist/node/instance.js";
import { plugin as pluginDeclaration } from "../dist/node/index.js";
import * as messages from "../dist/node/messages.js";
import { features } from "../dist/node/features.js";
// The instance validates message classes against the link registry
for (const Message of pluginDeclaration.messages) {
lib.Link.register(Message);
}
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
class TestConnector extends lib.BaseConnector {
constructor() {
super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 }));
this.valid = true;
this.connected = true;
this.hasSession = true;
}
send() {}
}
/** Run the plugin on a real instance with spies on what leaves it, returns the event handler it registered. */
async function startPlugin(
t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })], players = [],
) {
const instanceConfig = new lib.InstanceConfig("host");
instanceConfig.set("instance.id", 1);
instanceConfig.set("instance.name", "test");
const state = { sent: [], rcons: [] };
const ipcHandlers = new Map();
const server = {
on: () => {},
handle: (channel, handler) => ipcHandlers.set(channel, handler),
sendRcon: async command => {
state.rcons.push(command);
return "";
},
};
const instance = new Instance(
{ assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), instanceConfig, server
);
instance.sendTo = async (dst, request) => {
state.sent.push(request);
if (request instanceof messages.PlayerGetRequest) {
const found = players.find(record => record.id === request.playerName);
return found ?? new messages.PlayerRecord(request.playerName);
}
return request instanceof messages.FeatureListRequest ? features : undefined;
};
const handlers = new Map();
instance.handle = (Event, handler) => handlers.set(Event, handler);
await entrypoint({ plugin: { name: "exp_scenario" }, instance, host: {}, logger });
const hooks = instance.hooks;
return {
state,
start: () => hooks.start.invoke(),
exit: () => hooks.exit.invoke(),
connectionEvent: event => hooks.controllerConnectionEvent.invoke(event),
featureUpdated: handlers.get(messages.FeatureUpdatedEvent),
ipc: (channel, event) => ipcHandlers.get(channel)(event),
playerEvent: event => hooks.playerEvent.invoke(event),
setStatus: status => { instance._status = status; },
};
}
/** The payload of a recorded rcon command. */
function decodeRcon(command) {
const match = command.match(/^\/sc exp_scenario\.features\.receive_update\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s);
return JSON.parse(match[2]);
}
/** The player data and code of a recorded rcon command made by luaWithPlayer. */
function decodePlayerRcon(command) {
const match = command.match(
/^\/sc local d = helpers\.json_to_table\[(=+)\[(.*)\]\1\] local p = game\.get_player\(d\.name\) if p then (.*) end$/s,
);
return { data: JSON.parse(match[2]), code: match[3] };
}
t.test("instance plugin", t2 => {
t2.test("start subscribes and sends every feature to lua", async t3 => {
const { start, state } = await startPlugin(t3);
await start();
t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes to updates first");
t3.ok(state.sent[1] instanceof messages.FeatureListRequest, "then lists the features");
const [update] = decodeRcon(state.rcons[0]);
const deathMarkers = features.find(feature => feature.name === "death_markers");
t3.equal(update.name, "death_markers");
t3.strictSame([update.values.enabled, update.values.show_map_markers], [false, false], "stored values are sent");
t3.equal(update.values.collect_corpses, true, "the rest are filled with their defaults");
t3.equal(Object.keys(update.values).length, deathMarkers.fields.length + 1, "every field and enabled");
});
t2.test("FeatureUpdatedEvent only sends while the game is up", async t3 => {
const { start, exit, featureUpdated, state } = await startPlugin(t3);
const event = new messages.FeatureUpdatedEvent([
new messages.FeatureRecord("afk_kick", true, { afk_minutes: 5 }),
new messages.FeatureRecord("removed", true, {}, 0, true),
]);
await featureUpdated(event);
t3.strictSame(state.rcons, [], "nothing is sent before start");
await start();
state.rcons.length = 0;
await featureUpdated(event);
const updates = decodeRcon(state.rcons[0]);
t3.strictSame(updates.map(update => update.name), ["afk_kick"], "deleted features are left out");
t3.strictSame([updates[0].values.enabled, updates[0].values.afk_minutes], [true, 5]);
await exit();
state.rcons.length = 0;
await featureUpdated(event);
t3.strictSame(state.rcons, [], "nothing is sent after exit");
});
t2.test("a new controller connection syncs again after the controller restarts", async t3 => {
const { start, connectionEvent, state } = await startPlugin(t3);
await connectionEvent("connect");
t3.strictSame(state.sent, [], "nothing happens before start");
await start();
state.sent.length = 0;
state.rcons.length = 0;
await connectionEvent("resume");
t3.strictSame(state.sent, [], "a resumed session keeps its subscription");
await connectionEvent("connect");
t3.ok(state.sent[0] instanceof lib.SubscriptionRequest, "subscribes again");
t3.equal(state.rcons.length, 1, "and sends every feature");
});
t2.test("feature updates pick a long bracket the values do not close", async t3 => {
const { start, featureUpdated, state } = await startPlugin(t3, []);
await start();
const name = "a]=]b]==]c";
await featureUpdated(new messages.FeatureUpdatedEvent([
new messages.FeatureRecord("station_auto_name", true, { station_name: name }),
]));
t3.match(state.rcons[0], "json_to_table[===[", "uses a level not found in the json");
t3.strictSame(decodeRcon(state.rcons[0])[0].values.station_name, name);
});
t2.end();
});
t.test("player settings", t2 => {
const alice = new messages.PlayerRecord("Alice", "Alice has arrived ]=] ]==]");
t2.test("a joining player's message is printed to everyone", async t3 => {
const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [alice]);
await start();
setStatus("running");
state.rcons.length = 0;
await playerEvent({ type: "join", name: "Alice" });
t3.equal(state.rcons.length, 1);
const { data, code } = decodePlayerRcon(state.rcons[0]);
t3.strictSame(data, { message: alice.joinMessage, name: "Alice" }, "the message survives the long bracket");
t3.equal(code, "game.print(d.message, { color = p.color })");
await playerEvent({ type: "join", name: "Bob" });
await playerEvent({ type: "leave", name: "Alice" });
t3.equal(state.rcons.length, 1, "players without a message and leaves print nothing");
});
t2.test("nothing is printed while the instance is not running or greetings are disabled", async t3 => {
const { start, playerEvent, setStatus, featureUpdated, state } = await startPlugin(t3, [], [alice]);
await start();
state.rcons.length = 0;
await playerEvent({ type: "join", name: "Alice" });
t3.strictSame(state.rcons, [], "not running");
setStatus("running");
await featureUpdated(new messages.FeatureUpdatedEvent([new messages.FeatureRecord("greetings", false)]));
state.rcons.length = 0;
await playerEvent({ type: "join", name: "Alice" });
t3.strictSame(state.rcons, [], "greetings disabled");
});
t2.test("ipc from the set and remove commands is forwarded to the controller", async t3 => {
const { ipc, state } = await startPlugin(t3);
await ipc("exp_scenario:join_message_set", { player_name: "Alice", message: "Hi" });
await ipc("exp_scenario:join_message_set", { player_name: "Alice" });
const sent = state.sent.filter(request => request instanceof messages.JoinMessageSetRequest);
t3.strictSame(sent.map(request => [request.playerName, request.message]), [["Alice", "Hi"], ["Alice", null]]);
});
t2.test("a joining player's stored toolbar is sent to the lua side", async t3 => {
const bob = new messages.PlayerRecord("Bob", undefined, "eNqrVkrLz1eyUlBKSixSqgUAGQwD8g==");
const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [bob]);
await start();
setStatus("running");
state.rcons.length = 0;
await playerEvent({ type: "join", name: "Bob" });
t3.equal(state.rcons.length, 1, "only the toolbar, Bob has no join message");
const { data, code } = decodePlayerRcon(state.rcons[0]);
t3.strictSame(data, { toolbar: bob.toolbar, name: "Bob" });
t3.equal(code, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)");
});
t2.test("ipc with a toolbar is forwarded to the controller", async t3 => {
const { ipc, state } = await startPlugin(t3);
await ipc("exp_scenario:toolbar", { player_name: "Alice", toolbar: "abc" });
const [event] = state.sent.filter(request => request instanceof messages.PlayerToolbarEvent);
t3.strictSame([event.playerName, event.toolbar], ["Alice", "abc"]);
});
t2.test("ipc asking for the message prints it to the player", async t3 => {
const { ipc, setStatus, state } = await startPlugin(t3, [], [alice]);
setStatus("running");
await ipc("exp_scenario:join_message_get", { player_name: "Alice" });
await ipc("exp_scenario:join_message_get", { player_name: "Bob" });
const [found, missing] = state.rcons.map(decodePlayerRcon);
t3.equal(found.data.message, alice.joinMessage);
t3.equal(missing.data.message, undefined, "no message key, so the lua side prints message-none");
t3.match(found.code, "exp_greetings.message-none");
});
t2.end();
});