mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 23:04:52 +09:00
The controller merged uploads using the uploading instance's current config. An upload serialized under different settings, for example one sent from the save right after an admin turned on sync_inventories and restarted the instance, was then merged as if it held a character and wiped the stored one. Uploads now carry the components they were serialized with, the controller merges by those and drops the key before storing. The scenario keeps the components a player's synced session started with and serializes their upload with them, so changing the options while a player is online takes effect the next time they join. A player whose session lacks a component that is now synced downloads again even if the generation matches. Downloads snapshot the components when they start, so a change during a download can't mix two sets. A failed acquire unsyncs the player again regardless of the inventories setting, as before this branch. The god and dead player inventories move from the inventories component to the controller one, matching what the scenario syncs, which removes the special case in mergePlayerData. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
7.7 KiB
JavaScript
183 lines
7.7 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import util from "node:util";
|
|
import zlib from "node:zlib";
|
|
import * as lib from "@clusterio/lib";
|
|
|
|
import * as mock from "../../../test/mock.js";
|
|
import loadInstancePlugin, { recipeNotificationDelta, applyRecipeNotificationDelta } from "../dist/node/instance.js";
|
|
import { DownloadResponse } from "../dist/node/messages.js";
|
|
import { components } from "../dist/node/components.js";
|
|
import { plugin as info } from "../dist/node/index.js";
|
|
|
|
const inflate = util.promisify(zlib.inflate);
|
|
const deflate = util.promisify(zlib.deflate);
|
|
|
|
// Matches helpers.encode_string in Factorio
|
|
async function encode(value) {
|
|
return (await deflate(Buffer.from(JSON.stringify(value)))).toString("base64");
|
|
}
|
|
async function decode(encoded) {
|
|
return JSON.parse((await inflate(Buffer.from(encoded, "base64"))).toString());
|
|
}
|
|
|
|
describe("player_sync", function() {
|
|
describe("recipeNotificationDelta()", function() {
|
|
it("should be empty when the lists match", function() {
|
|
assert.deepEqual(recipeNotificationDelta(["a", "b"], ["b", "a"]), {});
|
|
});
|
|
it("should add stored names missing from current", function() {
|
|
assert.deepEqual(recipeNotificationDelta(["a", "b", "c"], ["a"]), { add: ["b", "c"] });
|
|
});
|
|
it("should remove current names missing from stored", function() {
|
|
assert.deepEqual(recipeNotificationDelta(["a"], ["a", "b"]), { remove: ["b"] });
|
|
});
|
|
it("should handle both directions at once", function() {
|
|
assert.deepEqual(recipeNotificationDelta(["a", "b"], ["b", "c"]), { add: ["a"], remove: ["c"] });
|
|
});
|
|
it("should keep duplicates in stored out of the current list", function() {
|
|
assert.deepEqual(recipeNotificationDelta(["a", "a"], ["a"]), {});
|
|
});
|
|
});
|
|
|
|
describe("applyRecipeNotificationDelta()", function() {
|
|
const warnings = [];
|
|
const logger = { warn: msg => warnings.push(msg) };
|
|
const apply = (playerData, current) => applyRecipeNotificationDelta(playerData, current, logger);
|
|
|
|
it("should replace the stored list with an encoded delta", async function() {
|
|
const playerData = { name: "test", recipe_notifications: await encode(["a", "b"]) };
|
|
await apply(playerData, await encode(["b", "c"]));
|
|
assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a"], remove: ["c"] });
|
|
});
|
|
it("should send an empty delta when nothing changed", async function() {
|
|
const playerData = { name: "test", recipe_notifications: await encode(["a"]) };
|
|
await apply(playerData, await encode(["a"]));
|
|
assert.deepEqual(await decode(playerData.recipe_notifications), {});
|
|
});
|
|
it("should add everything without a current list", async function() {
|
|
const playerData = { name: "test", recipe_notifications: await encode(["a"]) };
|
|
await apply(playerData, undefined);
|
|
assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a"] });
|
|
});
|
|
it("should do nothing without stored notifications", async function() {
|
|
const playerData = { name: "test" };
|
|
await apply(playerData, await encode(["a"]));
|
|
assert.deepEqual(playerData, { name: "test" });
|
|
});
|
|
it("should drop the field on invalid input", async function() {
|
|
const playerData = { name: "test", recipe_notifications: "not base64 zlib" };
|
|
await apply(playerData, await encode(["a"]));
|
|
assert.equal(playerData.recipe_notifications, undefined);
|
|
assert.equal(warnings.length, 1);
|
|
});
|
|
});
|
|
|
|
describe("ipc-player_sync_download", function() {
|
|
let instance;
|
|
before(async function() {
|
|
try {
|
|
lib.registerPluginMessages([info]);
|
|
} catch (err) {
|
|
// Already registered by the full test suite
|
|
}
|
|
({ instance } = await mock.loadInstancePlugin(loadInstancePlugin, info));
|
|
instance.mockConfigEntries.set("player_sync.rcon_chunk_size", 100000);
|
|
});
|
|
beforeEach(function() {
|
|
instance.server.reset();
|
|
for (const component of Object.keys(components)) {
|
|
instance.mockConfigEntries.set(`player_sync.sync_${component}`, true);
|
|
}
|
|
});
|
|
|
|
function respondWith(playerData) {
|
|
instance.connector.once("send", message => {
|
|
instance.connector.emit("message", new lib.MessageResponse(
|
|
1, message.dst, message.src, new DownloadResponse(playerData)
|
|
));
|
|
});
|
|
}
|
|
async function download(request) {
|
|
instance.server.emit("ipc-player_sync_download", request);
|
|
// zlib runs on the threadpool, so poll on timers rather than setImmediate
|
|
for (let i = 0; i < 1000 && !instance.server.rconCommands.length; i++) {
|
|
await lib.wait(1);
|
|
}
|
|
}
|
|
function downloadCommand(playerData) {
|
|
const json = lib.escapeString(JSON.stringify(playerData));
|
|
return `/sc player_sync.download_inventory('test','${json}',1,1)`;
|
|
}
|
|
|
|
it("should send an empty download when the controller has no data", async function() {
|
|
respondWith(undefined);
|
|
await download({ player_name: "test" });
|
|
assert.deepEqual(
|
|
instance.server.rconCommands,
|
|
["/sc player_sync.download_inventory('test',nil,0,0)"]
|
|
);
|
|
});
|
|
it("should send everything as a delta when no snapshot was given", async function() {
|
|
respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a"]) });
|
|
await download({ player_name: "test" });
|
|
const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) };
|
|
assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]);
|
|
});
|
|
it("should send a delta when a snapshot was given", async function() {
|
|
respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a", "b"]) });
|
|
await download({ player_name: "test", recipe_notifications: await encode(["b"]) });
|
|
const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) };
|
|
assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]);
|
|
});
|
|
it("should leave out components the instance does not sync", async function() {
|
|
instance.mockConfigEntries.set("player_sync.sync_force", false);
|
|
instance.mockConfigEntries.set("player_sync.sync_recipe_notifications", false);
|
|
respondWith({ generation: 1, name: "test", force: "enemy", tag: "tag", recipe_notifications: "x" });
|
|
await download({ player_name: "test" });
|
|
const expected = { generation: 1, name: "test", tag: "tag" };
|
|
assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected)]);
|
|
});
|
|
});
|
|
|
|
describe("set_components", function() {
|
|
let instance;
|
|
before(async function() {
|
|
({ instance } = await mock.loadInstancePlugin(loadInstancePlugin, info));
|
|
});
|
|
beforeEach(function() {
|
|
instance.server.reset();
|
|
for (const component of Object.keys(components)) {
|
|
instance.mockConfigEntries.set(`player_sync.sync_${component}`, true);
|
|
}
|
|
});
|
|
|
|
it("should send the synced components on start", async function() {
|
|
instance.mockConfigEntries.set("player_sync.sync_quick_bar", false);
|
|
await instance.hooks.start.invoke();
|
|
const enabled = Object.fromEntries(
|
|
Object.keys(components).filter(c => c !== "quick_bar").map(c => [c, true])
|
|
);
|
|
const json = lib.escapeString(JSON.stringify(enabled));
|
|
assert.deepEqual(
|
|
instance.server.rconCommands,
|
|
[`/sc player_sync.set_components("${json}")`]
|
|
);
|
|
});
|
|
it("should send the synced components when one is changed", async function() {
|
|
await instance.hooks.instanceConfigFieldChanged.invoke("player_sync.rcon_chunk_size", 1, 2);
|
|
assert.equal(instance.server.rconCommands.length, 0);
|
|
await instance.hooks.instanceConfigFieldChanged.invoke("player_sync.sync_force", false, true);
|
|
assert.equal(instance.server.rconCommands.length, 1);
|
|
});
|
|
it("should not send the components when the instance is not running", async function() {
|
|
instance.status = "stopped";
|
|
try {
|
|
await instance.hooks.instanceConfigFieldChanged.invoke("player_sync.sync_force", false, true);
|
|
} finally {
|
|
instance.status = "running";
|
|
}
|
|
assert.equal(instance.server.rconCommands.length, 0);
|
|
});
|
|
});
|
|
});
|