Files
factorio-clusterio/plugins/player_sync/ctl.ts
T
bbassieandClaude Fable 5.1 a81d98e981 Add inspect and modify tools for player sync
Admins had no way to look at or fix the player data the controller
stores when a sync went wrong. The web page now lists every stored
entry with its generation, size and the instance holding the lock, and
opens a drawer per player with an inventory summary, JSON export and
import, delete and lock release. The same operations are exposed as
clusterioctl player-sync commands.

Import bumps the generation past the stored one so instances holding an
older copy download the new data on the next join. Import and delete
are refused while an instance holds the lock, since its upload on leave
would undo the change. A new player_sync.inventory.modify permission
gates the write operations.

Closes #1003

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 18:52:29 +00:00

126 lines
4.9 KiB
TypeScript

import fs from "node:fs/promises";
import * as lib from "@clusterio/lib";
import type { Ctl, CtlPluginContext } from "@clusterio/ctl";
import * as msg from "./messages.js";
import { summarizePlayerInventories } from "./player_data.js";
function print(...content: any) {
// eslint-disable-next-line no-console
console.log(...content);
}
async function instanceName(ctl: Ctl, instanceId: number | undefined) {
if (instanceId === undefined) {
return "-";
}
let instance = await ctl.send(new lib.InstanceDetailsGetRequest(instanceId));
return `${instance.name} (${instanceId})`;
}
const playerSyncCommands = new lib.CommandTree({
name: "player-sync", description: "Player sync plugin commands",
});
playerSyncCommands.add(new lib.Command({
definition: [["list", "l"], "List player data stored on the controller"],
handler: async function(args: object, ctl: Ctl) {
let players = await ctl.send(new msg.ListPlayersRequest());
players.sort((a, b) => a.name.localeCompare(b.name));
print("name | generation | size | acquired by");
for (let player of players) {
print(
`${player.name} | ${player.generation ?? "-"} | ` +
`${player.size === undefined ? "-" : lib.formatBytes(player.size)} | ` +
`${await instanceName(ctl, player.instanceId)}`
);
}
},
}));
playerSyncCommands.add(new lib.Command({
definition: ["show <player>", "Show a summary of the stored player data", (yargs) => {
yargs.positional("player", { describe: "Name of the player", type: "string" });
}],
handler: async function(args: { player: string }, ctl: Ctl) {
let response = await ctl.send(new msg.GetPlayerDataRequest(args.player));
print(`acquired by: ${await instanceName(ctl, response.instanceId)}`);
let playerData = response.playerData;
if (!playerData) {
print(`No player data stored for ${args.player}`);
return;
}
print(`generation: ${playerData.generation}`);
print(`size: ${lib.formatBytes(JSON.stringify(playerData).length)}`);
print(`controller: ${playerData.controller}`);
print(`force: ${playerData.force}`);
for (let inventory of summarizePlayerInventories(playerData)) {
print(`${inventory.name}:`);
for (let item of inventory.items) {
let quality = item.quality ? ` (${item.quality})` : "";
let size = item.exportSize ? ` ${lib.formatBytes(item.exportSize)}` : "";
print(` ${item.count} ${item.name}${quality}${size}`);
}
}
},
}));
playerSyncCommands.add(new lib.Command({
definition: ["export <player> [file]", "Export stored player data as JSON", (yargs) => {
yargs.positional("player", { describe: "Name of the player", type: "string" });
yargs.positional("file", { describe: "File to write to, prints to stdout if omitted", type: "string" });
}],
handler: async function(args: { player: string, file?: string }, ctl: Ctl) {
let response = await ctl.send(new msg.GetPlayerDataRequest(args.player));
if (!response.playerData) {
throw new lib.CommandError(`No player data stored for ${args.player}`);
}
let content = JSON.stringify(response.playerData, null, "\t");
if (args.file) {
await fs.writeFile(args.file, content);
print(`Wrote player data for ${args.player} to ${args.file}`);
} else {
print(content);
}
},
}));
playerSyncCommands.add(new lib.Command({
definition: ["import <player> <file>", "Replace stored player data with JSON from a file", (yargs) => {
yargs.positional("player", { describe: "Name of the player", type: "string" });
yargs.positional("file", { describe: "File to read player data from", type: "string" });
}],
handler: async function(args: { player: string, file: string }, ctl: Ctl) {
let playerData = JSON.parse(await fs.readFile(args.file, "utf8"));
if (typeof playerData !== "object" || playerData === null || Array.isArray(playerData)) {
throw new lib.CommandError("Player data must be a JSON object");
}
playerData.name = args.player;
playerData.generation ??= 0;
let response = await ctl.send(new msg.SetPlayerDataRequest(args.player, playerData));
print(`Stored player data for ${args.player} as generation ${response.generation}`);
},
}));
playerSyncCommands.add(new lib.Command({
definition: ["delete <player>", "Delete stored player data", (yargs) => {
yargs.positional("player", { describe: "Name of the player", type: "string" });
}],
handler: async function(args: { player: string }, ctl: Ctl) {
await ctl.send(new msg.DeletePlayerDataRequest(args.player));
},
}));
playerSyncCommands.add(new lib.Command({
definition: ["release <player>", "Release the lock an instance holds on a player", (yargs) => {
yargs.positional("player", { describe: "Name of the player", type: "string" });
}],
handler: async function(args: { player: string }, ctl: Ctl) {
await ctl.send(new msg.ForceReleaseRequest(args.player));
},
}));
export default async function(context: CtlPluginContext) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
rootCommand.add(playerSyncCommands);
});
}