Files
factorio-clusterio/plugins/player_sync/test/controller.js
T
2026-10-27 22:05:14 +00:00

483 lines
21 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, epoch: 0, 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")));
}
async function upload(instanceId, data) {
await handler(msg.UploadRequest)(new msg.UploadRequest(instanceId, data.name, data));
}
async function acquireResponse(instanceId, playerName) {
const response = await handler(msg.AcquireRequest)(new msg.AcquireRequest(instanceId, playerName));
// Random per controller run, the run token tests cover it
response.token = undefined;
return response;
}
async function loadWithInstances() {
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,
));
}
}
beforeEach(async function() {
await loadWithInstances();
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, "player"),
]);
});
});
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.ConflictError(
"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 and epoch", 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, { epoch: 1 }));
});
it("should invalidate unconfirmed uploads from instances", async function() {
// Instance 1 left with generation 4 but the upload never reached the controller
await release("stored");
await set("stored", playerData("stored", 0));
await upload(1, playerData("stored", 4));
assert.deepEqual((await saved()).get("stored"), playerData("stored", 4, { epoch: 1 }));
assert.deepEqual(
await acquireResponse(1, "stored"), new msg.AcquireResponse("acquired", 4, true, undefined, 1),
);
});
it("should ignore the epoch in imported data", async function() {
await release("stored");
await set("stored", playerData("stored", 0, { epoch: 7 }));
assert.equal((await saved()).get("stored").epoch, 1);
});
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);
});
// The link validates requests before the handler runs
const fromJSON = lib.Link.requestFromJSON(msg.SetPlayerDataRequest, "SetPlayerDataRequest");
async function setJSON(name, data) {
const request = fromJSON({ playerName: name, playerData: data });
return await handler(msg.SetPlayerDataRequest)(request);
}
it("should keep the stored data when an import fails validation", async function() {
await release("stored");
await setJSON("stored", playerData("stored", 0));
const before = (await saved()).get("stored");
assert.equal(before.epoch, 1);
const badFilter = { value: { name: "iron-plate" }, min: "many" };
for (const extra of [
{ crafting_queue: {} },
{ deleted: false },
{ deleted: true },
{ controller: "cutscene" },
{ inventories: { main: { i: [{ e: null }] } } },
{ personal_logistic_slots: {
enabled: true,
trash_not_requested: false,
sections: [{ group: "", active: true, multiplier: 1, filters: [badFilter] }],
} },
]) {
await assert.rejects(
setJSON("stored", playerData("stored", 0, extra)),
{ message: "Request SetPlayerDataRequest failed validation" },
);
}
assert.deepEqual((await saved()).get("stored"), before);
});
it("should start over from imported generations past the limit", async function() {
await release("stored");
assert.equal((await setJSON("stored", playerData("stored", 2 ** 53))).generation, 4);
await upload(1, playerData("stored", 5, { epoch: 1 }));
assert.deepEqual((await saved()).get("stored"), playerData("stored", 5, { epoch: 1 }));
});
for (const deleted of [false, true]) {
it(`should recover ${deleted ? "deleted" : "stored"} data past the generation limit`, async function() {
// Could be stored by uploads or by imports from before the limit
for (const generation of [msg.generationLimit, 2 ** 53]) {
const stuck = { ...playerData("stored", generation), ...deleted ? { deleted } : {} };
const entries = [["stored", stuck]];
await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify(entries));
await loadWithInstances();
// Importing the exported data as is
assert.equal((await setJSON("stored", playerData("stored", generation))).generation, 1);
await upload(1, playerData("stored", 2, { epoch: 1 }));
assert.deepEqual((await saved()).get("stored"), playerData("stored", 2, { epoch: 1 }));
}
});
}
it("should keep counting up below the generation limit", async function() {
const stored = playerData("stored", msg.generationLimit - 1);
await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify([["stored", stored]]));
await loadWithInstances();
const response = await setJSON("stored", playerData("stored", 0));
assert.equal(response.generation, msg.generationLimit);
});
it("should only load entries marked deleted: true as tombstones", async function() {
// Imports from before the marker was reserved could store deleted: false
const data = playerData("stored", 4, { epoch: 1 });
const entries = [["stored", { ...data, deleted: false }]];
await fs.writeFile(path.join(databaseDir, "player_sync.json"), JSON.stringify(entries));
controller = await load();
const response = await handler(msg.GetPlayerDataRequest)(new msg.GetPlayerDataRequest("stored"));
assert.deepEqual(response.playerData, data);
});
});
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");
const response = await handler(msg.GetPlayerDataRequest)(new msg.GetPlayerDataRequest("stored"));
assert.equal(response.playerData, null);
});
it("should keep the generation and bump the epoch", async function() {
await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored"));
await del("stored");
assert.deepEqual(
await acquireResponse(2, "stored"), new msg.AcquireResponse("acquired", 3, false, undefined, 1),
);
// In the same file as the player data so the delete and its tombstone are written together
assert.deepEqual((await saved()).get("stored"), { generation: 3, epoch: 1, deleted: true });
controller = await load();
assert.deepEqual(
await acquireResponse(2, "stored"), new msg.AcquireResponse("acquired", 3, false, undefined, 1),
);
});
it("should accept the next upload from the new epoch only", async function() {
await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored"));
await del("stored");
await upload(1, playerData("stored", 11));
assert((await saved()).get("stored").deleted);
await upload(2, playerData("stored", 4, { epoch: 1 }));
assert.deepEqual((await saved()).get("stored"), playerData("stored", 4, { epoch: 1 }));
// The other instance downloads the baseline instead of uploading its stale copy
await upload(1, playerData("stored", 11));
assert.equal((await saved()).get("stored").generation, 4);
});
it("should reject unknown players", async function() {
await assert.rejects(del("unknown"), new lib.NotFoundError("No player data stored for unknown"));
});
it("should reject when the player is acquired", async function() {
await assert.rejects(del("stored"), acquiredError);
assert.deepEqual((await saved()).get("stored"), playerData("stored", 3));
});
});
describe("uploads without an epoch", function() {
// What instances running a version from before epochs send
function legacyData(generation) {
const data = playerData("stored", generation);
delete data.epoch;
return data;
}
beforeEach(async function() {
await handler(msg.ForceReleaseRequest)(new msg.ForceReleaseRequest("stored"));
});
it("should store them by generation", async function() {
await upload(1, legacyData(3));
await upload(1, legacyData(4));
assert.deepEqual((await saved()).get("stored"), legacyData(4));
});
it("should keep accepting them after an import", async function() {
const request = new msg.SetPlayerDataRequest("stored", playerData("stored", 0));
await handler(msg.SetPlayerDataRequest)(request);
await upload(1, legacyData(4));
assert.equal((await saved()).get("stored").epoch, 1);
await upload(1, legacyData(5));
assert.deepEqual((await saved()).get("stored"), playerData("stored", 5, { epoch: 1 }));
});
it("should accept any generation after a delete", async function() {
await handler(msg.DeletePlayerDataRequest)(new msg.DeletePlayerDataRequest("stored"));
await upload(1, legacyData(1));
assert.deepEqual((await saved()).get("stored"), playerData("stored", 1, { epoch: 1 }));
});
});
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.ConflictError("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);
});
});
});
describe("UploadRequest with destinationInstanceId", function() {
// Instances 1-3 run player_sync, 4 is stopped, 5 runs with player_sync off; 1 is the source in every case
async function setup() {
const controller = await load();
for (const [id, status, loadPlugin] of [
[1, "running", true], [2, "running", true], [3, "running", true],
[4, "stopped", true], [5, "running", false],
]) {
const fields = { "instance.name": `instance ${id}`, "player_sync.load_plugin": loadPlugin };
controller.instances.records.set(id, { id, status, config: { get: field => fields[field] } });
}
const sent = [];
controller.sendTo = (address, message) => { sent.push({ address, message }); };
return { controller, sent, upload: mock.getHandler(controller, msg.UploadRequest) };
}
const data = generation => ({ name: "test", generation });
async function stored(controller) {
const download = mock.getHandler(controller, msg.DownloadRequest);
return (await download(new msg.DownloadRequest(2, "test"))).playerData;
}
it("should send the stored data to a running destination with player_sync on", async function() {
const { sent, upload } = await setup();
await upload(new msg.UploadRequest(1, "test", data(1), 2));
assert.equal(sent.length, 1);
assert.deepEqual(sent[0].address, { instanceId: 2 });
assert.ok(sent[0].message instanceof msg.PrefetchEvent);
assert.deepEqual(sent[0].message.playerData, data(1));
});
it("should tag the prefetch and acquire reply with one token per controller run", async function() {
const { controller, sent, upload } = await setup();
await upload(new msg.UploadRequest(1, "test", data(1), 2));
const acquire = await mock.getHandler(controller, msg.AcquireRequest)(new msg.AcquireRequest(2, "test"));
assert.equal(acquire.status, "acquired");
assert.ok(sent[0].message.token);
assert.equal(acquire.token, sent[0].message.token);
const again = await setup();
await again.upload(new msg.UploadRequest(1, "test", data(1), 2));
assert.notEqual(again.sent[0].message.token, sent[0].message.token);
});
it("should not need a token on a busy reply", async function() {
const { controller } = await setup();
const acquire = mock.getHandler(controller, msg.AcquireRequest);
await acquire(new msg.AcquireRequest(3, "test"));
assert.equal((await acquire(new msg.AcquireRequest(2, "test"))).token, undefined);
});
it("should send the stored copy, with the parts the upload's components left out", async function() {
const { sent, upload } = await setup();
await upload(new msg.UploadRequest(1, "test", { ...data(1), force: "player", tag: "first" }));
const components = Object.fromEntries(Object.keys(allComponents).map(c => [c, c !== "force"]));
await upload(new msg.UploadRequest(1, "test", { ...data(2), tag: "second", components }, 2));
assert.equal(sent.length, 1);
assert.deepEqual(sent[0].message.playerData, { ...data(2), force: "player", tag: "second" });
});
it("should send nothing without a destination", async function() {
const { sent, upload } = await setup();
await upload(new msg.UploadRequest(1, "test", data(1)));
assert.equal(sent.length, 0);
});
it("should send nothing when an older generation isn't stored", async function() {
const { sent, upload } = await setup();
await upload(new msg.UploadRequest(1, "test", data(3)));
await upload(new msg.UploadRequest(1, "test", data(2), 2));
assert.equal(sent.length, 0);
});
it("should send nothing when another instance's lock rejects the upload", async function() {
const { controller, sent, upload } = await setup();
await mock.getHandler(controller, msg.AcquireRequest)(new msg.AcquireRequest(3, "test"));
await upload(new msg.UploadRequest(1, "test", data(1), 2));
assert.equal(sent.length, 0);
});
it("should store but not send to a stopped, source, plugin-off or unknown instance", async function() {
const { controller, sent, upload } = await setup();
let generation = 0;
for (const id of [4, 1, 5, 99]) {
generation += 1;
await upload(new msg.UploadRequest(1, "test", data(generation), id));
}
assert.equal(sent.length, 0);
assert.equal((await stored(controller)).generation, 4);
});
it("should store the upload and not fail it when sending throws", async function() {
const { controller, upload } = await setup();
controller.sendTo = () => { throw new Error("Host containing instance is not connected"); };
await upload(new msg.UploadRequest(1, "test", data(1), 2));
assert.deepEqual(await stored(controller), data(1));
});
});
});