mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
The scenario no longer sends its components with the download request. The instance strips the download by its own config and sends the components it used with the first chunk. If they differ from the ones the scenario started the download with, because the config changed in between, the scenario restarts the download. A pending download is now marked with sync_in_progress instead of clearing dirty while spectating, and it is set for every download rather than only when inventories are synced. Uploads are skipped while it is set, so a player who leaves during a download with inventories off no longer uploads their stale state. SyncComponent names the components in the Lua annotations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
9.2 KiB
JavaScript
215 lines
9.2 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, enabled = Object.keys(components)) {
|
|
const json = lib.escapeString(JSON.stringify(playerData));
|
|
const sent = lib.escapeString(JSON.stringify(Object.fromEntries(enabled.map(c => [c, true]))));
|
|
return `/sc player_sync.download_inventory('test','${json}',1,1,'${sent}')`;
|
|
}
|
|
|
|
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 which are not synced", async function() {
|
|
for (const component of Object.keys(components)) {
|
|
instance.mockConfigEntries.set(`player_sync.sync_${component}`, component === "appearance");
|
|
}
|
|
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, ["appearance"])]);
|
|
});
|
|
it("should send the components it stripped by when the config changes during the download", async function() {
|
|
instance.connector.once("send", message => {
|
|
instance.mockConfigEntries.set("player_sync.sync_force", false);
|
|
instance.connector.emit("message", new lib.MessageResponse(
|
|
1, message.dst, message.src,
|
|
new DownloadResponse({ generation: 1, name: "test", force: "enemy", tag: "tag" }),
|
|
));
|
|
});
|
|
await download({ player_name: "test" });
|
|
const expected = { generation: 1, name: "test", tag: "tag" };
|
|
const enabled = Object.keys(components).filter(c => c !== "force");
|
|
assert.deepEqual(instance.server.rconCommands, [downloadCommand(expected, enabled)]);
|
|
});
|
|
it("should only send the components with the first chunk", async function() {
|
|
instance.mockConfigEntries.set("player_sync.rcon_chunk_size", 10);
|
|
try {
|
|
respondWith({ generation: 1, name: "test" });
|
|
await download({ player_name: "test" });
|
|
for (let i = 0; i < 50 && instance.server.rconCommands.length < 3; i++) {
|
|
await lib.wait(1);
|
|
}
|
|
const [first, ...rest] = instance.server.rconCommands;
|
|
assert.match(first, /,1,3,'.+'\)$/);
|
|
for (const command of rest) {
|
|
assert.match(command, /,\d,3\)$/);
|
|
}
|
|
} finally {
|
|
instance.mockConfigEntries.set("player_sync.rcon_chunk_size", 100000);
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|