mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
Add inspect and modify tools for player sync
Admins had no way to look at or fix the player data the controller stores when a sync went wrong. The web page now lists every stored entry with its generation, size and the instance holding the lock, and opens a drawer per player with an inventory summary, JSON export and import, delete and lock release. The same operations are exposed as clusterioctl player-sync commands. Import bumps the generation past the stored one so instances holding an older copy download the new data on the next join. Import and delete are refused while an instance holds the lock, since its upload on leave would undo the change. A new player_sync.inventory.modify permission gates the write operations. Closes #1003 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
5e2ecdd143
commit
a81d98e981
12 files changed
+953
-22
No files matched your search
@@ -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 <player>
|
||||
npx clusterioctl player-sync export <player> [file]
|
||||
npx clusterioctl player-sync import <player> <file>
|
||||
npx clusterioctl player-sync delete <player>
|
||||
npx clusterioctl player-sync release <player>
|
||||
|
||||
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.
|
||||
|
||||
@@ -55,14 +55,22 @@ export default async function(context: ControllerPluginContext) {
|
||||
const playerDatastore: Map<string, IpcPlayerData> = 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<string, msg.PlayerEntry>();
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
@@ -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 <player>", "Show a summary of the stored player data", (yargs) => {
|
||||
yargs.positional("player", { describe: "Name of the player", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { player: string }, ctl: Ctl) {
|
||||
let response = await ctl.send(new msg.GetPlayerDataRequest(args.player));
|
||||
print(`acquired by: ${await instanceName(ctl, response.instanceId)}`);
|
||||
let playerData = response.playerData;
|
||||
if (!playerData) {
|
||||
print(`No player data stored for ${args.player}`);
|
||||
return;
|
||||
}
|
||||
print(`generation: ${playerData.generation}`);
|
||||
print(`size: ${lib.formatBytes(JSON.stringify(playerData).length)}`);
|
||||
print(`controller: ${playerData.controller}`);
|
||||
print(`force: ${playerData.force}`);
|
||||
for (let inventory of summarizePlayerInventories(playerData)) {
|
||||
print(`${inventory.name}:`);
|
||||
for (let item of inventory.items) {
|
||||
let quality = item.quality ? ` (${item.quality})` : "";
|
||||
let size = item.exportSize ? ` ${lib.formatBytes(item.exportSize)}` : "";
|
||||
print(` ${item.count} ${item.name}${quality}${size}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
playerSyncCommands.add(new lib.Command({
|
||||
definition: ["export <player> [file]", "Export stored player data as JSON", (yargs) => {
|
||||
yargs.positional("player", { describe: "Name of the player", type: "string" });
|
||||
yargs.positional("file", { describe: "File to write to, prints to stdout if omitted", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { player: string, file?: string }, ctl: Ctl) {
|
||||
let response = await ctl.send(new msg.GetPlayerDataRequest(args.player));
|
||||
if (!response.playerData) {
|
||||
throw new lib.CommandError(`No player data stored for ${args.player}`);
|
||||
}
|
||||
let content = JSON.stringify(response.playerData, null, "\t");
|
||||
if (args.file) {
|
||||
await fs.writeFile(args.file, content);
|
||||
print(`Wrote player data for ${args.player} to ${args.file}`);
|
||||
} else {
|
||||
print(content);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
playerSyncCommands.add(new lib.Command({
|
||||
definition: ["import <player> <file>", "Replace stored player data with JSON from a file", (yargs) => {
|
||||
yargs.positional("player", { describe: "Name of the player", type: "string" });
|
||||
yargs.positional("file", { describe: "File to read player data from", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { player: string, file: string }, ctl: Ctl) {
|
||||
let playerData = JSON.parse(await fs.readFile(args.file, "utf8"));
|
||||
if (typeof playerData !== "object" || playerData === null || Array.isArray(playerData)) {
|
||||
throw new lib.CommandError("Player data must be a JSON object");
|
||||
}
|
||||
playerData.name = args.player;
|
||||
playerData.generation ??= 0;
|
||||
let response = await ctl.send(new msg.SetPlayerDataRequest(args.player, playerData));
|
||||
print(`Stored player data for ${args.player} as generation ${response.generation}`);
|
||||
},
|
||||
}));
|
||||
|
||||
playerSyncCommands.add(new lib.Command({
|
||||
definition: ["delete <player>", "Delete stored player data", (yargs) => {
|
||||
yargs.positional("player", { describe: "Name of the player", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { player: string }, ctl: Ctl) {
|
||||
await ctl.send(new msg.DeletePlayerDataRequest(args.player));
|
||||
},
|
||||
}));
|
||||
|
||||
playerSyncCommands.add(new lib.Command({
|
||||
definition: ["release <player>", "Release the lock an instance holds on a player", (yargs) => {
|
||||
yargs.positional("player", { describe: "Name of the player", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { player: string }, ctl: Ctl) {
|
||||
await ctl.send(new msg.ForceReleaseRequest(args.player));
|
||||
},
|
||||
}));
|
||||
|
||||
export default async function(context: CtlPluginContext) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
rootCommand.add(playerSyncCommands);
|
||||
});
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<typeof PlayerEntry.jsonSchema>): 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<typeof GetPlayerDataResponse.jsonSchema>): 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<typeof GetPlayerDataRequest.jsonSchema>) {
|
||||
return new this(json.playerName);
|
||||
}
|
||||
}
|
||||
|
||||
export class SetPlayerDataResponse {
|
||||
constructor(
|
||||
public generation: number,
|
||||
) {
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"generation": Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof SetPlayerDataResponse.jsonSchema>): 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<typeof SetPlayerDataRequest.jsonSchema>) {
|
||||
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<typeof DeletePlayerDataRequest.jsonSchema>) {
|
||||
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<typeof ForceReleaseRequest.jsonSchema>) {
|
||||
return new this(json.playerName);
|
||||
}
|
||||
}
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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<string, ItemSummary>();
|
||||
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[] }) })
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)), []);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" ],
|
||||
}
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -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 <Table
|
||||
size="small"
|
||||
title={() => props.name}
|
||||
columns={[
|
||||
{
|
||||
title: "Item",
|
||||
key: "item",
|
||||
render: (_, item) => (item.exportSize !== undefined
|
||||
? <Typography.Text type="secondary">{item.name}</Typography.Text>
|
||||
: <>
|
||||
<FactorioIcon modPackId={modPack?.id} prototype={itemMetadata?.get(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<GetPlayerDataResponse>();
|
||||
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 <Drawer
|
||||
title={playerName}
|
||||
open={playerName !== undefined}
|
||||
onClose={props.onClose}
|
||||
size="large"
|
||||
>
|
||||
{response && <Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
{acquiredBy !== undefined && <Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`Acquired by ${acquiredBy}`}
|
||||
description={
|
||||
"The instance uploads its copy of the player data when the player leaves. Have the player " +
|
||||
"leave before modifying it, or release the lock if the instance is stuck holding it."
|
||||
}
|
||||
action={canModify && <Popconfirm
|
||||
title="Release the lock on this player?"
|
||||
onConfirm={() => control.send(new ForceReleaseRequest(playerName!)).then(changed).catch(
|
||||
notifyErrorHandler("Error releasing lock")
|
||||
)}
|
||||
>
|
||||
<Button>Release</Button>
|
||||
</Popconfirm>}
|
||||
/>}
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="Generation">{playerData?.generation ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Size">
|
||||
{playerData ? lib.formatBytes(JSON.stringify(playerData).length) : "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Controller">{playerData?.controller ?? "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Force">{playerData?.force ?? "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space wrap>
|
||||
<Button disabled={!playerData} onClick={download}>Export JSON</Button>
|
||||
{canModify && <Upload
|
||||
accept=".json,application/json"
|
||||
showUploadList={false}
|
||||
beforeUpload={file => {
|
||||
upload(file).catch(notifyErrorHandler("Error storing player data"));
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button>Import JSON</Button>
|
||||
</Upload>}
|
||||
{canModify && <Popconfirm
|
||||
title="Delete the stored player data?"
|
||||
description={
|
||||
"The next join creates a new synced inventory from whatever the player has on that instance."
|
||||
}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => control.send(new DeletePlayerDataRequest(playerName!)).then(changed).catch(
|
||||
notifyErrorHandler("Error deleting player data")
|
||||
)}
|
||||
>
|
||||
<Button danger disabled={!playerData}>Delete</Button>
|
||||
</Popconfirm>}
|
||||
</Space>
|
||||
{!playerData && <Typography.Text type="secondary">No player data stored</Typography.Text>}
|
||||
{playerData && summarizePlayerInventories(playerData).map(
|
||||
inventory => <InventoryTable key={inventory.name} {...inventory} />
|
||||
)}
|
||||
</Space>}
|
||||
</Drawer>;
|
||||
}
|
||||
|
||||
function InventoryPage() {
|
||||
let control = useContext(ControlContext);
|
||||
const control = useContext(ControlContext);
|
||||
const instanceName = useInstanceName();
|
||||
const [statsData, setStatsData] = useState<DatabaseStatsResponse>();
|
||||
const [players, setPlayers] = useState<PlayerEntry[]>([]);
|
||||
const [selected, setSelected] = useState<string>();
|
||||
|
||||
let [statsData, updateStatsData] = useState<DatabaseStatsResponse>();
|
||||
const tableState = useTableQueryState<PlayerEntry>({
|
||||
namespace: "inventory", defaultSortKey: "name", pagination: { defaultPageSize: 50 },
|
||||
});
|
||||
const nameSearch = useColumnSearch<PlayerEntry>(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 <PageLayout nav={[{ name: "Player sync" }]}>
|
||||
<PageHeader title="Player sync" />
|
||||
{statsData && <>
|
||||
<p>Database size: {Math.round(statsData.databaseSize / 1000)}kB</p>
|
||||
<p>Database size: {lib.formatBytes(statsData.databaseSize)}</p>
|
||||
<p>Database entries: {statsData.databaseEntries}</p>
|
||||
<p>Largest entry is {statsData.largestEntry.name} with {(statsData.largestEntry.size / 1000)}kB</p>
|
||||
<p>Largest entry is {statsData.largestEntry.name} with {lib.formatBytes(statsData.largestEntry.size)}</p>
|
||||
</>}
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
dataIndex: "name",
|
||||
...nameSearch,
|
||||
filteredValue: tableState.filteredValue("name"),
|
||||
sorter: (a, b) => 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),
|
||||
})}
|
||||
/>
|
||||
<PlayerDrawer playerName={selected} onClose={() => setSelected(undefined)} onChange={reload} />
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user