mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
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>
239 lines
9.5 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|