Files
factorio-clusterio/plugins/player_sync/test/plugin.js
T
bbassieandClaude Opus 5.5 39df22be65 Close the remaining ways an accepted import fails to restore
Imports no longer accept the cutscene controller, which needs waypoints
to restore. Logistic filters and the basic item stack fields now have the
types and limits Factorio enforces, and the inventory summary skips
malformed items and counts repeated exported items once instead of
expanding them.

Lua's JSON loses precision past 2^50, so a generation there could no
longer be counted up and uploads were discarded. Imports now start over
from generation 1 when the stored or imported generation is at that limit.
Instances holding a copy from another epoch take the stored generation
instead of keeping their own higher one when there is no data to download.

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

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