diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index 8df6829e..0c4a1a9c 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -30,6 +30,28 @@ Remove the old package and install the new one in its place: The `inventory_sync.*` config fields and the `inventory_sync.inventory.view` permission are renamed on load. The controller moves `inventories.json` in the database directory to `player_sync.json`, and saves keep their player data the next time they are started with the new module. +## Fixing broken inventories + +The controller stores one entry per player in `player_sync.json` and keeps a lock on the player while an instance has them online. +When sync goes wrong for a player, the Player sync page in the web interface lists every stored entry with its generation, size and the instance holding the lock. +Click an entry to see a summary of its inventories, export it as JSON, import an edited JSON file in its place, delete it, or release the lock. + +The same operations are available from clusterioctl: + + npx clusterioctl player-sync list + npx clusterioctl player-sync show + npx clusterioctl player-sync export [file] + npx clusterioctl player-sync import + npx clusterioctl player-sync delete + npx clusterioctl player-sync release + +Importing bumps the generation so instances holding an older copy download the new one the next time the player joins. +Deleting makes the next join turn whatever the player has on that instance into the new synced inventory. +Both are refused while an instance holds the lock on the player, because the instance uploads its own copy when the player leaves and that would undo the change. +Have the player leave first, or release the lock if the instance is stuck holding it. + +Viewing and exporting need the `player_sync.inventory.view` permission. Importing, deleting and releasing need `player_sync.inventory.modify`, which is not granted by default. + ## Method of operation This plugin does event based synchronization of inventories. diff --git a/plugins/player_sync/controller.ts b/plugins/player_sync/controller.ts index 887b4b06..1355a18f 100644 --- a/plugins/player_sync/controller.ts +++ b/plugins/player_sync/controller.ts @@ -55,14 +55,22 @@ export default async function(context: ControllerPluginContext) { const playerDatastore: Map = await loadDatabase(controller.config, logger); let playerDatastoreDirty = false; - function acquire(instanceId: number, playerName: string): boolean { + // Returns the acquisition record for the player if it's still in effect + function getAcquisition(playerName: string) { let acquisitionRecord = acquiredPlayers.get(playerName); if ( !acquisitionRecord - || acquisitionRecord.instanceId === instanceId || !controller.instances.has(acquisitionRecord.instanceId) || acquisitionRecord.expiresMs && acquisitionRecord.expiresMs < Date.now() ) { + return undefined; + } + return acquisitionRecord; + } + + function acquire(instanceId: number, playerName: string): boolean { + let acquisitionRecord = getAcquisition(playerName); + if (!acquisitionRecord || acquisitionRecord.instanceId === instanceId) { acquiredPlayers.set(playerName, { instanceId }); return true; } @@ -70,6 +78,19 @@ export default async function(context: ControllerPluginContext) { return false; } + // Modifying stored data while an instance holds the player is pointless + // as the instance uploads its own copy when the player leaves. + function checkNotAcquired(playerName: string) { + let acquisitionRecord = getAcquisition(playerName); + if (acquisitionRecord) { + let instanceName = controller.instances.get(acquisitionRecord.instanceId)!.config.get("instance.name"); + throw new lib.RequestError( + `${playerName} is currently acquired by ${instanceName}, ` + + "have the player leave or release the lock first" + ); + } + } + controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance: InstanceRecord) => { let instanceId = instance.id; if (["unassigned", "deleted"].includes(instance.status)) { @@ -202,4 +223,63 @@ export default async function(context: ControllerPluginContext) { }, ); }); + + controller.handle(msg.ListPlayersRequest, async () => { + let entries = new Map(); + for (let [name, playerData] of playerDatastore) { + entries.set(name, new msg.PlayerEntry(name, playerData.generation, JSON.stringify(playerData).length)); + } + for (let name of acquiredPlayers.keys()) { + let acquisitionRecord = getAcquisition(name); + if (!acquisitionRecord) { + continue; + } + let entry = entries.get(name) ?? new msg.PlayerEntry(name); + entry.instanceId = acquisitionRecord.instanceId; + entries.set(name, entry); + } + return [...entries.values()]; + }); + + controller.handle(msg.GetPlayerDataRequest, async (request: msg.GetPlayerDataRequest) => { + let { playerName } = request; + return new msg.GetPlayerDataRequest.Response( + playerDatastore.get(playerName) ?? null, + getAcquisition(playerName)?.instanceId, + ); + }); + + controller.handle(msg.SetPlayerDataRequest, async (request: msg.SetPlayerDataRequest) => { + let { playerName, playerData } = request; + checkNotAcquired(playerName); + + // Bump the generation so instances holding an older copy download this one + let oldPlayerData = playerDatastore.get(playerName); + playerData.name = playerName; + playerData.generation = Math.max(playerData.generation, (oldPlayerData?.generation ?? 0) + 1); + playerDatastore.set(playerName, playerData); + playerDatastoreDirty = true; + logger.info(`Replaced player data for ${playerName} with generation ${playerData.generation}`); + return new msg.SetPlayerDataRequest.Response(playerData.generation); + }); + + controller.handle(msg.DeletePlayerDataRequest, async (request: msg.DeletePlayerDataRequest) => { + let { playerName } = request; + if (!playerDatastore.has(playerName)) { + throw new lib.RequestError(`No player data stored for ${playerName}`); + } + checkNotAcquired(playerName); + + playerDatastore.delete(playerName); + playerDatastoreDirty = true; + logger.info(`Deleted player data for ${playerName}`); + }); + + controller.handle(msg.ForceReleaseRequest, async (request: msg.ForceReleaseRequest) => { + let { playerName } = request; + if (!acquiredPlayers.delete(playerName)) { + throw new lib.RequestError(`${playerName} is not acquired by any instance`); + } + logger.info(`Released lock on ${playerName}`); + }); } diff --git a/plugins/player_sync/ctl.ts b/plugins/player_sync/ctl.ts new file mode 100644 index 00000000..d92b6550 --- /dev/null +++ b/plugins/player_sync/ctl.ts @@ -0,0 +1,125 @@ +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 ", "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 [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 ", "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 ", "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 ", "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); + }); +} diff --git a/plugins/player_sync/index.ts b/plugins/player_sync/index.ts index fe76dfba..11d0e0b1 100644 --- a/plugins/player_sync/index.ts +++ b/plugins/player_sync/index.ts @@ -18,6 +18,7 @@ declare module "@clusterio/lib" { } export interface Permissions { "player_sync.inventory.view": never; + "player_sync.inventory.modify": never; } } @@ -91,6 +92,7 @@ export const plugin: lib.PluginDeclaration = { }, controllerEntrypoint: "dist/node/controller.js", + ctlEntrypoint: "dist/node/ctl.js", controllerConfigFields: { "player_sync.player_lock_timeout": { title: "Player Lock Timeout", @@ -113,6 +115,11 @@ export const plugin: lib.PluginDeclaration = { messages.UploadRequest, messages.DownloadRequest, messages.DatabaseStatsRequest, + messages.ListPlayersRequest, + messages.GetPlayerDataRequest, + messages.SetPlayerDataRequest, + messages.DeletePlayerDataRequest, + messages.ForceReleaseRequest, ], permissions: [ { @@ -121,6 +128,11 @@ export const plugin: lib.PluginDeclaration = { description: "View player inventories", grantByDefault: true, }, + { + name: "player_sync.inventory.modify", + title: "Modify player inventories", + description: "Replace, delete and release the lock on player inventories stored on the controller", + }, ], webEntrypoint: "./web", routes: ["/inventory"], diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 993b56c4..6a5433b2 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -1,5 +1,5 @@ import { Type, Static } from "@sinclair/typebox"; -import { StringEnum } from "@clusterio/lib"; +import { StringEnum, jsonArray } from "@clusterio/lib"; // .\module\serialize.lua:serialize.serialize_player() export type IpcPlayerData = { @@ -193,3 +193,158 @@ export class DatabaseStatsRequest { static permission = "player_sync.inventory.view" as const; static Response = DatabaseStatsResponse; } + +export class PlayerEntry { + constructor( + public name: string, + public generation?: number, + public size?: number, + public instanceId?: number, + ) { + } + + static jsonSchema = Type.Object({ + "name": Type.String(), + "generation": Type.Optional(Type.Integer()), + "size": Type.Optional(Type.Integer()), + "instanceId": Type.Optional(Type.Integer()), + }); + + static fromJSON(json: Static): PlayerEntry { + return new this(json.name, json.generation, json.size, json.instanceId); + } +} + +export class ListPlayersRequest { + declare ["constructor"]: typeof ListPlayersRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "player_sync" as const; + static permission = "player_sync.inventory.view" as const; + static Response = jsonArray(PlayerEntry); +} + +export class GetPlayerDataResponse { + constructor( + public playerData: IpcPlayerData | null, + public instanceId?: number, + ) { + } + + static jsonSchema = Type.Object({ + "playerData": Type.Union([jsonPlayerData, Type.Null()]), + "instanceId": Type.Optional(Type.Integer()), + }); + + static fromJSON(json: Static): GetPlayerDataResponse { + return new this(json.playerData as IpcPlayerData | null, json.instanceId); + } +} + +export class GetPlayerDataRequest { + declare ["constructor"]: typeof GetPlayerDataRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "player_sync" as const; + static permission = "player_sync.inventory.view" as const; + static Response = GetPlayerDataResponse; + + constructor( + public playerName: string, + ) { + } + + static jsonSchema = Type.Object({ + "playerName": Type.String(), + }); + + static fromJSON(json: Static) { + return new this(json.playerName); + } +} + +export class SetPlayerDataResponse { + constructor( + public generation: number, + ) { + } + + static jsonSchema = Type.Object({ + "generation": Type.Integer(), + }); + + static fromJSON(json: Static): SetPlayerDataResponse { + return new this(json.generation); + } +} + +export class SetPlayerDataRequest { + declare ["constructor"]: typeof SetPlayerDataRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "player_sync" as const; + static permission = "player_sync.inventory.modify" as const; + static Response = SetPlayerDataResponse; + + constructor( + public playerName: string, + public playerData: IpcPlayerData, + ) { + } + + static jsonSchema = Type.Object({ + "playerName": Type.String(), + "playerData": jsonPlayerData, + }); + + static fromJSON(json: Static) { + return new this(json.playerName, json.playerData as IpcPlayerData); + } +} + +export class DeletePlayerDataRequest { + declare ["constructor"]: typeof DeletePlayerDataRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "player_sync" as const; + static permission = "player_sync.inventory.modify" as const; + + constructor( + public playerName: string, + ) { + } + + static jsonSchema = Type.Object({ + "playerName": Type.String(), + }); + + static fromJSON(json: Static) { + return new this(json.playerName); + } +} + +export class ForceReleaseRequest { + declare ["constructor"]: typeof ForceReleaseRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "player_sync" as const; + static permission = "player_sync.inventory.modify" as const; + + constructor( + public playerName: string, + ) { + } + + static jsonSchema = Type.Object({ + "playerName": Type.String(), + }); + + static fromJSON(json: Static) { + return new this(json.playerName); + } +} diff --git a/plugins/player_sync/package.json b/plugins/player_sync/package.json index 2f4db121..ff06cf62 100644 --- a/plugins/player_sync/package.json +++ b/plugins/player_sync/package.json @@ -35,6 +35,7 @@ "peerDependencies": { "@clusterio/controller": "workspace:^", + "@clusterio/ctl": "workspace:^", "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^" }, @@ -51,6 +52,7 @@ }, "devDependencies": { "@clusterio/controller": "workspace:^", + "@clusterio/ctl": "workspace:^", "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", diff --git a/plugins/player_sync/player_data.ts b/plugins/player_sync/player_data.ts new file mode 100644 index 00000000..b4857ee5 --- /dev/null +++ b/plugins/player_sync/player_data.ts @@ -0,0 +1,56 @@ +import type { IpcPlayerData } from "./messages.js"; + +// See packages/host/modules/clusterio/serialize.lua for the item stack format +type SerializedItem = { + n?: string, + c?: number, + q?: string, + r?: number, + e?: string, +}; + +export type ItemSummary = { + name: string, + quality?: string, + count: number, + /** Length of the export string for blueprints and other exportable items */ + exportSize?: number, +}; + +export type InventorySummary = { + name: string, + items: ItemSummary[], +}; + +export function summarizeInventory(inventory: { i?: SerializedItem[] }): ItemSummary[] { + let items: ItemSummary[] = []; + let stacks = new Map(); + for (let entry of inventory.i ?? []) { + let repeat = (entry.r ?? 0) + 1; + if (entry.e !== undefined) { + for (let i = 0; i < repeat; i++) { + items.push({ name: "exported item", count: 1, exportSize: entry.e.length }); + } + continue; + } + if (entry.n === undefined) { + continue; + } + let key = `${entry.n}\0${entry.q ?? ""}`; + let stack = stacks.get(key); + if (!stack) { + stack = { name: entry.n, quality: entry.q, count: 0 }; + stacks.set(key, stack); + items.push(stack); + } + stack.count += (entry.c ?? 0) * repeat; + } + return items; +} + +export function summarizePlayerInventories(playerData: IpcPlayerData): InventorySummary[] { + let inventories = playerData.character?.inventories ?? playerData.inventories ?? {}; + return Object.entries(inventories).map( + ([name, inventory]) => ({ name, items: summarizeInventory(inventory as { i?: SerializedItem[] }) }) + ); +} diff --git a/plugins/player_sync/test/controller.js b/plugins/player_sync/test/controller.js index b1da5e64..67b9c4c7 100644 --- a/plugins/player_sync/test/controller.js +++ b/plugins/player_sync/test/controller.js @@ -3,11 +3,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import * as lib from "@clusterio/lib"; +import { InstanceRecord } from "@clusterio/controller"; import * as mock from "../../../test/mock.js"; import controllerEntrypoint from "../dist/node/controller.js"; import { components as allComponents } from "../dist/node/components.js"; import { plugin as info } from "../dist/node/index.js"; -import { DatabaseStatsRequest, DownloadRequest, UploadRequest } from "../dist/node/messages.js"; +import * as msg from "../dist/node/messages.js"; describe("player_sync controller", function() { const databaseDir = path.join("temp", "test", "player_sync"); @@ -36,7 +37,7 @@ describe("player_sync controller", function() { await fs.writeFile(path.join(databaseDir, "inventories.json"), JSON.stringify([["test", playerData]])); const controller = await load(); - const stats = await mock.getHandler(controller, DatabaseStatsRequest)(); + const stats = await mock.getHandler(controller, msg.DatabaseStatsRequest)(); assert.equal(stats.databaseEntries, 1); assert.equal(stats.largestEntry.name, "test"); await assert.rejects(fs.access(path.join(databaseDir, "inventories.json")), { code: "ENOENT" }); @@ -47,7 +48,7 @@ describe("player_sync controller", function() { await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify([["new", { name: "new" }]])); const controller = await load(); - const stats = await mock.getHandler(controller, DatabaseStatsRequest)(); + const stats = await mock.getHandler(controller, msg.DatabaseStatsRequest)(); assert.equal(stats.databaseEntries, 1); assert.equal(stats.largestEntry.name, "new"); }); @@ -71,8 +72,8 @@ describe("player_sync controller", function() { }); async function upload(playerData) { - await mock.getHandler(controller, UploadRequest)({ instanceId: 1, playerName: "test", playerData }); - return (await mock.getHandler(controller, DownloadRequest)({ instanceId: 1, playerName: "test" })) + await mock.getHandler(controller, msg.UploadRequest)({ instanceId: 1, playerName: "test", playerData }); + return (await mock.getHandler(controller, msg.DownloadRequest)({ instanceId: 1, playerName: "test" })) .playerData; } @@ -89,4 +90,149 @@ describe("player_sync controller", function() { ); }); }); + + describe("player data tools", function() { + function playerData(name, generation, extra = {}) { + return { generation, name, controller: "character", force: "player", ...extra }; + } + + let controller; + function handler(Class) { + return mock.getHandler(controller, Class); + } + async function acquire(instanceId, playerName) { + const response = await handler(msg.AcquireRequest)(new msg.AcquireRequest(instanceId, playerName)); + return response.status === "acquired"; + } + async function saved() { + await controller.hooks.save.invoke(); + return new Map(JSON.parse(await fs.readFile(path.join(databaseDir, "player_sync.json"), "utf8"))); + } + + beforeEach(async function() { + controller = await mock.loadControllerPlugin(async (context) => { + context.controller.mockConfigEntries.set("controller.database_directory", databaseDir); + // Negative so locks expire as soon as their instance stops + context.controller.mockConfigEntries.set("player_sync.player_lock_timeout", -1); + await controllerEntrypoint(context); + }, info); + for (const [id, name, status] of [[1, "Running", "running"], [2, "Stopped", "stopped"]]) { + controller.instances.records.set(id, new InstanceRecord( + new lib.InstanceConfig("controller", { "instance.id": id, "instance.name": name }), status, + )); + } + + await handler(msg.UploadRequest)(new msg.UploadRequest(1, "stored", playerData("stored", 3))); + assert(await acquire(1, "stored")); + assert(await acquire(1, "online")); + assert(await acquire(2, "expired")); + await controller.hooks.instanceStatusChanged.invoke(controller.instances.get(2), "running"); + assert(await acquire(99, "gone")); + await saved(); + }); + + describe("ListPlayersRequest", function() { + it("should list stored data and live acquisitions", async function() { + const entries = await handler(msg.ListPlayersRequest)(new msg.ListPlayersRequest()); + entries.sort((a, b) => a.name.localeCompare(b.name)); + assert.deepEqual(entries, [ + new msg.PlayerEntry("online", undefined, undefined, 1), + new msg.PlayerEntry("stored", 3, JSON.stringify(playerData("stored", 3)).length, 1), + ]); + }); + }); + + describe("GetPlayerDataRequest", function() { + async function get(name) { + return await handler(msg.GetPlayerDataRequest)(new msg.GetPlayerDataRequest(name)); + } + it("should return stored data and acquisition", async function() { + assert.deepEqual(await get("stored"), new msg.GetPlayerDataResponse(playerData("stored", 3), 1)); + }); + it("should return null for unknown players", async function() { + assert.deepEqual(await get("unknown"), new msg.GetPlayerDataResponse(null, undefined)); + }); + it("should ignore expired and dangling acquisitions", async function() { + for (const name of ["expired", "gone"]) { + assert.equal((await get(name)).instanceId, undefined); + } + }); + }); + + const acquiredError = new lib.RequestError( + "stored is currently acquired by Running, have the player leave or release the lock first" + ); + + describe("SetPlayerDataRequest", function() { + async function set(name, data) { + return await handler(msg.SetPlayerDataRequest)(new msg.SetPlayerDataRequest(name, data)); + } + async function release(name) { + await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest(name)); + } + it("should store data with a bumped generation", async function() { + await release("stored"); + assert.deepEqual(await set("stored", playerData("other", 1)), new msg.SetPlayerDataResponse(4)); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 4)); + }); + it("should keep a higher provided generation", async function() { + await release("stored"); + assert.equal((await set("stored", playerData("stored", 10))).generation, 10); + }); + it("should store data for new players", async function() { + assert.equal((await set("new", playerData("new", 0))).generation, 1); + assert.equal((await saved()).get("new").generation, 1); + }); + it("should reject when the player is acquired", async function() { + await assert.rejects(set("stored", playerData("stored", 3)), acquiredError); + assert.equal((await saved()).get("stored").generation, 3); + }); + }); + + describe("DeletePlayerDataRequest", function() { + async function del(name) { + await handler(msg.DeletePlayerDataRequest)(new msg.DeletePlayerDataRequest(name)); + } + it("should delete stored data", async function() { + await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored")); + await del("stored"); + assert(!(await saved()).has("stored")); + }); + it("should reject unknown players", async function() { + await assert.rejects(del("unknown"), new lib.RequestError("No player data stored for unknown")); + }); + it("should reject when the player is acquired", async function() { + await assert.rejects(del("stored"), acquiredError); + assert((await saved()).has("stored")); + }); + }); + + describe("ForceReleaseRequest", function() { + async function release(name) { + await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest(name)); + } + it("should remove the acquisition", async function() { + await release("online"); + assert(await acquire(2, "online")); + }); + it("should reject players that are not acquired", async function() { + await assert.rejects( + release("unknown"), new lib.RequestError("unknown is not acquired by any instance"), + ); + }); + }); + + describe("AcquireRequest", function() { + it("should refuse players held by another instance", async function() { + assert(!await acquire(2, "online")); + assert(await acquire(1, "online")); + }); + it("should take over expired and dangling acquisitions", async function() { + assert(await acquire(1, "expired")); + assert(await acquire(1, "gone")); + const response = await handler(msg.GetPlayerDataRequest)(new msg.GetPlayerDataRequest("gone")); + assert.equal(response.instanceId, 1); + }); + }); + }); }); diff --git a/plugins/player_sync/test/plugin.js b/plugins/player_sync/test/plugin.js new file mode 100644 index 00000000..bfa82933 --- /dev/null +++ b/plugins/player_sync/test/plugin.js @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; + +import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js"; +import * as msg from "../dist/node/messages.js"; +import { summarizeInventory, summarizePlayerInventories } from "../dist/node/player_data.js"; + + +function playerData(name, generation, extra = {}) { + return { generation, name, controller: "character", force: "player", ...extra }; +} + +describe("player_sync plugin", function() { + describe("messages", function() { + it("PlayerEntry should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(msg.PlayerEntry, testMatrix( + ["player"], // name + [undefined, 0, 4], // generation + [undefined, 0, 1234], // size + [undefined, 1], // instanceId + )); + }); + it("GetPlayerDataResponse should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(msg.GetPlayerDataResponse, testMatrix( + [null, playerData("player", 1)], // playerData + [undefined, 1], // instanceId + )); + }); + it("SetPlayerDataResponse should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(msg.SetPlayerDataResponse, testMatrix([1, 5])); + }); + it("requests should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(msg.GetPlayerDataRequest, testMatrix(["player"])); + testRoundTripJsonSerialisable(msg.DeletePlayerDataRequest, testMatrix(["player"])); + testRoundTripJsonSerialisable(msg.ForceReleaseRequest, testMatrix(["player"])); + testRoundTripJsonSerialisable(msg.SetPlayerDataRequest, testMatrix( + ["player"], // playerName + [playerData("player", 3)], // playerData + )); + }); + }); + + describe("summarizeInventory()", function() { + it("should merge stacks and expand repeats", function() { + assert.deepEqual(summarizeInventory({ i: [ + { n: "iron-plate", c: 100, r: 2 }, + { n: "iron-plate", c: 50, q: "rare" }, + { n: "iron-plate", c: 7, s: 10 }, + { e: "0eNq", r: 1 }, + { f: "copper-plate" }, + ] }), [ + { name: "iron-plate", quality: undefined, count: 307 }, + { name: "iron-plate", quality: "rare", count: 50 }, + { name: "exported item", count: 1, exportSize: 4 }, + { name: "exported item", count: 1, exportSize: 4 }, + ]); + }); + it("should handle empty inventories", function() { + assert.deepEqual(summarizeInventory({}), []); + }); + }); + + describe("summarizePlayerInventories()", function() { + it("should prefer character inventories", function() { + const data = playerData("player", 1, { + character: { inventories: { main: { i: [{ n: "wood", c: 1 }] } } }, + inventories: { main: { i: [{ n: "stone", c: 1 }] } }, + }); + assert.deepEqual(summarizePlayerInventories(data), [ + { name: "main", items: [{ name: "wood", quality: undefined, count: 1 }] }, + ]); + }); + it("should fall back to player inventories", function() { + const data = playerData("player", 1, { inventories: { main: { i: [{ n: "stone", c: 1 }] } } }); + assert.deepEqual(summarizePlayerInventories(data), [ + { name: "main", items: [{ name: "stone", quality: undefined, count: 1 }] }, + ]); + }); + it("should handle missing inventories", function() { + assert.deepEqual(summarizePlayerInventories(playerData("player", 1)), []); + }); + }); +}); diff --git a/plugins/player_sync/tsconfig.browser.json b/plugins/player_sync/tsconfig.browser.json index ba2d8c39..a41ff3bb 100644 --- a/plugins/player_sync/tsconfig.browser.json +++ b/plugins/player_sync/tsconfig.browser.json @@ -4,5 +4,5 @@ { "path": "../../packages/lib/tsconfig.browser.json" }, { "path": "../../packages/web_ui/tsconfig.browser.json" }, ], - "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "player_data.ts", "package.json" ], } diff --git a/plugins/player_sync/tsconfig.node.json b/plugins/player_sync/tsconfig.node.json index 55d31e46..a6b81e60 100644 --- a/plugins/player_sync/tsconfig.node.json +++ b/plugins/player_sync/tsconfig.node.json @@ -4,6 +4,7 @@ { "path": "../../packages/lib/tsconfig.node.json" }, { "path": "../../packages/host/tsconfig.node.json" }, { "path": "../../packages/controller/tsconfig.node.json" }, + { "path": "../../packages/ctl/tsconfig.node.json" }, ], "include": ["./**/*.ts"], "exclude": ["test/*", "./dist/*"], diff --git a/plugins/player_sync/web/index.tsx b/plugins/player_sync/web/index.tsx index dea3d6bf..1d14c190 100644 --- a/plugins/player_sync/web/index.tsx +++ b/plugins/player_sync/web/index.tsx @@ -1,29 +1,279 @@ -import React, { useContext, useEffect, useState } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; +import { Alert, Button, Descriptions, Drawer, Popconfirm, Space, Table, Typography, Upload } from "antd"; -import { PageLayout, PageHeader, ControlContext, type WebPluginContext } from "@clusterio/web_ui"; -import { DatabaseStatsRequest, DatabaseStatsResponse } from "../messages.js"; +import * as lib from "@clusterio/lib"; +import { + PageLayout, PageHeader, ControlContext, FactorioIcon, notify, notifyErrorHandler, + useAccount, useInstances, useDefaultModPack, useExportLocale, useExportPrototypeMetadata, + useTableQueryState, useColumnSearch, type WebPluginContext, +} from "@clusterio/web_ui"; +import { + DatabaseStatsRequest, DatabaseStatsResponse, DeletePlayerDataRequest, ForceReleaseRequest, + GetPlayerDataRequest, GetPlayerDataResponse, IpcPlayerData, ListPlayersRequest, PlayerEntry, + SetPlayerDataRequest, +} from "../messages.js"; +import { type ItemSummary, summarizePlayerInventories } from "../player_data.js"; import "./style.css"; +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; + +function useInstanceName() { + const [instances] = useInstances(); + return (instanceId?: number) => { + if (instanceId === undefined) { + return ""; + } + return instances.get(instanceId)?.name ?? `Instance ${instanceId}`; + }; +} + +function InventoryTable(props: { name: string, items: ItemSummary[] }) { + const modPack = useDefaultModPack(); + const locale = useExportLocale(modPack); + const prototypes = useExportPrototypeMetadata(modPack); + const itemMetadata = prototypes?.get("item"); + + function getLocaleName(itemName: string) { + let meta = itemMetadata?.get(itemName); + if (typeof meta?.localised_name === "string") { + return meta.localised_name; + } + if (meta?.localised_name) { + return locale.get(meta.localised_name[0]) ?? itemName; + } + return locale.get(`item-name.${itemName}`) ?? locale.get(`entity-name.${itemName}`) ?? itemName; + } + + return props.name} + columns={[ + { + title: "Item", + key: "item", + render: (_, item) => (item.exportSize !== undefined + ? {item.name} + : <> + + {getLocaleName(item.name)} + + ), + }, + { + title: "Quality", + key: "quality", + render: (_, item) => item.quality, + }, + { + title: "Count", + key: "count", + align: "right", + render: (_, item) => item.count, + }, + { + title: "Size", + key: "size", + align: "right", + render: (_, item) => (item.exportSize === undefined ? "" : lib.formatBytes(item.exportSize)), + }, + ]} + dataSource={props.items.map((item, key) => ({ ...item, key }))} + pagination={false} + />; +} + +function PlayerDrawer(props: { playerName?: string, onClose: () => void, onChange: () => void }) { + const control = useContext(ControlContext); + const account = useAccount(); + const instanceName = useInstanceName(); + const [response, setResponse] = useState(); + const { playerName } = props; + + const reload = useCallback(() => { + if (playerName === undefined) { + setResponse(undefined); + return; + } + control.send(new GetPlayerDataRequest(playerName)).then(setResponse).catch( + notifyErrorHandler("Error loading player data") + ); + }, [control, playerName]); + useEffect(reload, [reload]); + + function changed() { + reload(); + props.onChange(); + } + + async function upload(file: File) { + let playerData: IpcPlayerData; + try { + playerData = JSON.parse(await file.text()); + } catch (err: any) { + notify("Invalid JSON", "error", err.message); + return; + } + playerData.name = playerName!; + playerData.generation ??= 0; + let result = await control.send(new SetPlayerDataRequest(playerName!, playerData)); + notify(`Stored player data for ${playerName} as generation ${result.generation}`); + changed(); + } + + function download() { + let json = JSON.stringify(response!.playerData, null, "\t"); + let url = URL.createObjectURL(new Blob([json], { type: "application/json" })); + let link = document.createElement("a"); + link.href = url; + link.download = `${playerName}.json`; + link.click(); + URL.revokeObjectURL(url); + } + + const canModify = account.hasPermission("player_sync.inventory.modify"); + const playerData = response?.playerData; + const acquiredBy = response?.instanceId !== undefined ? instanceName(response.instanceId) : undefined; + return + {response && + {acquiredBy !== undefined && control.send(new ForceReleaseRequest(playerName!)).then(changed).catch( + notifyErrorHandler("Error releasing lock") + )} + > + + } + />} + + {playerData?.generation ?? "-"} + + {playerData ? lib.formatBytes(JSON.stringify(playerData).length) : "-"} + + {playerData?.controller ?? "-"} + {playerData?.force ?? "-"} + + + + {canModify && { + upload(file).catch(notifyErrorHandler("Error storing player data")); + return false; + }} + > + + } + {canModify && control.send(new DeletePlayerDataRequest(playerName!)).then(changed).catch( + notifyErrorHandler("Error deleting player data") + )} + > + + } + + {!playerData && No player data stored} + {playerData && summarizePlayerInventories(playerData).map( + inventory => + )} + } + ; +} + function InventoryPage() { - let control = useContext(ControlContext); + const control = useContext(ControlContext); + const instanceName = useInstanceName(); + const [statsData, setStatsData] = useState(); + const [players, setPlayers] = useState([]); + const [selected, setSelected] = useState(); - let [statsData, updateStatsData] = useState(); + const tableState = useTableQueryState({ + namespace: "inventory", defaultSortKey: "name", pagination: { defaultPageSize: 50 }, + }); + const nameSearch = useColumnSearch(tableState, "name", player => player.name, "Search"); - useEffect(() => { - (async () => { - // Get statistics - updateStatsData(await control.send(new DatabaseStatsRequest())); - })(); - }, []); + const reload = useCallback(() => { + control.send(new DatabaseStatsRequest()).then(setStatsData).catch( + notifyErrorHandler("Error loading player sync statistics") + ); + control.send(new ListPlayersRequest()).then(setPlayers).catch( + notifyErrorHandler("Error loading player list") + ); + }, [control]); + useEffect(reload, [reload]); return {statsData && <> -

