Files
Danielv123 a5bd43637e Enable the player proxy by default and give lobbies an empty save
The player proxy now listens on 34197 by default. New hosts default to
the port range 34200-34299 so they don't hand that port to instances,
and the controller logs an error and runs without the proxy if the port
is taken, as hosts with the old range only skip it when the controller
already holds it.

Lobby instances start the save lobby.zip, created on first start as a
one tile map with no autoplace and with the freeplay scenario replaced by
an empty one. This uses the new chooseSave instance hook, which lets
plugins pick or create the save to start, and setSaveScenario from
@clusterio/host, which sets the scenario the save patcher keeps loading.
Map gen settings can't use a width and height of 0 for this, as Factorio
takes 0 to mean an unlimited map.

The address players reach the proxy on moves from lobby.public_address
to controller.player_proxy_public_address. When it's set the player_auth
login page lists it once for the instances behind the proxy instead of
their own addresses, and lobbies with player_auth loaded get a button
that opens the web interface login dialog.
2026-10-29 20:54:11 +01:00

306 lines
11 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 * as patch from "@clusterio/host/dist/node/src/patch.js";
import { createZipBuffer } from "../common.js";
describe("host/patch", function() {
describe("class SaveModule", function() {
describe("static moduleFilePath()", function() {
it("Should remap locale files", function() {
const mappings = [
["locale/en/foo.cfg", "locale/en/test-foo.cfg"],
["locale/en/foo.txt", "locale/en/test-foo.txt"],
["locale/en/foo.tmp.txt", "locale/en/test-foo.tmp.txt"],
["locale/en/foo", "locale/en/test-foo"],
["locale/en.cfg", "locale/en/test.cfg"],
["locale/en.txt", "locale/en/test.txt"],
["locale/en.tmp.txt", "locale/en/test.tmp.txt"],
["locale/en", "locale/en/test"],
["locale/en/bar/foo.cfg", "locale/en/test-bar/foo.cfg"],
];
for (let [input, expected] of mappings) {
assert.equal(patch.SaveModule.moduleFilePath(input, "test"), expected);
}
});
});
describe("loadFiles()", function() {
const testModule = new patch.SaveModule(new lib.ModuleInfo("test", "1.0.0"));
const referenceFiles = new Map([
["modules/test/test.lua", "-- test\n"],
["modules/test/module_exports.lua", "-- module_exports\n"],
["locale/en/test.cfg", "module-test=A Test\n"],
["locale/en/test-locale.cfg", "module-test-locale=Test Locale\n"],
["modules/test.lua", "return require(\"modules/test/module_exports\")"],
]);
before(async function() {
await testModule.loadFiles("test/file/modules/test");
});
it("Should read the contents of all files in a module", function() {
assert.equal(testModule.files.size, referenceFiles.size);
for (let [relativePath, contents] of referenceFiles) {
if (!testModule.files.has(relativePath)) {
assert.fail(`Module is missing file: ${relativePath}`);
} else {
assert.equal(testModule.files.get(relativePath).toString("utf-8"), contents);
}
}
});
});
});
describe("generateLoader()", function() {
let reference = [
"-- Auto generated scenario module loader created by Clusterio",
"-- Modifications to this file will be lost when loaded in Clusterio",
"clusterio_patch_number = 1",
"",
'local event_handler = require("event_handler")',
"",
"-- Scenario modules",
'event_handler.add_lib(require("foo"))',
"",
"-- Clusterio modules",
'event_handler.add_lib(require("modules/spam/bar"))',
'require("modules/spam/spam")',
"",
].join("\n");
let patchInfo = new patch.PatchInfo(
1,
new lib.ModuleInfo("foo", "0.0.0", ["foo"]),
[
new patch.SaveModule(
new lib.ModuleInfo(
"spam",
"0.0.0",
["bar.lua"],
["spam.lua"],
),
new Map([
["bar.lua", Buffer.from("")],
["spam.lua", Buffer.from("")],
["excluded.lua", Buffer.from("")],
]),
),
],
);
it("should produce output matching the reference", function() {
assert.equal(patch._generateLoader(patchInfo), reference);
});
});
function module({ name, version, dependencies = {} }) {
const dependencyMap = new Map(Object.entries(dependencies));
return new patch.SaveModule(new lib.ModuleInfo(name, version, [], [], dependencyMap));
}
describe("reorderDependencies()", function() {
it("should reorder to satisfy simple dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {} }),
];
patch._reorderDependencies(modules);
assert(modules[0].info.name === "b", "Dependency was not reorederd");
});
it("should throw on invalid version", function() {
let modules = [
module({ name: "a", version: "foo", dependencies: {} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Invalid version 'foo' for module a")
);
});
it("should throw on invalid version range", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "invalid"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Invalid version range 'invalid' for dependency b on module a")
);
});
it("should throw on missing dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Missing dependency b for module a")
);
modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"c": "*"} }),
module({ name: "c", version: "1.0.0", dependencies: {"d": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Missing dependency d for module c")
);
});
it("should throw on outdated dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {} }),
module({ name: "b", version: "1.0.0", dependencies: {"a": ">=2"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module b requires a >=2")
);
});
it("should throw on dependency loops", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"a": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module dependency loop detected: a -> b -> a")
);
modules = [
module({ name: "d", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"c": "*"} }),
module({ name: "c", version: "1.0.0", dependencies: {"a": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module dependency loop detected: b -> c -> a -> b")
);
});
it("should reorder correctly for multiple dependencies", function() {
function validate(modules) {
let present = new Set();
for (let { info } of modules) {
for (let dependency of info.dependencies.keys()) {
if (!present.has(dependency)) {
assert.fail(`${info.name} depends on ${dependency}, but was ordered before it`);
}
}
present.add(info.name);
}
}
let names = ["a", "b", "c"];
let successCount = 0;
const bitCount = names.length * (names.length - 1);
for (let i=0; i < 2**bitCount; i++) {
let value = i;
let bits = [];
for (let j=0; j < bitCount; j++) {
bits.push(value & 1);
value >>= 1;
}
let modules = [];
for (let j=0; j < names.length; j++) {
let name = names[j];
let dependencies = {};
for (let k=0; k < names.length - 1; k++) {
if (bits[k + j * (names.length - 1)]) {
dependencies[names[k + (k >= j)]] = "*";
}
}
modules.push(module({ name, version: "1.0.0", dependencies }));
}
let success = false;
try {
patch._reorderDependencies(modules);
success = true;
} catch (err) { }
if (success) {
validate(modules);
successCount += 1;
}
}
// Deriving the formula for the number of graphs without
// dependencies loops is left as an excercise for the reader.
assert.equal(successCount, 25, "Incorrect number of successful orderings");
});
});
describe("loadModules()", function() {
it("should load the bundled clusterio module", async function() {
const modules = await patch.loadModules([]);
assert.deepEqual([...modules.keys()], ["clusterio"]);
assert(modules.get("clusterio").files.has("modules/clusterio/api.lua"));
});
it("should load modules from plugins", async function() {
const pluginInfos = await lib.loadPluginInfos(
new Map([["research_sync", path.resolve("plugins/research_sync")]])
);
const modules = await patch.loadModules(pluginInfos);
assert.deepEqual([...modules.keys()], ["research_sync", "clusterio"]);
assert(modules.get("research_sync").files.has("modules/research_sync/sync.lua"));
});
it("should skip plugins without a module", async function() {
const pluginInfos = await lib.loadPluginInfos(
new Map([["test_plugin_no_module", path.resolve("test/file/test_plugin_no_module")]])
);
const modules = await patch.loadModules(pluginInfos);
assert.deepEqual([...modules.keys()], ["clusterio"]);
});
it("should throw on a plugin module named like a bundled module", async function() {
const pluginDir = path.join("temp", "test", "clusterio_plugin");
await fs.mkdir(path.join(pluginDir, "module"), { recursive: true });
await fs.writeFile(path.join(pluginDir, "package.json"), "{}");
await fs.writeFile(path.join(pluginDir, "module", "module.json"), "{}");
await assert.rejects(
patch.loadModules([{
name: "clusterio", version: "1.0.0", packagePath: path.resolve(pluginDir, "package.json"),
}]),
new Error("Module with name clusterio already exists in a plugin")
);
});
});
describe("patch()", function() {
it("should throw on unknown scenario", async function() {
let zipPath = path.join("temp", "test", "patch.zip");
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", "-- unknown\n"]]));
await assert.rejects(
patch.patch(zipPath, []),
new Error("Unable to patch save, unknown scenario (3acc3be3861144e55604f5ac2f2555071885ebc4)")
);
});
});
describe("setSaveScenario()", function() {
it("should let a save with an unknown scenario be patched without loading it", async function() {
await fs.mkdir(path.join("temp", "test"), { recursive: true });
const zipPath = path.join("temp", "test", "set-scenario.zip");
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", "-- unknown\n"],
["world/level.dat", "level"],
]));
await patch.setSaveScenario(zipPath, new lib.ModuleInfo("empty", "0.0.0"));
await patch.patch(zipPath, []);
const zip = await lib.ZipArchive.fromFile(zipPath);
try {
const info = JSON.parse((await zip.readFile("world/clusterio.json")).toString());
assert.equal(info.scenario.name, "empty");
assert.deepEqual(info.scenario.load, []);
assert.equal(info.patch_number, 1);
assert.equal((await zip.readFile("world/level.dat")).toString(), "level");
assert.doesNotMatch((await zip.readFile("world/control.lua")).toString(), /unknown/);
} finally {
zip.close();
}
await assert.rejects(
patch.setSaveScenario(zipPath, new lib.ModuleInfo("empty", "0.0.0")),
new Error("Unable to set scenario of a save that has already been patched")
);
});
});
});