mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Add opt-in UDP messaging between the host and the Factorio server using the --enable-lua-udp option of Factorio 2.0.59+. When factorio.enable_lua_udp is set the instance binds a localhost UDP socket, starts Factorio with --enable-lua-udp on factorio.lua_udp_port (random if null, like the RCON port) and tells the clusterio module the host port via clusterio_private.update_instance. Data from Factorio is sent with clusterio_api.send_udp(channel, data) in the same IPC format as send_json, so plugins receive it through the existing ipc-<channel> events. Packets not sent from the Lua UDP port are ignored, as that is the only unauthenticated way into the IPC handler. Data to Factorio is sent with the new sendUdp method on instances and instance plugins, and is dispatched in-game as on_udp_packet_received events by polling helpers.recv_udp every tick from the clusterio module. Plugins requiring UDP declare the new LuaUdp feature flag, which like SavePatching and ScriptCommands refuses to load the plugin on instances without the option enabled. Verified against a headless 2.1.17 server: send_udp and recv_udp are silent no-ops when the option is off, so a stale port in a save that is moved to an instance without UDP does no harm. The integration test matrix now starts the instance with UDP enabled on Factorio 2.0.59+ and round trips a player event over it. Closes #969 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
402 lines
12 KiB
JavaScript
402 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import * as lib from "@clusterio/lib";
|
|
const validators = lib.Config.validators;
|
|
import { ControllerConfig, HostConfig, InstanceConfig, CtlConfig } from "@clusterio/lib";
|
|
|
|
describe("lib/config/validators", function() {
|
|
describe("all", function() {
|
|
it("should call all validators in order", function() {
|
|
let calls = [];
|
|
|
|
function v1(value, config) {
|
|
calls.push("v1");
|
|
}
|
|
function v2(value, config) {
|
|
calls.push("v2");
|
|
}
|
|
|
|
const combined = validators.all(v1, v2);
|
|
combined("value", {});
|
|
|
|
assert.deepEqual(calls, ["v1", "v2"], "validators were not called in order");
|
|
});
|
|
|
|
it("should propagate errors from validators", function() {
|
|
function v1() {}
|
|
function v2() {
|
|
throw new Error("failure");
|
|
}
|
|
|
|
const combined = validators.all(v1, v2);
|
|
|
|
assert.throws(
|
|
() => combined("value", {}),
|
|
/failure/,
|
|
"error from validator was not propagated"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("optional", function() {
|
|
it("should skip validation when value is null", function() {
|
|
let called = false;
|
|
|
|
function validator() {
|
|
called = true;
|
|
}
|
|
|
|
const wrapped = validators.optional(validator);
|
|
wrapped(null, {});
|
|
|
|
assert.equal(called, false, "validator should not have been called");
|
|
});
|
|
|
|
it("should run validator when value is not null", function() {
|
|
let called = false;
|
|
|
|
function validator() {
|
|
called = true;
|
|
}
|
|
|
|
const wrapped = validators.optional(validator);
|
|
wrapped("value", {});
|
|
|
|
assert.equal(called, true, "validator was not called");
|
|
});
|
|
});
|
|
|
|
describe("greaterThan", function() {
|
|
it("should allow values greater than min", function() {
|
|
const validator = validators.greaterThan(5);
|
|
assert.doesNotThrow(() => validator(6));
|
|
});
|
|
|
|
it("should throw if value equals min", function() {
|
|
const validator = validators.greaterThan(5);
|
|
|
|
assert.throws(
|
|
() => validator(5),
|
|
/Value must be greater than 5/,
|
|
"did not throw on equal value"
|
|
);
|
|
});
|
|
|
|
it("should throw if value is less than min", function() {
|
|
const validator = validators.greaterThan(5);
|
|
|
|
assert.throws(
|
|
() => validator(4),
|
|
/Value must be greater than 5/,
|
|
"did not throw on smaller value"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("greaterThanZero", function() {
|
|
it("should allow values greater than zero", function() {
|
|
assert.doesNotThrow(() => validators.greaterThanZero(1));
|
|
});
|
|
|
|
it("should throw on zero", function() {
|
|
assert.throws(
|
|
() => validators.greaterThanZero(0),
|
|
/Value must be greater than 0/
|
|
);
|
|
});
|
|
|
|
it("should throw on negative values", function() {
|
|
assert.throws(
|
|
() => validators.greaterThanZero(-1),
|
|
/Value must be greater than 0/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("greaterThanEqual", function() {
|
|
it("should allow values greater than min", function() {
|
|
const validator = validators.greaterThanEqual(5);
|
|
assert.doesNotThrow(() => validator(6));
|
|
});
|
|
|
|
it("should allow values equal to min", function() {
|
|
const validator = validators.greaterThanEqual(5);
|
|
assert.doesNotThrow(() => validator(5));
|
|
});
|
|
|
|
it("should throw if value is less than min", function() {
|
|
const validator = validators.greaterThanEqual(5);
|
|
|
|
assert.throws(
|
|
() => validator(4),
|
|
/Value must be greater than 5/,
|
|
"did not throw on smaller value"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("greaterThanEqualZero", function() {
|
|
it("should allow zero", function() {
|
|
assert.doesNotThrow(() => validators.greaterThanEqualZero(0));
|
|
});
|
|
|
|
it("should allow positive values", function() {
|
|
assert.doesNotThrow(() => validators.greaterThanEqualZero(1));
|
|
});
|
|
|
|
it("should throw on negative values", function() {
|
|
assert.throws(
|
|
() => validators.greaterThanEqualZero(-1),
|
|
/Value must be greater than 0/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("integer", function() {
|
|
it("should allow integer values", function() {
|
|
assert.doesNotThrow(() => validators.integer(10));
|
|
});
|
|
|
|
it("should throw on non-integer values", function() {
|
|
assert.throws(
|
|
() => validators.integer(1.5),
|
|
/Value must be an integer/,
|
|
"did not throw on float"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("filePath", function() {
|
|
it("should accept valid paths", function() {
|
|
assert.doesNotThrow(() => validators.filePath("test/file.txt"));
|
|
assert.doesNotThrow(() => validators.filePath("/absolute/path"));
|
|
});
|
|
|
|
it("should throw if value is not a string", function() {
|
|
// This is the only case that will cause path.resolve to throw on posix
|
|
assert.throws(
|
|
() => validators.filePath(null),
|
|
/Value must be a valid path/
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("lib/config/definitions/validators", function() {
|
|
describe("Controller Config", function() {
|
|
it("should validate controller.static_url", function() {
|
|
const config = new ControllerConfig("controller");
|
|
|
|
// valid cases
|
|
config.set("controller.static_url", "");
|
|
config.set("controller.static_url", null);
|
|
config.set("controller.static_url", "static/");
|
|
config.set("controller.static_url", "./static/");
|
|
config.set("controller.static_url", "/static/");
|
|
config.set("controller.static_url", "http://example/static/");
|
|
|
|
// invalid cases
|
|
assert.throws(
|
|
() => config.set("controller.static_url", "/foo/"),
|
|
"Path component of static_url must be \/static\/ not \/foo\/."
|
|
);
|
|
assert.throws(
|
|
() => config.set("controller.static_url", "http://example/foo/static/"),
|
|
"Path component of static_url must be \/static\/ not \/foo\/static\/."
|
|
);
|
|
assert.throws(
|
|
() => config.set("controller.static_url", "http://example/static"),
|
|
"Path component of static_url must be \/static\/ not \/static."
|
|
);
|
|
assert.throws(
|
|
() => config.set("controller.static_url", "http://:80"),
|
|
/Invalid URL/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Host Config", function() {
|
|
|
|
});
|
|
|
|
describe("Instance Config", function() {
|
|
it("should validate factorio.version", function() {
|
|
const config = new InstanceConfig("controller");
|
|
|
|
// valid cases
|
|
assert.doesNotThrow(() => config.set("factorio.version", "latest"));
|
|
assert.doesNotThrow(() => config.set("factorio.version", "1.1"));
|
|
assert.doesNotThrow(() => config.set("factorio.version", "1.1.87"));
|
|
|
|
// invalid case
|
|
assert.throws(
|
|
() => config.set("factorio.version", "invalidValue"),
|
|
/Value must be 'latest', a release channel, or match X.Y or X.Y.Z/
|
|
);
|
|
});
|
|
|
|
describe("factorio.player_online_autosave_slots", function() {
|
|
it("should validate against autosave slots", function() {
|
|
const config = new InstanceConfig("controller", {
|
|
"factorio.settings": {
|
|
autosave_slots: 5,
|
|
},
|
|
});
|
|
|
|
// valid: equal
|
|
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 5));
|
|
|
|
// valid: greater
|
|
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 6));
|
|
|
|
// invalid: less than autosave_slots
|
|
assert.throws(
|
|
() => config.set("factorio.player_online_autosave_slots", 4),
|
|
/Value cannot be less than the number of autosave slots/
|
|
);
|
|
});
|
|
it("should allow disabling player online autosaves", function() {
|
|
const config = new InstanceConfig("controller", {
|
|
"factorio.settings": {
|
|
autosave_slots: 5,
|
|
},
|
|
});
|
|
|
|
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 0));
|
|
});
|
|
it("should allow when autosave_slots is not a number", function() {
|
|
const config = new InstanceConfig("controller", {
|
|
"factorio.settings": {
|
|
autosave_slots: null,
|
|
},
|
|
});
|
|
|
|
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 0));
|
|
assert.doesNotThrow(() => config.set("factorio.player_online_autosave_slots", 1));
|
|
});
|
|
});
|
|
|
|
describe("plugin.load_plugin", function() {
|
|
const initialConfigFields = {};
|
|
|
|
before(function() {
|
|
for (const fieldName of Object.keys(InstanceConfig.fieldDefinitions)) {
|
|
if (fieldName.endsWith("load_plugin")) {
|
|
initialConfigFields[fieldName] = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("should validate against factorio.enable_save_patching", function() {
|
|
class TestInstanceConfig extends InstanceConfig {
|
|
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
|
|
}
|
|
|
|
lib.addPluginFieldDefinitions(
|
|
{name: "save_patching", features: ["SavePatching"] },
|
|
"instanceConfigFields", TestInstanceConfig
|
|
);
|
|
|
|
const config = new TestInstanceConfig("controller", initialConfigFields);
|
|
|
|
// valid: does not require script commands
|
|
assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false));
|
|
|
|
// valid: can have load disabled
|
|
assert.doesNotThrow(() => config.set("save_patching.load_plugin", false));
|
|
|
|
// valid: does not require save patching when not loaded
|
|
assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false));
|
|
|
|
// invalid: requires save patching when loaded
|
|
assert.throws(
|
|
() => config.set("save_patching.load_plugin", true),
|
|
/requires save patching/
|
|
);
|
|
});
|
|
it("should validate against factorio.enable_script_commands", function() {
|
|
class TestInstanceConfig extends InstanceConfig {
|
|
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
|
|
}
|
|
|
|
lib.addPluginFieldDefinitions(
|
|
{ name: "script_commands", features: ["ScriptCommands"] },
|
|
"instanceConfigFields", TestInstanceConfig
|
|
);
|
|
|
|
const config = new TestInstanceConfig("controller", initialConfigFields);
|
|
|
|
// valid: does not require save patching
|
|
assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false));
|
|
|
|
// valid: can have load disabled
|
|
assert.doesNotThrow(() => config.set("script_commands.load_plugin", false));
|
|
|
|
// valid: does not require script commands when not loaded
|
|
assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false));
|
|
|
|
// invalid: requires script commands when loaded
|
|
assert.throws(
|
|
() => config.set("script_commands.load_plugin", true),
|
|
/requires script commands/
|
|
);
|
|
});
|
|
it("should validate against factorio.enable_lua_udp", function() {
|
|
class TestInstanceConfig extends InstanceConfig {
|
|
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
|
|
}
|
|
|
|
lib.addPluginFieldDefinitions(
|
|
{ name: "lua_udp", features: ["LuaUdp"] },
|
|
"instanceConfigFields", TestInstanceConfig
|
|
);
|
|
|
|
const config = new TestInstanceConfig("controller", initialConfigFields);
|
|
|
|
// valid: does not require save patching or script commands
|
|
assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false));
|
|
assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false));
|
|
|
|
// valid: can have load disabled
|
|
assert.doesNotThrow(() => config.set("lua_udp.load_plugin", false));
|
|
|
|
// invalid: requires lua udp when loaded (disabled by default)
|
|
assert.throws(
|
|
() => config.set("lua_udp.load_plugin", true),
|
|
/requires Lua UDP/
|
|
);
|
|
|
|
// valid: requirement satisfied
|
|
assert.doesNotThrow(() => config.set("factorio.enable_lua_udp", true));
|
|
assert.doesNotThrow(() => config.set("lua_udp.load_plugin", true));
|
|
});
|
|
it("should allow when no feature flags are present", function() {
|
|
class TestInstanceConfig extends InstanceConfig {
|
|
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
|
|
}
|
|
|
|
lib.addPluginFieldDefinitions(
|
|
{ name: "no_features", features: [] },
|
|
"instanceConfigFields", TestInstanceConfig
|
|
);
|
|
|
|
const config = new TestInstanceConfig("controller", initialConfigFields);
|
|
|
|
// valid: does not require save patching
|
|
assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false));
|
|
|
|
// valid: does not require script commands
|
|
assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false));
|
|
|
|
// valid: can have load disabled
|
|
assert.doesNotThrow(() => config.set("no_features.load_plugin", false));
|
|
|
|
// no invalid case
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Ctl Config", function() {
|
|
|
|
});
|
|
});
|