diff --git a/plugins/player_sync/README.md b/plugins/player_sync/README.md index 8df6829e..850f1724 100644 --- a/plugins/player_sync/README.md +++ b/plugins/player_sync/README.md @@ -30,6 +30,33 @@ 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 and deleting both start a new epoch for the player. +An instance holding a copy from an earlier epoch throws it away the next time the player joins, even if it is newer than what the controller has, and an upload from an earlier epoch is discarded. +After an import the instance downloads the new data. +After a delete the next join turns whatever the player has on that instance into the new synced inventory. +Deleted players stay in `player_sync.json` as an entry with `deleted: true` so their epoch survives a controller restart. +Hosts running a version of this plugin from before epochs keep comparing generations only, so they can still overwrite an import with a copy they failed to upload. +Update the plugin on every host to get the full guarantee. +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..16589fdd 100644 --- a/plugins/player_sync/controller.ts +++ b/plugins/player_sync/controller.ts @@ -7,6 +7,9 @@ import path from "path"; import * as lib from "@clusterio/lib"; import * as msg from "./messages.js"; +// Left behind by deleted player data so instances can tell their copy is stale +type Tombstone = { generation: number, epoch: number, deleted: true }; + async function loadDatabase(config: lib.ControllerConfig, logger: lib.Logger) { let databaseDirectory = config.get("controller.database_directory"); let itemsPath = path.resolve(databaseDirectory, "player_sync.json"); @@ -22,47 +25,74 @@ async function loadDatabase(config: lib.ControllerConfig, logger: lib.Logger) { } } } + let entries: [string, IpcPlayerData | Tombstone][] = []; logger.verbose(`Loading ${itemsPath}`); try { let content = await fs.readFile(itemsPath, { encoding: "utf8" }); - return new Map(JSON.parse(content)); + entries = JSON.parse(content); } catch (err: any) { - if (err.code === "ENOENT") { - logger.verbose("Creating new player data database"); - return new Map(); + if (err.code !== "ENOENT") { + throw err; } - throw err; + logger.verbose("Creating new player data database"); } + + let playerDatastore = new Map(); + let tombstones = new Map(); + for (let [name, entry] of entries) { + if ("deleted" in entry && entry.deleted === true) { + tombstones.set(name, entry); + } else { + // Imports used to keep the marker + delete (entry as any).deleted; + playerDatastore.set(name, entry as IpcPlayerData); + } + } + return { playerDatastore, tombstones }; } +// Tombstones share the file with the player data so a delete is written in one go async function saveDatabase( controllerConfig: lib.ControllerConfig, - playerDatastore: Map | undefined, + playerDatastore: Map, + tombstones: Map, logger: lib.Logger, ) { - if (playerDatastore) { - let file = path.resolve(controllerConfig.get("controller.database_directory"), "player_sync.json"); - logger.verbose(`writing ${file}`); - let content = JSON.stringify(Array.from(playerDatastore)); - await lib.safeOutputFile(file, content); - } + let file = path.resolve(controllerConfig.get("controller.database_directory"), "player_sync.json"); + logger.verbose(`writing ${file}`); + let content = JSON.stringify([...playerDatastore, ...tombstones]); + await lib.safeOutputFile(file, content); } export default async function(context: ControllerPluginContext) { const { controller, logger, plugin } = context; const acquiredPlayers = new Map(); - const playerDatastore: Map = await loadDatabase(controller.config, logger); + const { playerDatastore, tombstones } = await loadDatabase(controller.config, logger); let playerDatastoreDirty = false; - function acquire(instanceId: number, playerName: string): boolean { + // Generation and epoch instances should compare their copy against + function storedVersion(playerName: string) { + let stored = playerDatastore.get(playerName) ?? tombstones.get(playerName); + return { generation: stored?.generation ?? 0, epoch: stored?.epoch ?? 0 }; + } + + // 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 +100,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)) { @@ -101,7 +144,7 @@ export default async function(context: ControllerPluginContext) { controller.hooks.save.attach(plugin.name, async () => { if (playerDatastoreDirty) { playerDatastoreDirty = false; - await saveDatabase(controller.config, playerDatastore, logger); + await saveDatabase(controller.config, playerDatastore, tombstones, logger); } }); @@ -116,11 +159,13 @@ export default async function(context: ControllerPluginContext) { }; } - let playerData = playerDatastore.get(playerName); + let { generation, epoch } = storedVersion(playerName); return new msg.AcquireRequest.Response( "acquired", - playerData ? playerData.generation : 0, - Boolean(playerData), + generation, + playerDatastore.has(playerName), + undefined, + epoch || undefined, ); }); @@ -155,18 +200,33 @@ export default async function(context: ControllerPluginContext) { } acquiredPlayers.delete(playerName); - let oldPlayerData = playerDatastore.get(playerName); - if (store && oldPlayerData && oldPlayerData.generation >= playerData.generation) { + let stored = storedVersion(playerName); + if (playerData.epoch === undefined) { + // Instances from before epochs were added never send one, they keep the rules they had before + stored.generation = playerDatastore.get(playerName)?.generation ?? 0; + if (stored.epoch) { + playerData.epoch = stored.epoch; + } + } + if (store && (playerData.epoch ?? 0) !== stored.epoch) { logger.warn( - `${instanceName} uploaded generation ${playerData.generation} while the stored` + - `generation is ${oldPlayerData.generation} for ${playerName}` + `${instanceName} uploaded ${playerName} from epoch ${playerData.epoch} while the stored ` + + `epoch is ${stored.epoch}, discarding the outdated copy` + ); + store = false; + + } else if (store && stored.generation >= playerData.generation) { + logger.warn( + `${instanceName} uploaded generation ${playerData.generation} while the stored ` + + `generation is ${stored.generation} for ${playerName}` ); store = false; } if (store) { logger.verbose(`Received player data for ${playerName} from ${instanceName}`); - playerDatastore.set(playerName, mergePlayerData(oldPlayerData, playerData)); + playerDatastore.set(playerName, mergePlayerData(playerDatastore.get(playerName), playerData)); + tombstones.delete(playerName); playerDatastoreDirty = true; } }); @@ -202,4 +262,71 @@ export default async function(context: ControllerPluginContext) { }, ); }); + + controller.handle(msg.ListPlayersRequest, async () => { + let entries = new Map(); + for (let [name, playerData] of playerDatastore) { + let size = JSON.stringify(playerData).length; + entries.set(name, new msg.PlayerEntry(name, playerData.generation, size, undefined, playerData.force)); + } + 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); + + // A new epoch makes every instance drop its copy, including ones with an unconfirmed upload + let stored = storedVersion(playerName); + playerData.name = playerName; + // Start over from generations at or past the limit, instances that know epochs download the import anyway + let imported = playerData.generation < msg.generationLimit ? playerData.generation : 0; + let next = stored.generation < msg.generationLimit ? stored.generation + 1 : 1; + playerData.generation = Math.max(imported, next); + playerData.epoch = stored.epoch + 1; + playerDatastore.set(playerName, playerData); + tombstones.delete(playerName); + 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); + + let { generation, epoch } = storedVersion(playerName); + tombstones.set(playerName, { generation, epoch: epoch + 1, deleted: true }); + 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..c0028faf --- /dev/null +++ b/plugins/player_sync/ctl.ts @@ -0,0 +1,130 @@ +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 "-"; + } + try { + let instance = await ctl.send(new lib.InstanceDetailsGetRequest(instanceId)); + return `${instance.name} (${instanceId})`; + } catch (err) { + // Viewing player data does not require core.instance.get + return String(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/instance.ts b/plugins/player_sync/instance.ts index b4e6b978..d7fcec40 100644 --- a/plugins/player_sync/instance.ts +++ b/plugins/player_sync/instance.ts @@ -27,6 +27,7 @@ type IpcAcquireResponse = { generation?: number, has_data?: boolean, message?: string, + epoch?: number, } /** @@ -118,6 +119,7 @@ export default async function(context: InstancePluginContext) { message: "Controller is temporarily unavailable", has_data: undefined, generation: undefined, + epoch: undefined, }; if (host.connector.connected && !disconnecting) { @@ -132,6 +134,7 @@ export default async function(context: InstancePluginContext) { generation: acquireResponse.generation, has_data: acquireResponse.hasData, message: acquireResponse.message, + epoch: acquireResponse.epoch, }; } catch (err: any) { if (!(err instanceof lib.SessionLost)) { diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 993b56c4..146d2727 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -1,9 +1,14 @@ -import { Type, Static } from "@sinclair/typebox"; -import { StringEnum } from "@clusterio/lib"; +import { Type, Static, TSchema } from "@sinclair/typebox"; +import { StringEnum, jsonArray } from "@clusterio/lib"; // .\module\serialize.lua:serialize.serialize_player() export type IpcPlayerData = { generation: number, + /** + * Bumped when the stored data is imported or deleted, invalidating copies held by instances. + * Instances from before epochs were added leave it out of uploads. + */ + epoch?: number, name: string, controller?: string, color?: number[], @@ -18,17 +23,169 @@ export type IpcPlayerData = { character?: any, inventories?: any, quick_bar?: any, - hotbar?: string[], - personal_logistic_slots?: {name:string, min:number, max:number}[], + hotbar?: Record, + personal_logistic_slots?: any, crafting_queue?: any, recipe_notifications?: string, // Components the upload was serialized with, all when missing components?: Record | unknown[], } +// Marks tombstones in player_sync.json +const jsonReserved = { deleted: Type.Optional(Type.Never()) }; const jsonPlayerData = Type.Object({ - generation: Type.Number(), + generation: Type.Integer({ minimum: 0 }), + epoch: Type.Optional(Type.Integer({ minimum: 0 })), name: Type.String(), + ...jsonReserved, +}); + +// The parts of an import that module/serialize.lua relies on, item stacks are checked by the Lua deserializer +// An empty Lua table is encoded as an object +function jsonList(item: T) { + return Type.Union([Type.Array(item), Type.Object({}, { maxProperties: 0 })]); +} +// Item stack format of packages/host/modules/clusterio/serialize.lua, the nested parts are left to Lua +const jsonInventory = Type.Object({ + b: Type.Optional(Type.Integer({ minimum: 0 })), + i: jsonList(Type.Object({ + s: Type.Optional(Type.Integer({ minimum: 1 })), + r: Type.Optional(Type.Integer({ minimum: 0 })), + f: Type.Optional(Type.Union([Type.String(), Type.Object({})])), + e: Type.Optional(Type.String()), + n: Type.Optional(Type.String()), + c: Type.Optional(Type.Integer({ minimum: 1 })), + q: Type.Optional(Type.String()), + h: Type.Optional(Type.Number({ minimum: 0 })), + p: Type.Optional(Type.Number({ minimum: 0 })), + d: Type.Optional(Type.Number({ minimum: 0 })), + a: Type.Optional(Type.Number({ minimum: 0 })), + })), +}); +const jsonInventories = Type.Record(Type.String(), jsonInventory); +const jsonColor = Type.Union([ + Type.Object({ r: Type.Number(), g: Type.Number(), b: Type.Number(), a: Type.Optional(Type.Number()) }), + Type.Array(Type.Number(), { minItems: 3, maxItems: 4 }), +]); +// Missing stats are left as they are, the minimums are the ones Factorio enforces +const jsonCharacter = Type.Object({ + character_crafting_speed_modifier: Type.Optional(Type.Number({ minimum: -1 })), + character_mining_speed_modifier: Type.Optional(Type.Number({ minimum: -1 })), + character_additional_mining_categories: Type.Optional(jsonList(Type.String())), + character_running_speed_modifier: Type.Optional(Type.Number({ minimum: -1 })), + character_build_distance_bonus: Type.Optional(Type.Number({ minimum: 0 })), + character_item_drop_distance_bonus: Type.Optional(Type.Number({ minimum: 0 })), + character_reach_distance_bonus: Type.Optional(Type.Number({ minimum: 0 })), + character_resource_reach_distance_bonus: Type.Optional(Type.Number({ minimum: 0 })), + character_item_pickup_distance_bonus: Type.Optional(Type.Number({ minimum: 0 })), + character_loot_pickup_distance_bonus: Type.Optional(Type.Number({ minimum: 0 })), + character_inventory_slots_bonus: Type.Optional(Type.Integer({ minimum: 0 })), + character_trash_slot_count_bonus: Type.Optional(Type.Integer({ minimum: 0 })), + character_maximum_following_robot_count_bonus: Type.Optional(Type.Integer({ minimum: 0 })), + character_health_bonus: Type.Optional(Type.Number({ minimum: 0 })), + character_personal_logistic_requests_enabled: Type.Optional(Type.Boolean()), + allow_dispatching_robots: Type.Optional(Type.Boolean()), + inhibit_movement_bonus: Type.Optional(Type.Boolean()), + inventories: Type.Optional(jsonInventories), +}); +const jsonLogisticSlot = Type.Object({ + name: Type.String(), + min: Type.Optional(Type.Integer({ minimum: 0 })), + max: Type.Optional(Type.Integer({ minimum: 0 })), +}); +const jsonComparator = StringEnum(["=", ">", "<", "≥", ">=", "≤", "<=", "≠", "!="]); +// Limits enforced by LuaLogisticSection.filters +const jsonLogisticFilter = Type.Object({ + value: Type.Optional(Type.Union([ + Type.String(), + Type.Object({ + type: Type.Optional(StringEnum([ + "item", "fluid", "virtual", "entity", "recipe", "space-location", "asteroid-chunk", "quality", + ])), + name: Type.String(), + quality: Type.Optional(Type.String()), + comparator: Type.Optional(jsonComparator), + }), + ])), + min: Type.Optional(Type.Integer({ minimum: -(2 ** 31), maximum: 2 ** 31 - 1 })), + max: Type.Optional(Type.Integer({ minimum: 0, maximum: 2 ** 32 - 1 })), + minimum_delivery_count: Type.Optional(Type.Integer({ minimum: 0, maximum: 2 ** 32 - 1 })), + // Exported as null, the prototype does not convert to JSON + import_from: Type.Optional(Type.Union([Type.String(), Type.Null()])), +}); +const jsonPersonalLogisticSlots = Type.Union([ + Type.Object({ + enabled: Type.Boolean(), + trash_not_requested: Type.Boolean(), + sections: jsonList(Type.Object({ + group: Type.String(), + active: Type.Boolean(), + multiplier: Type.Number({ minimum: 0 }), + filters: jsonList(jsonLogisticFilter), + })), + }), + // From before 2.0, slot index to request + Type.Record(Type.String({ pattern: "^[1-9][0-9]*$" }), jsonLogisticSlot, { additionalProperties: false }), + Type.Array(jsonLogisticSlot), +]); +// QuickBarSlotEncoded in module/serialize.lua +const jsonQuickBarSlot = Type.Union([ + Type.String(), + Type.Object({ + t: Type.Literal("f"), + n: Type.String(), + q: Type.Optional(Type.String()), + c: Type.Optional(jsonComparator), + }), + Type.Object({ + t: Type.Literal("i"), + n: Type.String(), + s: Type.Integer({ minimum: 1 }), + b: Type.Optional(jsonList(Type.Integer({ minimum: 1 }))), + }), + Type.Object({ + t: Type.Literal("r"), + p: jsonList(Type.Integer({ minimum: 1 })), + y: Type.String(), + l: Type.Optional(Type.String()), + }), +]); +const jsonCraftingQueue = Type.Object({ + crafting_queue: jsonList(Type.Object({ recipe: Type.String(), count: Type.Integer({ minimum: 1 }) })), + crafting_queue_progress: Type.Number({ minimum: 0, maximum: 1 }), + ingredients: jsonList(Type.Object({ + name: Type.String(), + count: Type.Integer({ minimum: 1 }), + quality: Type.Optional(Type.String()), + })), +}); +const jsonTable = Type.Union([Type.Array(Type.Unknown()), Type.Object({})]); +// Lua's JSON loses precision past this, generations from here on are not counted up +export const generationLimit = 2 ** 50; +const jsonImportedPlayerData = Type.Object({ + generation: Type.Integer({ minimum: 0 }), + epoch: Type.Optional(Type.Integer({ minimum: 0 })), + name: Type.String(), + // Any physical controller, remote view is never stored and a cutscene needs waypoints to restore + controller: Type.Optional(StringEnum(["character", "god", "editor", "spectator", "ghost"])), + color: Type.Optional(jsonColor), + chat_color: Type.Optional(jsonColor), + tag: Type.Optional(Type.String()), + force: Type.Optional(Type.String()), + cheat_mode: Type.Optional(Type.Boolean()), + flashlight: Type.Optional(Type.Boolean()), + shortcuts: Type.Optional(Type.Record(Type.String(), Type.Boolean())), + game_view_settings: Type.Optional(Type.Record(Type.String(), Type.Boolean())), + ticks_to_respawn: Type.Optional(Type.Integer({ minimum: 0 })), + character: Type.Optional(jsonCharacter), + inventories: Type.Optional(jsonInventories), + quick_bar: Type.Optional(Type.Record(Type.String(), jsonQuickBarSlot)), + hotbar: Type.Optional(Type.Record(Type.String(), Type.String())), + personal_logistic_slots: Type.Optional(jsonPersonalLogisticSlots), + crafting_queue: Type.Optional(jsonCraftingQueue), + recipe_notifications: Type.Optional(Type.String()), + components: Type.Optional(jsonTable), + ...jsonReserved, }); export class AcquireResponse { @@ -37,6 +194,7 @@ export class AcquireResponse { public generation?: number, public hasData?: boolean, public message?: string, + public epoch?: number, ) { } @@ -45,10 +203,11 @@ export class AcquireResponse { "generation": Type.Optional(Type.Integer()), "hasData": Type.Optional(Type.Boolean()), "message": Type.Optional(Type.String()), + "epoch": Type.Optional(Type.Integer()), }); static fromJSON(json: Static): AcquireResponse { - return new this(json.status, json.generation, json.hasData, json.message); + return new this(json.status, json.generation, json.hasData, json.message, json.epoch); } }; export class AcquireRequest { @@ -193,3 +352,160 @@ 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, + public force?: string, + ) { + } + + static jsonSchema = Type.Object({ + "name": Type.String(), + "generation": Type.Optional(Type.Integer()), + "size": Type.Optional(Type.Integer()), + "instanceId": Type.Optional(Type.Integer()), + "force": Type.Optional(Type.String()), + }); + + static fromJSON(json: Static): PlayerEntry { + return new this(json.name, json.generation, json.size, json.instanceId, json.force); + } +} + +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": jsonImportedPlayerData, + }); + + 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/module/download_inventory.lua b/plugins/player_sync/module/download_inventory.lua index d6f6e54d..2d5a07b4 100644 --- a/plugins/player_sync/module/download_inventory.lua +++ b/plugins/player_sync/module/download_inventory.lua @@ -29,7 +29,7 @@ local function download_inventory(player_name, data, number, total, components) end if record.restart then rcon.print("Restarting outdated download") - player_sync.initiate_inventory_download(player, player_record, record.generation) + player_sync.initiate_inventory_download(player, player_record, record.generation, record.epoch) return end @@ -55,6 +55,7 @@ local function download_inventory(player_name, data, number, total, components) player_record.sync = true player_record.components = record.components player_record.generation = record.generation + player_record.epoch = record.epoch return end diff --git a/plugins/player_sync/module/player_sync.lua b/plugins/player_sync/module/player_sync.lua index 1569ae9f..7e7718d9 100644 --- a/plugins/player_sync/module/player_sync.lua +++ b/plugins/player_sync/module/player_sync.lua @@ -112,6 +112,8 @@ function player_sync.serialize_player(player, player_record) local failed_deserialization = get_script_data().failed_deserialization[player.name] or {} local serialized_player = serialize.serialize_player(player, failed_deserialization, player_record.components) serialized_player.generation = player_record.generation + -- Always sent so the controller can tell this instance supports epochs + serialized_player.epoch = player_record.epoch or 0 -- Leaving on the tick of a download the temporary bonus is still there and must not be uploaded. Removing it -- from the character here would shrink the main inventory before the armor's bonus applies and spill items. @@ -299,9 +301,15 @@ function player_sync.sync_player(acquire_response) end local script_data = get_script_data() + -- Epoch is bumped when an admin replaces or deletes the stored data + local epoch = acquire_response.epoch or 0 local finished_record = script_data.finished_downloads[acquire_response.player_name] if finished_record then - if acquire_response.status == "acquired" and finished_record.generation == acquire_response.generation then + if + acquire_response.status == "acquired" + and finished_record.generation == acquire_response.generation + and (finished_record.epoch or 0) == epoch + then player_sync.finish_download(player, finished_record) return end @@ -314,7 +322,11 @@ function player_sync.sync_player(acquire_response) local download_record = script_data.active_downloads[acquire_response.player_name] if download_record then -- Ignore the response if it affirms the current active download - if acquire_response.status == "acquired" and download_record.generation == acquire_response.generation then + if + acquire_response.status == "acquired" + and download_record.generation == acquire_response.generation + and (download_record.epoch or 0) == epoch + then game.print("Continuing active download") return end @@ -335,6 +347,8 @@ function player_sync.sync_player(acquire_response) -- download was started. Restart it. game.print("Restarting download") download_record.restart = true + download_record.generation = acquire_response.generation + download_record.epoch = epoch return end @@ -363,15 +377,17 @@ function player_sync.sync_player(acquire_response) return end + local stale = (player_record.epoch or 0) ~= epoch if acquire_response.has_data and ( not player_record.sync + or stale or acquire_response.generation > player_record.generation or serialize.newly_synced(player_record.components, script_data.components) ) then - player_sync.initiate_inventory_download(player, player_record, acquire_response.generation) + player_sync.initiate_inventory_download(player, player_record, acquire_response.generation, epoch) else if acquire_response.generation == 0 then @@ -385,6 +401,14 @@ function player_sync.sync_player(acquire_response) start_sync(player_record, script_data.components) player_record.dirty = true player_record.sync_in_progress = false + -- Deleted data keeps its generation and epoch so the next upload is accepted as the new baseline + player_record.epoch = epoch + -- A copy from another epoch has nothing to do with the stored generation + if stale then + player_record.generation = acquire_response.generation + else + player_record.generation = math.max(player_record.generation, acquire_response.generation) + end end end @@ -408,6 +432,7 @@ function player_sync.finish_download(player, finished_record) player_record.sync_in_progress = false start_sync(player_record, finished_record.components) player_record.generation = finished_record.generation + player_record.epoch = finished_record.epoch end -- Download inventory from controller @@ -514,12 +539,13 @@ function player_sync.confirm_upload(player_name, generation) player_record.dirty = false end -function player_sync.initiate_inventory_download(player, player_record, generation) +function player_sync.initiate_inventory_download(player, player_record, generation, epoch) player.print("Initiating inventory download...") local record = { started = game.ticks_played, last_active = game.ticks_played, generation = generation, + epoch = epoch, data = "", } local script_data = get_script_data() diff --git a/plugins/player_sync/module/serialize.lua b/plugins/player_sync/module/serialize.lua index 368766a6..8659bb1d 100644 --- a/plugins/player_sync/module/serialize.lua +++ b/plugins/player_sync/module/serialize.lua @@ -70,7 +70,10 @@ end --- Restore the stats of a character, the inventories are restored with place_items function serialize.deserialize_character(character, serialized) for _, key in pairs(character_stat_keys) do - character[key] = serialized[key] + -- Missing from imports and from data saved by another Factorio version + if serialized[key] ~= nil then + character[key] = serialized[key] + end end end @@ -382,13 +385,13 @@ function serialize.deserialize_personal_logistic_slots(player, serialized) logistic_point.remove_section(i) end - -- If this is an array instead of a table, migrate to v2 format + -- Data from before 2.0 maps slot indices to requests, migrate it to a section if next(serialized) == nil then return - elseif serialized[1] ~= nil then + elseif serialized.sections == nil then local section = logistic_point.add_section() for i, slot in pairs(serialized) do - section.set_slot(i, { + section.set_slot(tonumber(i), { value = { name = slot.name, quality = "normal", @@ -936,6 +939,7 @@ end --- @class SerializedPlayerData --- @field generation number +--- @field epoch number? --- @field name string --- @field controller string? --- @field color Color? @@ -1360,10 +1364,11 @@ function serialize.deserialize_player(player, serialized, components, local_cont end end - if syncs(components, "appearance") and serialized.color then - player.color = serialized.color - player.chat_color = serialized.chat_color - player.tag = serialized.tag + if syncs(components, "appearance") then + -- Imports may supply any subset of these + if serialized.color then player.color = serialized.color end + if serialized.chat_color then player.chat_color = serialized.chat_color end + if serialized.tag then player.tag = serialized.tag end end if syncs(components, "force") and serialized.force then player.force = serialized.force diff --git a/plugins/player_sync/package.json b/plugins/player_sync/package.json index 2f4db121..c95e068f 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:^" }, @@ -50,7 +51,9 @@ "@sinclair/typebox": "catalog:" }, "devDependencies": { + "@ant-design/icons": "^5.6.1", "@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..cbc1d661 --- /dev/null +++ b/plugins/player_sync/player_data.ts @@ -0,0 +1,61 @@ +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, +}; + +// Factorio serializes an empty table as {} +type SerializedInventory = { i?: SerializedItem[] | Record }; + +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: SerializedInventory): ItemSummary[] { + let items: ItemSummary[] = []; + let stacks = new Map(); + for (let entry of Object.values(inventory?.i ?? [])) { + // Uploads are not validated as strictly as imports + if (typeof entry !== "object" || entry === null) { + continue; + } + let repeat = Number.isInteger(entry.r) ? entry.r! + 1 : 1; + if (typeof entry.e === "string") { + items.push({ name: "exported item", count: repeat, exportSize: entry.e.length }); + continue; + } + if (typeof entry.n !== "string") { + 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 += (typeof entry.c === "number" ? 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 SerializedInventory) }) + ); +} diff --git a/plugins/player_sync/test/controller.js b/plugins/player_sync/test/controller.js index b1da5e64..2a5328df 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,300 @@ describe("player_sync controller", function() { ); }); }); + + describe("player data tools", function() { + function playerData(name, generation, extra = {}) { + return { generation, epoch: 0, 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"))); + } + async function upload(instanceId, data) { + await handler(msg.UploadRequest)(new msg.UploadRequest(instanceId, data.name, data)); + } + async function acquireResponse(instanceId, playerName) { + return await handler(msg.AcquireRequest)(new msg.AcquireRequest(instanceId, playerName)); + } + + async function loadWithInstances() { + 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, + )); + } + } + + beforeEach(async function() { + await loadWithInstances(); + + 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, "player"), + ]); + }); + }); + + 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 and epoch", 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, { epoch: 1 })); + }); + it("should invalidate unconfirmed uploads from instances", async function() { + // Instance 1 left with generation 4 but the upload never reached the controller + await release("stored"); + await set("stored", playerData("stored", 0)); + await upload(1, playerData("stored", 4)); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 4, { epoch: 1 })); + assert.deepEqual( + await acquireResponse(1, "stored"), new msg.AcquireResponse("acquired", 4, true, undefined, 1), + ); + }); + it("should ignore the epoch in imported data", async function() { + await release("stored"); + await set("stored", playerData("stored", 0, { epoch: 7 })); + assert.equal((await saved()).get("stored").epoch, 1); + }); + 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); + }); + + // The link validates requests before the handler runs + const fromJSON = lib.Link.requestFromJSON(msg.SetPlayerDataRequest, "SetPlayerDataRequest"); + async function setJSON(name, data) { + const request = fromJSON({ playerName: name, playerData: data }); + return await handler(msg.SetPlayerDataRequest)(request); + } + it("should keep the stored data when an import fails validation", async function() { + await release("stored"); + await setJSON("stored", playerData("stored", 0)); + const before = (await saved()).get("stored"); + assert.equal(before.epoch, 1); + const badFilter = { value: { name: "iron-plate" }, min: "many" }; + for (const extra of [ + { crafting_queue: {} }, + { deleted: false }, + { deleted: true }, + { controller: "cutscene" }, + { inventories: { main: { i: [{ e: null }] } } }, + { personal_logistic_slots: { + enabled: true, + trash_not_requested: false, + sections: [{ group: "", active: true, multiplier: 1, filters: [badFilter] }], + } }, + ]) { + await assert.rejects( + setJSON("stored", playerData("stored", 0, extra)), + { message: "Request SetPlayerDataRequest failed validation" }, + ); + } + assert.deepEqual((await saved()).get("stored"), before); + }); + it("should start over from imported generations past the limit", async function() { + await release("stored"); + assert.equal((await setJSON("stored", playerData("stored", 2 ** 53))).generation, 4); + await upload(1, playerData("stored", 5, { epoch: 1 })); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 5, { epoch: 1 })); + }); + for (const deleted of [false, true]) { + it(`should recover ${deleted ? "deleted" : "stored"} data past the generation limit`, async function() { + // Could be stored by uploads or by imports from before the limit + for (const generation of [msg.generationLimit, 2 ** 53]) { + const stuck = { ...playerData("stored", generation), ...deleted ? { deleted } : {} }; + const entries = [["stored", stuck]]; + await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify(entries)); + await loadWithInstances(); + // Importing the exported data as is + assert.equal((await setJSON("stored", playerData("stored", generation))).generation, 1); + await upload(1, playerData("stored", 2, { epoch: 1 })); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 2, { epoch: 1 })); + } + }); + } + it("should keep counting up below the generation limit", async function() { + const stored = playerData("stored", msg.generationLimit - 1); + await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify([["stored", stored]])); + await loadWithInstances(); + const response = await setJSON("stored", playerData("stored", 0)); + assert.equal(response.generation, msg.generationLimit); + }); + it("should only load entries marked deleted: true as tombstones", async function() { + // Imports from before the marker was reserved could store deleted: false + const data = playerData("stored", 4, { epoch: 1 }); + const entries = [["stored", { ...data, deleted: false }]]; + await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify(entries)); + controller = await load(); + const response = await handler(msg.GetPlayerDataRequest)(new msg.GetPlayerDataRequest("stored")); + assert.deepEqual(response.playerData, data); + }); + }); + + 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"); + const response = await handler(msg.GetPlayerDataRequest)(new msg.GetPlayerDataRequest("stored")); + assert.equal(response.playerData, null); + }); + it("should keep the generation and bump the epoch", async function() { + await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored")); + await del("stored"); + assert.deepEqual( + await acquireResponse(2, "stored"), new msg.AcquireResponse("acquired", 3, false, undefined, 1), + ); + // In the same file as the player data so the delete and its tombstone are written together + assert.deepEqual((await saved()).get("stored"), { generation: 3, epoch: 1, deleted: true }); + + controller = await load(); + assert.deepEqual( + await acquireResponse(2, "stored"), new msg.AcquireResponse("acquired", 3, false, undefined, 1), + ); + }); + it("should accept the next upload from the new epoch only", async function() { + await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored")); + await del("stored"); + await upload(1, playerData("stored", 11)); + assert((await saved()).get("stored").deleted); + await upload(2, playerData("stored", 4, { epoch: 1 })); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 4, { epoch: 1 })); + + // The other instance downloads the baseline instead of uploading its stale copy + await upload(1, playerData("stored", 11)); + assert.equal((await saved()).get("stored").generation, 4); + }); + 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.deepEqual((await saved()).get("stored"), playerData("stored", 3)); + }); + }); + + describe("uploads without an epoch", function() { + // What instances running a version from before epochs send + function legacyData(generation) { + const data = playerData("stored", generation); + delete data.epoch; + return data; + } + beforeEach(async function() { + await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored")); + }); + it("should store them by generation", async function() { + await upload(1, legacyData(3)); + await upload(1, legacyData(4)); + assert.deepEqual((await saved()).get("stored"), legacyData(4)); + }); + it("should keep accepting them after an import", async function() { + const request = new msg.SetPlayerDataRequest("stored", playerData("stored", 0)); + await handler(msg.SetPlayerDataRequest)(request); + await upload(1, legacyData(4)); + assert.equal((await saved()).get("stored").epoch, 1); + await upload(1, legacyData(5)); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 5, { epoch: 1 })); + }); + it("should accept any generation after a delete", async function() { + await handler(msg.DeletePlayerDataRequest)(new msg.DeletePlayerDataRequest("stored")); + await upload(1, legacyData(1)); + assert.deepEqual((await saved()).get("stored"), playerData("stored", 1, { epoch: 1 })); + }); + }); + + 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..77516d93 --- /dev/null +++ b/plugins/player_sync/test/plugin.js @@ -0,0 +1,419 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import * as lib from "@clusterio/lib"; +import { CtlHooks } from "@clusterio/ctl"; + +import * as mock from "../../../test/mock.js"; +import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js"; +import * as msg from "../dist/node/messages.js"; +import { summarizeInventory, summarizePlayerInventories } from "../dist/node/player_data.js"; +import ctlEntrypoint from "../dist/node/ctl.js"; +import { plugin as info } from "../dist/node/index.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 + [undefined, "player"], // force + )); + }); + it("GetPlayerDataResponse should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(msg.GetPlayerDataResponse, testMatrix( + [null, playerData("player", 1)], // playerData + [undefined, 1], // instanceId + )); + }); + it("AcquireResponse should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(msg.AcquireResponse, testMatrix( + ["acquired", "busy"], // status + [undefined, 3], // generation + [undefined, true], // hasData + [undefined, "Instance"], // message + [undefined, 2], // epoch + )); + }); + it("should reject non-integer generations", function() { + const validate = lib.compile(msg.SetPlayerDataRequest.jsonSchema); + assert(validate({ playerName: "player", playerData: playerData("player", 5) })); + for (const generation of [5.5, -1, "5"]) { + const request = { playerName: "player", playerData: playerData("player", generation) }; + assert(!validate(request), `accepted ${generation}`); + } + }); + it("should reject uploads carrying the tombstone marker", function() { + const validate = lib.compile(msg.UploadRequest.jsonSchema); + assert(validate({ instanceId: 1, playerName: "player", playerData: playerData("player", 5) })); + for (const deleted of [false, true]) { + const data = playerData("player", 5, { deleted }); + assert(!validate({ instanceId: 1, playerName: "player", playerData: data }), `accepted ${deleted}`); + } + }); + describe("SetPlayerDataRequest schema", function() { + const validate = lib.compile(msg.SetPlayerDataRequest.jsonSchema); + // Shaped like module/serialize.lua output + const exported = playerData("player", 7, { + epoch: 2, + color: { r: 1, g: 0.5, b: 0, a: 0.5 }, + chat_color: { r: 1, g: 0.5, b: 0, a: 1 }, + tag: "", + cheat_mode: false, + flashlight: true, + shortcuts: { "toggle-personal-roboport": true }, + game_view_settings: { show_minimap: true }, + character: { + character_running_speed_modifier: 0, + character_additional_mining_categories: {}, + allow_dispatching_robots: true, + inventories: { + main: { b: 80, i: [ + { n: "iron-plate", c: 100, r: 2 }, + { s: 10, f: { name: "coal" } }, + { f: { quality: "rare", comparator: "=" } }, + { f: "stone" }, + { n: "firearm-magazine", c: 10, q: "rare", a: 5 }, + { n: "automation-science-pack", c: 3, d: 0.5, p: 0.25, h: 0.75 }, + { e: "0eNq" }, + ] }, + armor: { i: {} }, + }, + }, + personal_logistic_slots: { + enabled: true, + trash_not_requested: false, + sections: [{ + group: "", + active: true, + multiplier: 1, + // Empty slots export as {} + filters: [ + {}, + { value: { name: "iron-plate", type: "item" }, min: 3 }, + { value: { name: "stone", type: "item" }, min: -5, max: 10 }, + { + value: { name: "coal", type: "item", quality: "normal", comparator: "=" }, + min: 5, + max: 10, + minimum_delivery_count: 2, + import_from: null, + }, + ], + }], + }, + quick_bar: { + 1: "iron-plate", + 2: { t: "f", n: "iron-gear-wheel", q: "normal", c: "=" }, + 3: { t: "i", n: "main", s: 4, b: [2] }, + 4: { t: "r", p: [1], y: "blueprint" }, + }, + crafting_queue: { + crafting_queue: [{ recipe: "iron-gear-wheel", count: 5 }], + crafting_queue_progress: 0.25, + ingredients: [{ name: "iron-plate", count: 10 }], + }, + recipe_notifications: "", + components: { controller: true, inventories: true }, + }); + function accepts(data) { + return validate({ playerName: "player", playerData: data }); + } + + it("should accept exported and partial player data", function() { + assert(accepts(exported), JSON.stringify(validate.errors)); + assert(accepts({ name: "player", generation: 0 })); + assert(accepts(playerData("player", 0, { controller: "ghost", ticks_to_respawn: 300 }))); + assert(accepts(playerData("player", 0, { color: [1, 0, 0] }))); + assert(accepts(playerData("player", 0, { chat_color: [1, 0, 0], tag: "tag" }))); + assert(accepts(playerData("player", 0, { + crafting_queue: { crafting_queue: {}, crafting_queue_progress: 0, ingredients: {} }, + personal_logistic_slots: { 1: { name: "iron-plate", min: 50 } }, + hotbar: { 1: "iron-plate" }, + }))); + }); + it("should reject data the scenario can't restore", function() { + for (const [field, value] of [ + ["controller", "not-a-controller"], + ["controller", "remote"], + ["controller", "cutscene"], + ["deleted", false], + ["deleted", true], + ["color", "red"], + ["force", 1], + ["ticks_to_respawn", -1], + ["inventories", { main: { i: "iron-plate" } }], + ["inventories", { main: [] }], + ["inventories", { main: { i: [{ e: null }] } }], + ["inventories", { main: { i: [{ n: 5, c: 1 }] } }], + ["inventories", { main: { i: [{ n: "wood", c: "many" }] } }], + ["inventories", { main: { i: [{ n: "wood", c: 0 }] } }], + ["character", { inventories: { main: { i: [{ s: 0 }] } } }], + ["shortcuts", { "toggle-personal-roboport": "yes" }], + ["character", { character_health_bonus: "100" }], + ["character", { character_inventory_slots_bonus: -1 }], + ["character", { character_reach_distance_bonus: -1 }], + ["character", { character_crafting_speed_modifier: -5 }], + ["quick_bar", { 1: { t: "f", n: "iron-plate", q: "normal", c: "~" } }], + ["crafting_queue", {}], + ["crafting_queue", { crafting_queue: [], crafting_queue_progress: 0 }], + ["crafting_queue", { + crafting_queue: [{ recipe: "iron-gear-wheel" }], crafting_queue_progress: 0, ingredients: [], + }], + ["quick_bar", { 1: { t: "i", n: "main" } }], + ["quick_bar", { 1: { t: "r", y: "blueprint" } }], + ["quick_bar", { 1: 5 }], + ["personal_logistic_slots", { enabled: true, sections: [] }], + ["personal_logistic_slots", { enabled: true, trash_not_requested: false, sections: [{}] }], + ["personal_logistic_slots", { 1: { min: 50 } }], + ["personal_logistic_slots", { + enabled: true, + trash_not_requested: false, + sections: [{ group: "", active: true, multiplier: 1, filters: [{ value: 5 }] }], + }], + ]) { + assert(!accepts({ ...exported, [field]: value }), `accepted ${field} ${JSON.stringify(value)}`); + } + for (const filter of [ + { value: { name: "iron-plate" }, min: "many" }, + { value: { name: "iron-plate" }, min: 2 ** 31 }, + { value: { name: "iron-plate" }, max: -1 }, + { value: { name: "iron-plate" }, minimum_delivery_count: -1 }, + { value: { type: "item" }, min: 1 }, + { value: { name: "iron-plate", type: "nope" }, min: 1 }, + { value: { name: "iron-plate", comparator: "~" }, min: 1 }, + { value: { name: "iron-plate", quality: 5 }, min: 1 }, + ]) { + const slots = { + enabled: true, + trash_not_requested: false, + sections: [{ group: "", active: true, multiplier: 1, filters: [filter] }], + }; + assert( + !accepts({ ...exported, personal_logistic_slots: slots }), `accepted ${JSON.stringify(filter)}`, + ); + } + }); + }); + 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: 2, exportSize: 4 }, + ]); + }); + it("should skip malformed entries", function() { + assert.deepEqual(summarizeInventory({ i: [ + { e: null }, + null, + "wood", + { n: 5, c: 1 }, + { n: "wood", c: "many", r: "x" }, + { n: "wood", c: 2, r: 1 }, + { e: "0eNq", r: 1e9 }, + ] }), [ + { name: "wood", quality: undefined, count: 4 }, + { name: "exported item", count: 1e9 + 1, exportSize: 4 }, + ]); + assert.deepEqual( + summarizePlayerInventories(playerData("player", 1, { inventories: { main: null } })), + [{ name: "main", items: [] }], + ); + }); + it("should handle empty inventories", function() { + assert.deepEqual(summarizeInventory({}), []); + assert.deepEqual(summarizeInventory(JSON.parse('{"i":{}}')), []); + assert.deepEqual( + summarizeInventory({ i: [{ n: "wood" }] }), + [{ name: "wood", quality: undefined, count: 0 }], + ); + }); + }); + + 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 empty serialized inventories", function() { + const data = JSON.parse('{"generation":1,"name":"player","character":{"inventories":{"armor":{"i":{}}}}}'); + assert.deepEqual(summarizePlayerInventories(data), [{ name: "armor", items: [] }]); + }); + it("should handle missing inventories", function() { + assert.deepEqual(summarizePlayerInventories(playerData("player", 1)), []); + assert.deepEqual(summarizePlayerInventories(playerData("player", 1, { character: {} })), []); + }); + }); + + describe("ctl commands", function() { + let commands; + let sent; + let printed; + let responses; + const ctl = { + async send(message) { + sent.push(message); + const handler = responses.get(message.constructor); + if (!handler) { + throw new Error(`Unexpected ${message.constructor.name}`); + } + return handler(message); + }, + }; + const storedData = playerData("alice", 2, { + character: { inventories: { main: { i: [{ n: "wood", c: 5, q: "rare" }, { e: "0eNq" }] } } }, + }); + let tempDir; + + before(async function() { + const root = new lib.CommandTree({ name: "clusterioctl", description: "root" }); + const hooks = new CtlHooks(new mock.MockLogger()); + await ctlEntrypoint({ hooks, logger: new mock.MockLogger(), plugin: info }); + await hooks.addCommands.invoke(root); + commands = root.subCommands.get("player-sync").subCommands; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "player_sync-ctl-")); + }); + after(async function() { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + beforeEach(function() { + sent = []; + printed = []; + responses = new Map([ + [lib.InstanceDetailsGetRequest, request => ({ id: request.instanceId, name: "Test" })], + [msg.ListPlayersRequest, () => [ + new msg.PlayerEntry("bob", undefined, undefined, 1), + new msg.PlayerEntry("alice", 2, 100), + ]], + [msg.GetPlayerDataRequest, request => new msg.GetPlayerDataResponse( + request.playerName === "alice" ? storedData : null, request.playerName === "alice" ? 1 : undefined + )], + [msg.SetPlayerDataRequest, () => new msg.SetPlayerDataResponse(3)], + [msg.DeletePlayerDataRequest, () => undefined], + [msg.ForceReleaseRequest, () => undefined], + ]); + }); + + async function run(name, args = {}) { + /* eslint-disable no-console */ + const original = console.log; + console.log = (...values) => printed.push(values.join(" ")); + try { + await commands.get(name).run(args, ctl); + } finally { + console.log = original; + } + /* eslint-enable no-console */ + } + + it("list should print sorted entries", async function() { + await run("list"); + assert.deepEqual(printed, [ + "name | generation | size | acquired by", + `alice | 2 | ${lib.formatBytes(100)} | -`, + "bob | - | - | Test (1)", + ]); + }); + it("show should print a summary", async function() { + await run("show", { player: "alice" }); + assert.deepEqual(printed, [ + "acquired by: Test (1)", + "generation: 2", + `size: ${lib.formatBytes(JSON.stringify(storedData).length)}`, + "controller: character", + "force: player", + "main:", + " 5 wood (rare)", + ` 1 exported item ${lib.formatBytes(4)}`, + ]); + }); + it("should show the instance ID when details can't be fetched", async function() { + responses.set(lib.InstanceDetailsGetRequest, () => { + throw new lib.PermissionError("Permission denied"); + }); + await run("list"); + assert.equal(printed[2], "bob | - | - | 1"); + printed = []; + await run("show", { player: "alice" }); + assert.equal(printed[0], "acquired by: 1"); + }); + it("show should handle players without data", async function() { + await run("show", { player: "carol" }); + assert.deepEqual(printed, ["acquired by: -", "No player data stored for carol"]); + }); + it("export should print or write JSON", async function() { + await run("export", { player: "alice" }); + assert.deepEqual(JSON.parse(printed.join("\n")), storedData); + const file = path.join(tempDir, "alice.json"); + await run("export", { player: "alice", file }); + assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), storedData); + await assert.rejects( + run("export", { player: "carol" }), + new lib.CommandError("No player data stored for carol"), + ); + }); + it("import should send the file contents", async function() { + const file = path.join(tempDir, "import.json"); + await fs.writeFile(file, JSON.stringify({ name: "other", controller: "god" })); + await run("import", { player: "alice", file }); + assert.deepEqual(sent, [ + new msg.SetPlayerDataRequest("alice", { name: "alice", controller: "god", generation: 0 }), + ]); + assert.deepEqual(printed, ["Stored player data for alice as generation 3"]); + }); + it("import should reject non-object JSON", async function() { + const file = path.join(tempDir, "bad.json"); + await fs.writeFile(file, "[1]"); + await assert.rejects( + run("import", { player: "alice", file }), + new lib.CommandError("Player data must be a JSON object"), + ); + }); + it("delete and release should send requests", async function() { + await run("delete", { player: "alice" }); + await run("release", { player: "alice" }); + assert.deepEqual(sent, [new msg.DeletePlayerDataRequest("alice"), new msg.ForceReleaseRequest("alice")]); + }); + }); +}); 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..dcfd2c9d 100644 --- a/plugins/player_sync/web/index.tsx +++ b/plugins/player_sync/web/index.tsx @@ -1,29 +1,389 @@ -import React, { useContext, useEffect, useState } from "react"; +import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { Alert, Button, Descriptions, Drawer, Input, Popconfirm, Space, Table, Typography, Upload } from "antd"; +import type { ColumnType } from "antd/es/table"; +import { DeleteOutlined, SearchOutlined } from "@ant-design/icons"; -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 titleCase(text: string) { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +// Factorio reads colors back as 0-1 floats but accepts 0-255 as well +function formatColor(color: any) { + let [r, g, b] = Array.isArray(color) ? color : [color.r, color.g, color.b]; + let scale = Math.max(r, g, b) > 1 ? 1 : 255; + let hex = (c: number) => Math.min(255, Math.max(0, Math.round(c * scale))).toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} + +// Filters the table locally, the inventory tables are not kept in the URL +function itemSearch(getText: (record: T) => string): ColumnType { + return { + filterIcon: (filtered: boolean) => , + filterDropdown: ({ selectedKeys, setSelectedKeys, confirm }) => ( +
e.stopPropagation()}> + { + setSelectedKeys(e.target.value ? [e.target.value] : []); + confirm({ closeDropdown: false }); + }} + onPressEnter={() => confirm()} + /> +
+ ), + onFilter: (value, record) => getText(record).toLowerCase().includes(String(value).toLowerCase()), + }; +} + +function InventoryTable(props: { name: string, items: ItemSummary[] }) { + const modPack = useDefaultModPack(); + const locale = useExportLocale(modPack); + const prototypes = useExportPrototypeMetadata(modPack); + const itemMetadata = prototypes?.get("item"); + const qualityMetadata = prototypes?.get("quality"); + // The export lists qualities in the order mods define them, normal is left out of the stored data + const qualityOrder = [...qualityMetadata?.keys() ?? []]; + const qualityRank = (quality?: string) => (quality === undefined ? -1 : qualityOrder.indexOf(quality)); + + function getLocaleName(type: "item" | "quality", name: string) { + let meta = (type === "item" ? itemMetadata : qualityMetadata)?.get(name); + if (typeof meta?.localised_name === "string") { + return meta.localised_name; + } + if (meta?.localised_name) { + return locale.get(meta.localised_name[0]) ?? name; + } + if (type === "quality") { + return locale.get(`quality-name.${name}`) ?? name; + } + return locale.get(`item-name.${name}`) ?? locale.get(`entity-name.${name}`) ?? name; + } + const itemText = (item: ItemSummary) => ( + item.exportSize !== undefined ? item.name : `${getLocaleName("item", item.name)} ${item.name}` + ); + + return + size="small" + title={() => titleCase(props.name)} + columns={[ + { + title: "Item", + key: "item", + ...itemSearch(itemText), + sorter: (a, b) => strcmp(itemText(a), itemText(b)), + render: (_, item) => (item.exportSize !== undefined + ? {item.name} + : + + {getLocaleName("item", item.name)} + + ), + }, + { + title: "Quality", + key: "quality", + sorter: (a, b) => qualityRank(a.quality) - qualityRank(b.quality), + render: (_, item) => item.quality !== undefined && + + {getLocaleName("quality", item.quality)} + , + }, + { + title: "Count", + key: "count", + align: "right", + sorter: (a, b) => a.count - b.count, + render: (_, item) => item.count, + }, + { + title: "Size", + key: "size", + align: "right", + sorter: (a, b) => (a.exportSize ?? -1) - (b.exportSize ?? -1), + render: (_, item) => (item.exportSize === undefined ? "" : lib.formatBytes(item.exportSize)), + }, + ]} + dataSource={props.items.map((item, key) => ({ ...item, key }))} + pagination={{ pageSize: 10, hideOnSinglePage: true, showSizeChanger: false }} + />; +} + +function usePlayerData(playerName?: string) { + const control = useContext(ControlContext); + const [loaded, setLoaded] = useState<{ playerName: string, response: GetPlayerDataResponse }>(); + // Only the latest request may set the response, an import or delete can finish after a reload + const requestId = useRef(0); + const currentPlayer = useRef(playerName); + currentPlayer.current = playerName; + + const reload = useCallback(() => { + // Ignore reloads from actions that finish after switching to another player + if (playerName !== currentPlayer.current) { + return; + } + requestId.current += 1; + let id = requestId.current; + if (playerName === undefined) { + return; + } + control.send(new GetPlayerDataRequest(playerName)).then(response => { + if (id === requestId.current) { + setLoaded({ playerName, response }); + } + }).catch( + notifyErrorHandler("Error loading player data") + ); + }, [control, playerName]); + useEffect(reload, [reload]); + + return [loaded?.playerName === playerName ? loaded?.response : undefined, reload] as const; +} + +type PlayerProps = { playerName: string, response: GetPlayerDataResponse, onChange: () => void }; + +function PlayerDrawer(props: { playerName?: string, onClose: () => void, onChange: () => void }) { + const { playerName } = props; + const [response, reload] = usePlayerData(playerName); + + function changed() { + reload(); + props.onChange(); + } + + const loaded = response && playerName !== undefined; + return } + > + {loaded && } + ; +} + +function PlayerActions(props: PlayerProps) { + const control = useContext(ControlContext); + const account = useAccount(); + const { playerName, response } = props; + + 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}`); + props.onChange(); + } + + 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 acquired = response.instanceId !== undefined; + return + + {canModify && { + upload(file).catch(notifyErrorHandler("Error storing player data")); + return false; + }} + > + + } + {canModify && control.send(new DeletePlayerDataRequest(playerName)).then(props.onChange).catch( + notifyErrorHandler("Error deleting player data") + )} + > + + } + />} + + {playerData?.generation ?? "-"} + + {playerData ? lib.formatBytes(JSON.stringify(playerData).length) : "-"} + + {playerData?.controller ?? "-"} + {playerData?.force ?? "-"} + + {color ? + + {color} + : "-"} + + {cheatMode} + + {!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"); + const forces = [...new Set(players.flatMap(player => (player.force === undefined ? [] : [player.force])))]; - 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 entries: {statsData.databaseEntries}

-

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

- } + {statsData && + {lib.formatBytes(statsData.databaseSize)} + {statsData.databaseEntries} + } + strcmp(a.name, b.name), + sortOrder: tableState.sortOrder("name"), + }, + { + title: "Force", + key: "force", + filters: forces.sort(strcmp).map(force => ({ text: force, value: force })), + filterMultiple: true, + filteredValue: tableState.filteredValue("force"), + onFilter: (value, player) => player.force === value, + sorter: (a, b) => strcmp(a.force ?? "", b.force ?? ""), + sortOrder: tableState.sortOrder("force"), + render: (_, player) => player.force ?? "-", + }, + { + 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} /> ; } diff --git a/plugins/player_sync/web/style.css b/plugins/player_sync/web/style.css index e69de29b..48549508 100644 --- a/plugins/player_sync/web/style.css +++ b/plugins/player_sync/web/style.css @@ -0,0 +1,8 @@ +.player-sync-color { + display: inline-block; + width: 16px; + height: 16px; + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 2px; + vertical-align: middle; +}