Files
factorio-expcluster/exp_statistics/instance.ts
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

60 lines
2.0 KiB
TypeScript

import type { InstancePluginContext } from "@clusterio/host";
import * as messages from "./messages.js";
/** Sent by the lua side with what each player gained since the last update. */
export type IpcStatisticsUpdate = {
players: { name: string, stats: Record<string, number> }[],
};
/**
* Forwards the statistics gained in game to the controller, and hands the
* totals of a player to lua when they join, see module/control.lua.
*/
export default async function (context: InstancePluginContext) {
const { instance, plugin, logger } = context;
async function luaCall(code: string) {
await instance.sendRcon(`/sc ${code}`, true);
}
async function luaSendTotals(record: messages.PlayerStatistics) {
const json = JSON.stringify({ name: record.id, stats: record.stats });
// Pick a long bracket the json does not contain
let level = "=";
while (json.includes(`]${level}]`)) {
level += "=";
}
await luaCall(`exp_statistics.receive_totals(helpers.json_to_table[${level}[${json}]${level}])`);
}
instance.server.handle("exp_statistics:update", async (event: IpcStatisticsUpdate) => {
instance.sendTo("controller", new messages.StatisticsUpdateEvent(
event.players.map(player => new messages.StatisticsDelta(player.name, player.stats)),
));
});
instance.hooks.playerEvent.attach(plugin.name, async event => {
if (event.type !== "join") {
return;
}
try {
const record = await instance.sendTo("controller", new messages.StatisticsGetRequest(event.name));
if (instance.status === "running") {
await luaSendTotals(record);
}
} catch (err: any) {
logger.warn(`Failed to send the statistics of ${event.name}: ${err.message}`);
}
});
// Anything still unsent at start came from a save made before the last
// update was sent, so it is dropped rather than counted twice
instance.hooks.start.attach(plugin.name, async () => {
await luaCall("exp_statistics.discard_unsent()");
});
instance.hooks.stop.attach(plugin.name, async () => {
await luaCall("exp_statistics.send_all()");
});
}