Files
factorio-expcluster/exp_statistics/test/instance.test.js
T
bbassieandClaude Opus 5.5 d1b22dd4e7 Add exp_statistics plugin for player statistics
Player statistics move out of exp_legacy into their own plugin, with the
totals kept on the controller instead of the PlayerData datastore, which
was never synced anywhere.

The lua module tracks the same statistics with the same rules. Gains are
kept per player as unsent in storage and sent over IPC every 5 minutes
for every player with changes, and for one player when they leave. Sent
values move into the totals the controller gave when the player joined,
so get() is the total on every server either way.

Nothing is resent. The instance plugin sends everything left when the
server stops and drops anything unsent when it starts. A leftover can
only come from a save made before it was sent, such as the autosave
loaded after a crash, so dropping it loses a few minutes at worst but
never counts twice.

The controller adds the deltas to a record per player name in
database/exp_statistics/players.json. Instances fetch a player's totals
on join and hand them to lua over rcon. The web UI gets a sortable table
of every player at /exp_statistics, behind exp_statistics.view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:44:59 +00:00

133 lines
4.9 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";
// 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 IPC handlers it registered. */
async function startPlugin(t2, { status = "running", stats = { Kills: 3 } } = {}) {
const instanceConfig = new lib.InstanceConfig("host");
instanceConfig.set("instance.id", 1);
instanceConfig.set("instance.name", "test");
const instance = new Instance(
{ assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig
);
const state = { sent: [], rcons: [], warnings: [] };
const ipc = new Map();
instance.server = {
handle: (name, handler) => ipc.set(name, handler),
};
instance.sendRcon = async command => {
state.rcons.push(command);
return "";
};
instance.sendTo = async (dst, message) => {
state.sent.push(message);
if (message instanceof messages.StatisticsGetRequest) {
return new messages.PlayerStatistics(message.name, stats);
}
return undefined;
};
instance.notifyStatus(status);
state.sent.length = 0;
const pluginLogger = { ...logger, warn: message => state.warnings.push(message) };
await entrypoint({ plugin: { name: "exp_statistics" }, instance, host: {}, logger: pluginLogger });
return { instance, state, updateIPC: event => ipc.get("exp_statistics:update")(event) };
}
/** The payload of a recorded receive_totals command. */
function decodeTotals(command) {
const match = command.match(/^\/sc exp_statistics\.receive_totals\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s);
return JSON.parse(match[2]);
}
t.test("instance plugin", t2 => {
t2.test("exp_statistics:update is forwarded to the controller", async t3 => {
const { state, updateIPC } = await startPlugin(t3);
await updateIPC({ players: [{ name: "alice", stats: { Kills: 1 } }, { name: "bob", stats: { Deaths: 2 } }] });
t3.equal(state.sent.length, 1, "one event");
const event = state.sent[0];
t3.ok(event instanceof messages.StatisticsUpdateEvent);
t3.strictSame(event.deltas.map(delta => [delta.name, delta.stats]), [
["alice", { Kills: 1 }],
["bob", { Deaths: 2 }],
]);
});
t2.test("a player joining is sent their totals", async t3 => {
const { instance, state } = await startPlugin(t3);
await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" });
const request = state.sent[0];
t3.ok(request instanceof messages.StatisticsGetRequest);
t3.equal(request.name, "alice");
t3.equal(state.rcons.length, 1);
t3.strictSame(decodeTotals(state.rcons[0]), { name: "alice", stats: { Kills: 3 } });
});
t2.test("the long bracket is not in the json", async t3 => {
const { instance, state } = await startPlugin(t3, { stats: { "]=]": 1 } });
await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" });
t3.ok(state.rcons[0].includes("[==["), "a longer bracket is used");
t3.strictSame(decodeTotals(state.rcons[0]).stats, { "]=]": 1 });
});
t2.test("leaving and other player events send nothing", async t3 => {
const { instance, state } = await startPlugin(t3);
await instance.hooks.playerEvent.invoke({ type: "leave", name: "alice", reason: "quit" });
await instance.hooks.playerEvent.invoke({ type: "promote", name: "alice" });
t3.strictSame(state.sent, []);
t3.strictSame(state.rcons, []);
});
t2.test("totals are not sent once the instance is no longer running", async t3 => {
const { instance, state } = await startPlugin(t3, { status: "stopping" });
await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" });
t3.strictSame(state.rcons, []);
});
t2.test("a failed request is logged", async t3 => {
const { instance, state } = await startPlugin(t3);
instance.sendTo = async () => { throw new Error("controller gone"); };
await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" });
t3.strictSame(state.rcons, []);
t3.match(state.warnings[0], /alice: controller gone/);
});
t2.test("start drops what was unsent and stop sends the rest", async t3 => {
const { instance, state } = await startPlugin(t3);
await instance.hooks.start.invoke();
await instance.hooks.stop.invoke();
t3.strictSame(state.rcons, [
"/sc exp_statistics.discard_unsent()",
"/sc exp_statistics.send_all()",
]);
});
t2.end();
});