Files
factorio-clusterio/plugins/player_sync/test/plugin.js
T
2026-10-17 20:26:50 +00:00

347 lines
13 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}`);
}
});
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" } }] },
armor: { i: {} },
},
},
personal_logistic_slots: {
enabled: true,
trash_not_requested: false,
sections: [{
group: "",
active: true,
multiplier: 1,
filters: [{ value: { name: "iron-plate", type: "item" }, min: 3 }],
}],
},
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, {
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"],
["color", "red"],
["force", 1],
["ticks_to_respawn", -1],
["inventories", { main: { i: "iron-plate" } }],
["inventories", { main: [] }],
["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)}`);
}
});
});
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({}), []);
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")]);
});
});
});