mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Generations alone could not order a repair against copies held by instances. Deleting dropped the watermark, so a stale instance could skip downloading the new baseline. An import could land on the same generation as an upload that never reached the controller, and the instance would then keep its own copy. Imports and deletes now bump a per-player epoch, which the controller returns on acquire and checks on upload. Instances throw away a copy from an earlier epoch, and deleted players keep their generation and epoch in player_sync_deleted.json. A restarted download also takes the new generation instead of the outdated one. Generations in player data must now be non-negative integers, so an invalid import is refused before it reaches the datastore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
12 KiB
JavaScript
286 lines
12 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(file = "player_sync.json") {
|
|
await controller.hooks.save.invoke();
|
|
return new Map(JSON.parse(await fs.readFile(path.join(databaseDir, file), "utf8")));
|
|
}
|
|
async function upload(instanceId, data) {
|
|
await handler(msg.UploadRequest)(new msg.UploadRequest(instanceId, data.name, data));
|
|
}
|
|
async function acquireResponse(instanceId, playerName) {
|
|
return await handler(msg.AcquireRequest)(new msg.AcquireRequest(instanceId, playerName));
|
|
}
|
|
|
|
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 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);
|
|
});
|
|
});
|
|
|
|
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 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),
|
|
);
|
|
assert.deepEqual((await saved("player_sync_deleted.json")).get("stored"), { generation: 3, epoch: 1 });
|
|
|
|
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()).has("stored"));
|
|
await upload(2, playerData("stored", 4, { epoch: 1 }));
|
|
assert.deepEqual((await saved()).get("stored"), playerData("stored", 4, { epoch: 1 }));
|
|
assert(!(await saved("player_sync_deleted.json")).has("stored"));
|
|
|
|
// 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.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);
|
|
});
|
|
});
|
|
});
|
|
});
|