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); }); }); }); });