Files
factorio-clusterio/plugins/player_sync/test/plugin.js
bbassieandClaude Opus 5.5 ec7b5f2db8 Rework the player sync web page after UX review
The drawer actions move to its header as Export, Import and a trash
icon for Delete. Import and Delete are disabled while an instance
holds the player, since the stored data is out of date until it
uploads. The drawer also shows color and cheat mode.

Inventory tables get title case names, pagination at 10 rows, item
search, sorting and quality icons. The player list shows force with
a filter instead of generation, and the database stats use
Descriptions without the largest entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:24:02 +00:00

420 lines
16 KiB
JavaScript

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