diff --git a/plugins/player_sync/controller.ts b/plugins/player_sync/controller.ts index 93ba3aa5..16589fdd 100644 --- a/plugins/player_sync/controller.ts +++ b/plugins/player_sync/controller.ts @@ -266,7 +266,8 @@ export default async function(context: ControllerPluginContext) { controller.handle(msg.ListPlayersRequest, async () => { let entries = new Map(); for (let [name, playerData] of playerDatastore) { - entries.set(name, new msg.PlayerEntry(name, playerData.generation, JSON.stringify(playerData).length)); + 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); diff --git a/plugins/player_sync/messages.ts b/plugins/player_sync/messages.ts index 7663a794..146d2727 100644 --- a/plugins/player_sync/messages.ts +++ b/plugins/player_sync/messages.ts @@ -359,6 +359,7 @@ export class PlayerEntry { public generation?: number, public size?: number, public instanceId?: number, + public force?: string, ) { } @@ -367,10 +368,11 @@ export class PlayerEntry { "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); + return new this(json.name, json.generation, json.size, json.instanceId, json.force); } } diff --git a/plugins/player_sync/package.json b/plugins/player_sync/package.json index ff06cf62..c95e068f 100644 --- a/plugins/player_sync/package.json +++ b/plugins/player_sync/package.json @@ -51,6 +51,7 @@ "@sinclair/typebox": "catalog:" }, "devDependencies": { + "@ant-design/icons": "^5.6.1", "@clusterio/controller": "workspace:^", "@clusterio/ctl": "workspace:^", "@clusterio/host": "workspace:^", diff --git a/plugins/player_sync/test/controller.js b/plugins/player_sync/test/controller.js index a7177d78..2a5328df 100644 --- a/plugins/player_sync/test/controller.js +++ b/plugins/player_sync/test/controller.js @@ -147,7 +147,7 @@ describe("player_sync controller", function() { 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), + new msg.PlayerEntry("stored", 3, JSON.stringify(playerData("stored", 3)).length, 1, "player"), ]); }); }); diff --git a/plugins/player_sync/test/plugin.js b/plugins/player_sync/test/plugin.js index ed521923..77516d93 100644 --- a/plugins/player_sync/test/plugin.js +++ b/plugins/player_sync/test/plugin.js @@ -26,6 +26,7 @@ describe("player_sync plugin", function() { [undefined, 0, 4], // generation [undefined, 0, 1234], // size [undefined, 1], // instanceId + [undefined, "player"], // force )); }); it("GetPlayerDataResponse should be round trip json serialisable", function() { diff --git a/plugins/player_sync/web/index.tsx b/plugins/player_sync/web/index.tsx index 38f101fc..dcfd2c9d 100644 --- a/plugins/player_sync/web/index.tsx +++ b/plugins/player_sync/web/index.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; -import { Alert, Button, Descriptions, Drawer, Popconfirm, Space, Table, Typography, Upload } from "antd"; +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 * as lib from "@clusterio/lib"; import { @@ -28,89 +30,134 @@ function useInstanceName() { }; } +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(itemName: string) { - let meta = itemMetadata?.get(itemName); + 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]) ?? itemName; + return locale.get(meta.localised_name[0]) ?? name; } - return locale.get(`item-name.${itemName}`) ?? locale.get(`entity-name.${itemName}`) ?? itemName; + 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={() => props.name} + 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.name)} - + {getLocaleName("item", item.name)} + ), }, { title: "Quality", key: "quality", - render: (_, item) => item.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={false} + pagination={{ pageSize: 10, hideOnSinglePage: true, showSizeChanger: false }} />; } -function PlayerDrawer(props: { playerName?: string, onClose: () => void, onChange: () => void }) { - return - {/* Keyed so a request that finishes after switching players can only update the old player's view */} - {props.playerName !== undefined - && } - ; -} - -function PlayerDetails(props: { playerName: string, onChange: () => void }) { +function usePlayerData(playerName?: string) { const control = useContext(ControlContext); - const account = useAccount(); - const instanceName = useInstanceName(); - const [response, setResponse] = useState(); - const { playerName } = props; + 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; - control.send(new GetPlayerDataRequest(playerName)).then(result => { + if (playerName === undefined) { + return; + } + control.send(new GetPlayerDataRequest(playerName)).then(response => { if (id === requestId.current) { - setResponse(result); + setLoaded({ playerName, response }); } }).catch( notifyErrorHandler("Error loading player data") @@ -118,11 +165,37 @@ function PlayerDetails(props: { playerName: string, onChange: () => void }) { }, [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 { @@ -135,11 +208,11 @@ function PlayerDetails(props: { playerName: string, onChange: () => void }) { playerData.generation ??= 0; let result = await control.send(new SetPlayerDataRequest(playerName, playerData)); notify(`Stored player data for ${playerName} as generation ${result.generation}`); - changed(); + props.onChange(); } function download() { - let json = JSON.stringify(response!.playerData, null, "\t"); + 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; @@ -149,67 +222,89 @@ function PlayerDetails(props: { playerName: string, onChange: () => void }) { } const canModify = account.hasPermission("player_sync.inventory.modify"); - const playerData = response?.playerData; - const acquiredBy = response?.instanceId !== undefined ? instanceName(response.instanceId) : undefined; - return <> - {response && - {acquiredBy !== undefined && control.send(new ForceReleaseRequest(playerName)).then(changed).catch( - notifyErrorHandler("Error releasing lock") - )} - > - - } - />} - - {playerData?.generation ?? "-"} - - {playerData ? lib.formatBytes(JSON.stringify(playerData).length) : "-"} - - {playerData?.controller ?? "-"} - {playerData?.force ?? "-"} - - - - {canModify && { - upload(file).catch(notifyErrorHandler("Error storing player data")); - return false; - }} - > - - } - {canModify && control.send(new DeletePlayerDataRequest(playerName)).then(changed).catch( - notifyErrorHandler("Error deleting player data") - )} - > - - } - - {!playerData && No player data stored} - {playerData && summarizePlayerInventories(playerData).map( - inventory => + 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() { @@ -223,6 +318,7 @@ function InventoryPage() { 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])))]; const reload = useCallback(() => { control.send(new DatabaseStatsRequest()).then(setStatsData).catch( @@ -236,11 +332,10 @@ function InventoryPage() { return - {statsData && <> -

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

-

Database entries: {statsData.databaseEntries}

-

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

- } + {statsData && + {lib.formatBytes(statsData.databaseSize)} + {statsData.databaseEntries} + }
(a.generation ?? -1) - (b.generation ?? -1), - sortOrder: tableState.sortOrder("generation"), - render: (_, player) => player.generation ?? "-", + 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", 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; +}