Database size: {Math.round(statsData.databaseSize / 1000)}kB

+

Database size: {lib.formatBytes(statsData.databaseSize)}

Database entries: {statsData.databaseEntries}

-

Largest entry is {statsData.largestEntry.name} with {(statsData.largestEntry.size / 1000)}kB

+

Largest entry is {statsData.largestEntry.name} with {lib.formatBytes(statsData.largestEntry.size)}

} +
strcmp(a.name, b.name), + sortOrder: tableState.sortOrder("name"), + }, + { + title: "Generation", + key: "generation", + align: "right", + sorter: (a, b) => (a.generation ?? -1) - (b.generation ?? -1), + sortOrder: tableState.sortOrder("generation"), + render: (_, player) => player.generation ?? "-", + }, + { + title: "Size", + key: "size", + align: "right", + sorter: (a, b) => (a.size ?? -1) - (b.size ?? -1), + sortOrder: tableState.sortOrder("size"), + render: (_, player) => (player.size === undefined ? "-" : lib.formatBytes(player.size)), + }, + { + title: "Acquired by", + key: "instance", + sorter: (a, b) => strcmp(instanceName(a.instanceId), instanceName(b.instanceId)), + sortOrder: tableState.sortOrder("instance"), + render: (_, player) => instanceName(player.instanceId), + }, + ]} + dataSource={players} + rowKey="name" + pagination={tableState.pagination} + onChange={tableState.onChange} + onRow={player => ({ + style: { cursor: "pointer" }, + onClick: () => setSelected(player.name), + })} + /> + setSelected(undefined)} onChange={reload} /> ; }