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")]); }); }); });