Files
factorio-clusterio/plugins/player_sync/test/controller.js
T
bbassieandClaude Fable 5.1 a81d98e981 Add inspect and modify tools for player sync
Admins had no way to look at or fix the player data the controller
stores when a sync went wrong. The web page now lists every stored
entry with its generation, size and the instance holding the lock, and
opens a drawer per player with an inventory summary, JSON export and
import, delete and lock release. The same operations are exposed as
clusterioctl player-sync commands.

Import bumps the generation past the stored one so instances holding an
older copy download the new data on the next join. Import and delete
are refused while an instance holds the lock, since its upload on leave
would undo the change. A new player_sync.inventory.modify permission
gates the write operations.

Closes #1003

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 18:52:29 +00:00

239 lines
9.5 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import * as lib from "@clusterio/lib";
import { InstanceRecord } from "@clusterio/controller";
import * as mock from "../../../test/mock.js";
import controllerEntrypoint from "../dist/node/controller.js";
import { components as allComponents } from "../dist/node/components.js";
import { plugin as info } from "../dist/node/index.js";
import * as msg from "../dist/node/messages.js";
describe("player_sync controller", function() {
const databaseDir = path.join("temp", "test", "player_sync");
before(function() {
try {
lib.registerPluginMessages([info]);
} catch (err) {
// Already registered by the full test suite
}
});
beforeEach(async function() {
await fs.rm(databaseDir, { recursive: true, force: true });
await fs.mkdir(databaseDir, { recursive: true });
});
async function load() {
return await mock.loadControllerPlugin(async (context) => {
context.controller.mockConfigEntries.set("controller.database_directory", databaseDir);
await controllerEntrypoint(context);
}, info);
}
it("should move inventories.json to player_sync.json", async function() { // Alpha 28
const playerData = { name: "test", generation: 1 };
await fs.writeFile(path.join(databaseDir, "inventories.json"), JSON.stringify([["test", playerData]]));
const controller = await load();
const stats = await mock.getHandler(controller, msg.DatabaseStatsRequest)();
assert.equal(stats.databaseEntries, 1);
assert.equal(stats.largestEntry.name, "test");
await assert.rejects(fs.access(path.join(databaseDir, "inventories.json")), { code: "ENOENT" });
await fs.access(path.join(databaseDir, "player_sync.json"));
});
it("should not replace an existing player_sync.json", async function() {
await fs.writeFile(path.join(databaseDir, "inventories.json"), JSON.stringify([["old", { name: "old" }]]));
await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify([["new", { name: "new" }]]));
const controller = await load();
const stats = await mock.getHandler(controller, msg.DatabaseStatsRequest)();
assert.equal(stats.databaseEntries, 1);
assert.equal(stats.largestEntry.name, "new");
});
describe("UploadRequest", function() {
let controller;
let disabled;
beforeEach(async function() {
disabled = [];
const stored = { generation: 1, name: "test", force: "player", tag: "stored" };
await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify([["test", stored]]));
controller = await load();
controller.instances = new Map([[1, {
config: {
get: name => {
if (name === "instance.name") { return "test instance"; }
return !disabled.includes(name.replace("player_sync.sync_", ""));
},
},
}]]);
});
async function upload(playerData) {
await mock.getHandler(controller, msg.UploadRequest)({ instanceId: 1, playerName: "test", playerData });
return (await mock.getHandler(controller, msg.DownloadRequest)({ instanceId: 1, playerName: "test" }))
.playerData;
}
it("should store the upload as is when it has no components", async function() {
const playerData = { generation: 2, name: "test", force: "enemy", tag: "uploaded" };
assert.deepEqual(await upload(playerData), playerData);
});
it("should merge by the payload's components and ignore the instance config", async function() {
disabled = ["appearance"];
const components = Object.fromEntries(Object.keys(allComponents).map(c => [c, c !== "force"]));
assert.deepEqual(
await upload({ generation: 2, name: "test", tag: "uploaded", components }),
{ generation: 2, name: "test", force: "player", tag: "uploaded" },
);
});
});
describe("player data tools", function() {
function playerData(name, generation, extra = {}) {
return { generation, name, controller: "character", force: "player", ...extra };
}
let controller;
function handler(Class) {
return mock.getHandler(controller, Class);
}
async function acquire(instanceId, playerName) {
const response = await handler(msg.AcquireRequest)(new msg.AcquireRequest(instanceId, playerName));
return response.status === "acquired";
}
async function saved() {
await controller.hooks.save.invoke();
return new Map(JSON.parse(await fs.readFile(path.join(databaseDir, "player_sync.json"), "utf8")));
}
beforeEach(async function() {
controller = await mock.loadControllerPlugin(async (context) => {
context.controller.mockConfigEntries.set("controller.database_directory", databaseDir);
// Negative so locks expire as soon as their instance stops
context.controller.mockConfigEntries.set("player_sync.player_lock_timeout", -1);
await controllerEntrypoint(context);
}, info);
for (const [id, name, status] of [[1, "Running", "running"], [2, "Stopped", "stopped"]]) {
controller.instances.records.set(id, new InstanceRecord(
new lib.InstanceConfig("controller", { "instance.id": id, "instance.name": name }), status,
));
}
await handler(msg.UploadRequest)(new msg.UploadRequest(1, "stored", playerData("stored", 3)));
assert(await acquire(1, "stored"));
assert(await acquire(1, "online"));
assert(await acquire(2, "expired"));
await controller.hooks.instanceStatusChanged.invoke(controller.instances.get(2), "running");
assert(await acquire(99, "gone"));
await saved();
});
describe("ListPlayersRequest", function() {
it("should list stored data and live acquisitions", async function() {
const entries = await handler(msg.ListPlayersRequest)(new msg.ListPlayersRequest());
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),
]);
});
});
describe("GetPlayerDataRequest", function() {
async function get(name) {
return await handler(msg.GetPlayerDataRequest)(new msg.GetPlayerDataRequest(name));
}
it("should return stored data and acquisition", async function() {
assert.deepEqual(await get("stored"), new msg.GetPlayerDataResponse(playerData("stored", 3), 1));
});
it("should return null for unknown players", async function() {
assert.deepEqual(await get("unknown"), new msg.GetPlayerDataResponse(null, undefined));
});
it("should ignore expired and dangling acquisitions", async function() {
for (const name of ["expired", "gone"]) {
assert.equal((await get(name)).instanceId, undefined);
}
});
});
const acquiredError = new lib.RequestError(
"stored is currently acquired by Running, have the player leave or release the lock first"
);
describe("SetPlayerDataRequest", function() {
async function set(name, data) {
return await handler(msg.SetPlayerDataRequest)(new msg.SetPlayerDataRequest(name, data));
}
async function release(name) {
await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest(name));
}
it("should store data with a bumped generation", async function() {
await release("stored");
assert.deepEqual(await set("stored", playerData("other", 1)), new msg.SetPlayerDataResponse(4));
assert.deepEqual((await saved()).get("stored"), playerData("stored", 4));
});
it("should keep a higher provided generation", async function() {
await release("stored");
assert.equal((await set("stored", playerData("stored", 10))).generation, 10);
});
it("should store data for new players", async function() {
assert.equal((await set("new", playerData("new", 0))).generation, 1);
assert.equal((await saved()).get("new").generation, 1);
});
it("should reject when the player is acquired", async function() {
await assert.rejects(set("stored", playerData("stored", 3)), acquiredError);
assert.equal((await saved()).get("stored").generation, 3);
});
});
describe("DeletePlayerDataRequest", function() {
async function del(name) {
await handler(msg.DeletePlayerDataRequest)(new msg.DeletePlayerDataRequest(name));
}
it("should delete stored data", async function() {
await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored"));
await del("stored");
assert(!(await saved()).has("stored"));
});
it("should reject unknown players", async function() {
await assert.rejects(del("unknown"), new lib.RequestError("No player data stored for unknown"));
});
it("should reject when the player is acquired", async function() {
await assert.rejects(del("stored"), acquiredError);
assert((await saved()).has("stored"));
});
});
describe("ForceReleaseRequest", function() {
async function release(name) {
await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest(name));
}
it("should remove the acquisition", async function() {
await release("online");
assert(await acquire(2, "online"));
});
it("should reject players that are not acquired", async function() {
await assert.rejects(
release("unknown"), new lib.RequestError("unknown is not acquired by any instance"),
);
});
});
describe("AcquireRequest", function() {
it("should refuse players held by another instance", async function() {
assert(!await acquire(2, "online"));
assert(await acquire(1, "online"));
});
it("should take over expired and dangling acquisitions", async function() {
assert(await acquire(1, "expired"));
assert(await acquire(1, "gone"));
const response = await handler(msg.GetPlayerDataRequest)(new msg.GetPlayerDataRequest("gone"));
assert.equal(response.instanceId, 1);
});
});
});
});