mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Merge pull request #996 from bbassie/feature/instance-udp
Add UDP communication support for instances
This commit is contained in:
19 files changed
+708
-25
No files matched your search
@@ -606,6 +606,23 @@ Like factorio.enable_save_patching, plugins that need script commands can't be l
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.enable_lua_udp
|
||||
|
||||
Enable UDP messaging between the host and the Factorio server by passing `--enable-lua-udp` to Factorio.
|
||||
This lets plugins send UDP packets to the server and receive data sent with `clusterio_api.send_udp` in-game.
|
||||
Requires Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.lua_udp_port
|
||||
|
||||
UDP port on localhost the Factorio server receives Lua UDP messages on when `factorio.enable_lua_udp` is enabled.
|
||||
If null a random port in the dynamic range is assigned each time the instance starts.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.enable_whitelist
|
||||
|
||||
Turn on the whitelist on the server.
|
||||
|
||||
@@ -292,6 +292,19 @@ Parameters:
|
||||
- `channel`: string identifying which channel to send it on.
|
||||
- `data`: table that can be converted to JSON with `compat.table_to_json`
|
||||
|
||||
#### clusterio_api.send_udp(channel, data)
|
||||
|
||||
Send data to Clusterio over the given channel using UDP instead of stdout.
|
||||
Clusterio plugins receive it as `udp-channel_name` events, see the [Communicating over UDP section](writing-plugins.md#communicating-over-udp) in the Writing Plugins document.
|
||||
Requires `factorio.enable_lua_udp` to be enabled on the instance and Factorio 2.1.10 or later, otherwise the data is silently dropped.
|
||||
The port to send to is passed to the module with a script command on startup, so `factorio.enable_script_commands` must be enabled as well.
|
||||
|
||||
**Note**: Delivery is not guaranteed and payloads should be kept well below 32 kB.
|
||||
|
||||
Parameters:
|
||||
- `channel`: string identifying which channel to send it on.
|
||||
- `data`: string with the payload to send, any bytes are allowed.
|
||||
|
||||
|
||||
### serialize library
|
||||
|
||||
|
||||
+39
-2
@@ -147,7 +147,7 @@ The following properties are recognized:
|
||||
See [Migrating Stored Data](#migrating-stored-data)
|
||||
|
||||
**features**:
|
||||
Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
|
||||
Array of features the plugin needs from the instances it is loaded on, any of `"SavePatching"`, `"ScriptCommands"` and `"LuaUdp"`.
|
||||
See [Handling Invalid Configuration](#handling-invalid-configuration)
|
||||
|
||||
**messages**:
|
||||
@@ -303,7 +303,7 @@ See [Configuration System](config-system.md) for more details on how this system
|
||||
|
||||
### Handling Invalid Configuration
|
||||
|
||||
If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
|
||||
If the plugin requires save patching, script commands or Lua UDP to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
|
||||
|
||||
```js
|
||||
export const plugin = {
|
||||
@@ -501,6 +501,43 @@ Data out from Factorio does not have the same limits as data into Factorio, RCON
|
||||
For `send_json` it's possible that payloads greater than 4kB are received after payloads that were sent at a later point in time.
|
||||
For RCON, commands longer than 50 characters may end up being executed after shorter commands sent after it.
|
||||
|
||||
### Communicating over UDP
|
||||
|
||||
Instances can optionally exchange UDP packets with the Factorio server for low latency fire-and-forget communication, using the `--enable-lua-udp` option of Factorio.
|
||||
This needs Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server.
|
||||
This is disabled by default and enabled by setting `factorio.enable_lua_udp` on the instance.
|
||||
Sending data out of Factorio also needs `factorio.enable_script_commands`, as the port to send to is passed to the module with a script command on startup.
|
||||
Plugins requiring it should declare the `LuaUdp` feature.
|
||||
Note that UDP does not guarantee delivery or ordering, and packets should be kept well below 32 kB.
|
||||
|
||||
Data out from Factorio is sent with the `send_udp` API of the Clusterio module.
|
||||
Unlike `send_json` the payload is an arbitrary string, and it is delivered to plugins as a `udp-channel_name` event with the payload as a Buffer:
|
||||
|
||||
```lua
|
||||
clusterio_api.send_udp("my_plugin_foo", "123")
|
||||
```
|
||||
|
||||
```js
|
||||
export default async function(context) {
|
||||
const { instance, logger } = context;
|
||||
instance.server.handleUdp("my_plugin_foo", async data => {
|
||||
logger.info(`got ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Like `handle` for `send_json`, `handleUdp` logs errors thrown by the handler.
|
||||
Use `instance.server.on("udp-my_plugin_foo", ...)` for custom error handling.
|
||||
|
||||
Data into Factorio is sent with the `sendUdp` method on the instance and delivered in-game as `defines.events.on_udp_packet_received` events with the packet in `event.payload`.
|
||||
The Clusterio module polls for received packets every tick, all modules and mods receive every packet so make sure the payload identifies your plugin.
|
||||
|
||||
```js
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
await instance.sendUdp("my_plugin_foo:123");
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## Defining Link Messages
|
||||
|
||||
|
||||
@@ -38,17 +38,20 @@ function api.get_instance_id()
|
||||
end
|
||||
|
||||
|
||||
local function escape_channel(channel)
|
||||
-- Escape bad characters. The question mark is used for separating the
|
||||
-- channel name from the payload.
|
||||
return channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||
return "\\x" .. string.format("%02x", match:byte())
|
||||
end)
|
||||
end
|
||||
|
||||
function api.send_json(channel, data)
|
||||
if not remote.interfaces.clusterio_api then
|
||||
return
|
||||
end
|
||||
|
||||
-- Escape bad characters. The question mark is used for separating the
|
||||
-- channel name from the payload.
|
||||
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||
return "\\x" .. string.format("%02x", match:byte())
|
||||
end)
|
||||
|
||||
channel = escape_channel(channel)
|
||||
data = compat.table_to_json(data)
|
||||
|
||||
-- If there's more than about 4kB of data users running with the Windows
|
||||
@@ -64,5 +67,14 @@ function api.send_json(channel, data)
|
||||
end
|
||||
end
|
||||
|
||||
function api.send_udp(channel, data)
|
||||
local port = call_api("get_host_udp_port")
|
||||
if not port then
|
||||
return -- Lua UDP is not enabled for this instance
|
||||
end
|
||||
|
||||
helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0)
|
||||
end
|
||||
|
||||
|
||||
return api
|
||||
@@ -17,14 +17,16 @@ function api.get_instance_id()
|
||||
return compat.script_data.clusterio.instance_id
|
||||
end
|
||||
|
||||
function api.send_json(channel, data)
|
||||
|
||||
local function escape_channel(channel)
|
||||
-- Escape bad characters. The question mark is used for separating the
|
||||
-- channel name from the payload.
|
||||
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||
return channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||
return "\\x" .. string.format("%02x", match:byte())
|
||||
end)
|
||||
end
|
||||
|
||||
function api.send_json(channel, data)
|
||||
channel = escape_channel(channel)
|
||||
data = compat.table_to_json(data)
|
||||
|
||||
-- If there's more than about 4kB of data users running with the Windows
|
||||
@@ -41,5 +43,15 @@ function api.send_json(channel, data)
|
||||
end
|
||||
end
|
||||
|
||||
function api.send_udp(channel, data)
|
||||
local clusterio = compat.script_data.clusterio
|
||||
local port = clusterio and clusterio.host_udp_port
|
||||
if not port then
|
||||
return -- Lua UDP is not enabled for this instance
|
||||
end
|
||||
|
||||
helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0)
|
||||
end
|
||||
|
||||
|
||||
return api
|
||||
@@ -12,16 +12,27 @@ local function check_patch()
|
||||
end
|
||||
end
|
||||
|
||||
-- Added in Factorio 2.0.59, indexing a missing key on a LuaObject errors
|
||||
local has_recv_udp = helpers ~= nil and pcall(function() return helpers.recv_udp end)
|
||||
|
||||
local impl = {}
|
||||
impl.events = {}
|
||||
|
||||
impl.events[defines.events.on_tick] = check_patch
|
||||
impl.events[defines.events.on_tick] = function()
|
||||
check_patch()
|
||||
-- Dispatch on_udp_packet_received for packets sent by the host, this is
|
||||
-- a no-op when the server was started without --enable-lua-udp
|
||||
if has_recv_udp then
|
||||
helpers.recv_udp(0)
|
||||
end
|
||||
end
|
||||
|
||||
impl.events[api.events.on_server_startup] = function()
|
||||
if not compat.script_data.clusterio then
|
||||
compat.script_data.clusterio = {
|
||||
instance_id = nil,
|
||||
instance_name = nil,
|
||||
host_udp_port = nil,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -74,11 +85,12 @@ end
|
||||
|
||||
-- Internal API
|
||||
clusterio_private = {}
|
||||
function clusterio_private.update_instance(new_id, new_name)
|
||||
function clusterio_private.update_instance(new_id, new_name, host_udp_port)
|
||||
check_patch()
|
||||
local script_data = compat.script_data
|
||||
script_data.clusterio.instance_id = new_id
|
||||
script_data.clusterio.instance_name = new_name
|
||||
script_data.clusterio.host_udp_port = host_udp_port
|
||||
script.raise_event(api.events.on_instance_updated, {
|
||||
instance_id = new_id,
|
||||
instance_name = new_name,
|
||||
@@ -105,6 +117,12 @@ remote.add_interface('clusterio_api', {
|
||||
return compat.script_data.clusterio.instance_name
|
||||
end,
|
||||
|
||||
get_host_udp_port = function()
|
||||
-- Not initialised until the first tick after the save is patched
|
||||
local clusterio = compat.script_data.clusterio
|
||||
return clusterio and clusterio.host_udp_port
|
||||
end,
|
||||
|
||||
get_file_no = function()
|
||||
local script_data = compat.script_data
|
||||
script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1
|
||||
|
||||
@@ -259,6 +259,22 @@ export class BaseInstancePlugin {
|
||||
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send UDP message to instance
|
||||
*
|
||||
* Send a UDP packet to the Factorio server, which is delivered in-game
|
||||
* as an on_udp_packet_received event. Delivery is not guaranteed and
|
||||
* the payload should be kept well below 64 kB. Requires the LuaUdp
|
||||
* plugin feature.
|
||||
*
|
||||
* This should not be called before onStart or after onStop.
|
||||
*
|
||||
* @param message - payload of the packet to send.
|
||||
*/
|
||||
async sendUdp(message: string | Buffer): Promise<void> {
|
||||
await this.instance.sendUdp(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send serially ordered RCON message to instance
|
||||
*
|
||||
|
||||
@@ -233,6 +233,8 @@ export default class Instance extends lib.Link {
|
||||
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
|
||||
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
|
||||
rconPassword: this.config.get("factorio.rcon_password") ?? undefined,
|
||||
enableLuaUdp: this.config.get("factorio.enable_lua_udp"),
|
||||
luaUdpPort: this.config.get("factorio.lua_udp_port") ?? undefined,
|
||||
enableWhitelist: this.config.get("factorio.enable_whitelist"),
|
||||
enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"),
|
||||
verboseLogging: this.config.get("factorio.verbose_logging"),
|
||||
@@ -506,6 +508,23 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send UDP message to the Factorio server
|
||||
*
|
||||
* Delivered in-game as an on_udp_packet_received event. Requires
|
||||
* factorio.enable_lua_udp to be enabled on the instance.
|
||||
*
|
||||
* @param message - payload of the packet to send.
|
||||
*/
|
||||
async sendUdp(message: string | Buffer) {
|
||||
if (!this.config.get("factorio.enable_lua_udp")) {
|
||||
throw new Error(
|
||||
"Attempted to send UDP message while Lua UDP is disabled. See config factorio.enable_lua_udp."
|
||||
);
|
||||
}
|
||||
await this.server.sendUdp(message);
|
||||
}
|
||||
|
||||
static async listSaves(instanceId: number, savesDir: string, loadedSave: string | null) {
|
||||
let defaultSave = null;
|
||||
if (loadedSave === null) {
|
||||
@@ -1090,7 +1109,8 @@ end`.replace(/\r?\n/g, " ");
|
||||
*/
|
||||
async updateInstanceData() {
|
||||
let name = lib.escapeString(this.name);
|
||||
await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}")`, true);
|
||||
let hostUdpPort = this.server.hostUdpPort ?? "nil";
|
||||
await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}", ${hostUdpPort})`, true);
|
||||
}
|
||||
|
||||
async updateFactorioSettings(current: Record<string, unknown>, previous: Record<string, unknown>) {
|
||||
|
||||
+139
-8
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { type FSWatcher, createWriteStream, watch as fsWatch, writeFileSync } from "node:fs";
|
||||
import child_process from "child_process";
|
||||
import dgram from "dgram";
|
||||
import path from "path";
|
||||
import stream from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
@@ -303,6 +304,15 @@ async function generatePassword(length: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function unescapeChannel(channel: Buffer) {
|
||||
return channel
|
||||
.toString("utf-8")
|
||||
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
|
||||
String.fromCharCode(parseInt(p1, 16))
|
||||
))
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret lines of output from Factorio
|
||||
*
|
||||
@@ -425,6 +435,9 @@ const outputHeuristics: Heuristic[] = [
|
||||
filter: { type: "log", message: /^Starting RCON interface/ },
|
||||
action: function() {
|
||||
this._startRcon().catch((err) => { this.emit("error", err); });
|
||||
if (this.enableLuaUdp) {
|
||||
this._startUdp().catch((err) => { this.emit("error", err); });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -556,6 +569,10 @@ export interface FactorioServerOptions {
|
||||
rconPort?: number,
|
||||
/** Password use for RCON. */
|
||||
rconPassword?: string,
|
||||
/** Enable UDP messaging with the Factorio server. */
|
||||
enableLuaUdp?: boolean,
|
||||
/** UDP port the Factorio server receives Lua UDP messages on. */
|
||||
luaUdpPort?: number,
|
||||
/** Turn on whitelisting. */
|
||||
enableWhitelist?: boolean,
|
||||
/** Enable Factorio.com based multiplayer bans. */
|
||||
@@ -600,8 +617,9 @@ type FactorioServerEvents = {
|
||||
"_saving": [],
|
||||
"_saved": [],
|
||||
|
||||
// IPS events
|
||||
// IPC events
|
||||
[ipcEvent: `ipc-${string}`]: [ event: any ],
|
||||
[udpEvent: `udp-${string}`]: [ data: Buffer ],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -621,6 +639,8 @@ type FactorioServerEvents = {
|
||||
* - autosave-fnished - invoked when the autosave finished
|
||||
* - save-finished - invoked when the server has finished a manual save
|
||||
* - exit - invoked when the sterver has exited
|
||||
* - ipc-<channel> - invoked with data sent with send_json in-game
|
||||
* - udp-<channel> - invoked with data sent with send_udp in-game
|
||||
* @extends events.EventEmitter
|
||||
*/
|
||||
export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
@@ -632,6 +652,10 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
rconPort: number;
|
||||
/** Password used for RCON on the Factorio game server */
|
||||
rconPassword: string;
|
||||
/** Enable UDP messaging with the Factorio game server */
|
||||
enableLuaUdp: boolean;
|
||||
/** UDP port the Factorio game server receives Lua UDP messages on */
|
||||
luaUdpPort: number;
|
||||
/** Enable player whitelist */
|
||||
enableWhitelist: boolean;
|
||||
/** Enable Factorio.com based multiplayer bans **/
|
||||
@@ -661,6 +685,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
_server: child_process.ChildProcessWithoutNullStreams | null = null;
|
||||
_rconClient: Rcon | null= null;
|
||||
_rconReady = false;
|
||||
_udpSocket: dgram.Socket | null = null;
|
||||
_gameReady = false;
|
||||
_stripRegExp?: RegExp;
|
||||
_maxConcurrentCommands = 5;
|
||||
@@ -693,6 +718,10 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
this.rconPort = options.rconPort || randomDynamicPort();
|
||||
/** Password used for RCON on the Factorio game server */
|
||||
this.rconPassword = options.rconPassword as string; // init will generate one if not there
|
||||
/** Enable UDP messaging with the Factorio game server */
|
||||
this.enableLuaUdp = options.enableLuaUdp || false;
|
||||
/** UDP port the Factorio game server receives Lua UDP messages on */
|
||||
this.luaUdpPort = options.luaUdpPort || randomDynamicPort();
|
||||
/** Enable player whitelist */
|
||||
this.enableWhitelist = options.enableWhitelist || false;
|
||||
/** Enable Factorio.com based multiplayer bans **/
|
||||
@@ -755,19 +784,24 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
}));
|
||||
}
|
||||
|
||||
handleUdp(channel: string, handler: (data: Buffer) => Promise<void>) {
|
||||
// Runs from the socket callback, so synchronous throws must be caught here too
|
||||
this.on(`udp-${channel}`, async (data) => {
|
||||
try {
|
||||
await handler(data);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Error handling udp event:\n${err.stack ?? err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _handleIpc(line: Buffer) {
|
||||
let channelEnd = line.indexOf("?");
|
||||
if (channelEnd === -1) {
|
||||
throw new Error(`Malformed IPC line "${line.toString()}"`);
|
||||
}
|
||||
|
||||
let channel = line
|
||||
.subarray(6, channelEnd)
|
||||
.toString("utf-8")
|
||||
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
|
||||
String.fromCharCode(parseInt(p1, 16))
|
||||
))
|
||||
;
|
||||
let channel = unescapeChannel(line.subarray(6, channelEnd));
|
||||
|
||||
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
|
||||
let content;
|
||||
@@ -805,6 +839,74 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
_handleUdpMessage(message: Buffer, rinfo: dgram.RemoteInfo) {
|
||||
// Factorio sends from the socket bound to the Lua UDP port, anything
|
||||
// else is some other local process.
|
||||
if (rinfo.port !== this.luaUdpPort) {
|
||||
this._logger.warn(`Ignoring UDP packet from unexpected source ${rinfo.address}:${rinfo.port}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let channelEnd = message.indexOf("?");
|
||||
if (channelEnd === -1) {
|
||||
this._logger.warn(`Ignoring malformed UDP packet "${message.subarray(0, 100).toString("utf-8")}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
let channel = unescapeChannel(message.subarray(0, channelEnd));
|
||||
let data = message.subarray(channelEnd + 1);
|
||||
if (!this.emit(`udp-${channel}`, data)) {
|
||||
this._logger.warn(`Warning: Unhandled udp-${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
async _startUdp() {
|
||||
if (this._udpSocket !== null) {
|
||||
throw Error("UDP socket is already open");
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket("udp4");
|
||||
socket.on("message", (message, rinfo) => this._handleUdpMessage(message, rinfo));
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.bind(0, "127.0.0.1", () => {
|
||||
socket.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Failed to open UDP socket:\n${err?.stack ?? err?.message ?? err}`);
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
socket.on("error", err => this.emit("error", err));
|
||||
this._udpSocket = socket;
|
||||
}
|
||||
|
||||
// Factorio before 2.1.10 crashes when a packet is received on a headless server
|
||||
_checkLuaUdpVersion() {
|
||||
if (this.enableLuaUdp && lib.integerFullVersion(this._version!) < lib.integerFullVersion("2.1.10")) {
|
||||
throw new Error("Lua UDP requires Factorio 2.1.10 or later");
|
||||
}
|
||||
}
|
||||
|
||||
_stopUdp() {
|
||||
if (this._udpSocket) {
|
||||
this._udpSocket.close();
|
||||
this._udpSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UDP port on localhost the host receives Lua UDP messages on
|
||||
*
|
||||
* Only available while the server is running with Lua UDP enabled.
|
||||
*/
|
||||
get hostUdpPort(): number | undefined {
|
||||
return this._udpSocket?.address().port;
|
||||
}
|
||||
|
||||
_handleOutput(rawLine: Buffer, source: "stdout" | "stderr") {
|
||||
if (rawLine.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) {
|
||||
this._handleIpc(rawLine).catch(err => this.emit("error", err));
|
||||
@@ -1087,6 +1189,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
this._server = null;
|
||||
this._rconClient = null;
|
||||
this._rconReady = false;
|
||||
this._udpSocket = null;
|
||||
this._gameReady = false;
|
||||
this._unexpected = [];
|
||||
this._killed = false;
|
||||
@@ -1132,6 +1235,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (this._rconClient) {
|
||||
this._rconClient.end().catch(() => {});
|
||||
}
|
||||
this._stopUdp();
|
||||
|
||||
if (this._whitelistWatcher) {
|
||||
this._whitelistWatcher.close();
|
||||
@@ -1150,6 +1254,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (this._rconClient) {
|
||||
this._rconClient.end().catch(() => {});
|
||||
}
|
||||
this._stopUdp();
|
||||
|
||||
if (this._whitelistWatcher) {
|
||||
this._whitelistWatcher.close();
|
||||
@@ -1248,6 +1353,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
*/
|
||||
async start(save: string) {
|
||||
this._check(["init"]);
|
||||
this._checkLuaUdpVersion();
|
||||
this._state = "running";
|
||||
|
||||
try {
|
||||
@@ -1263,6 +1369,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
"--port", String(this.gamePort),
|
||||
"--rcon-port", String(this.rconPort),
|
||||
"--rcon-password", this.rconPassword,
|
||||
...(this.enableLuaUdp ? ["--enable-lua-udp", String(this.luaUdpPort)] : []),
|
||||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||||
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
||||
...(this.verboseLogging ? ["--verbose"] : []),
|
||||
@@ -1300,6 +1407,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
*/
|
||||
async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) {
|
||||
this._check(["init"]);
|
||||
this._checkLuaUdpVersion();
|
||||
this._state = "running";
|
||||
|
||||
try {
|
||||
@@ -1321,6 +1429,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
"--port", String(this.gamePort),
|
||||
"--rcon-port", String(this.rconPort),
|
||||
"--rcon-password", this.rconPassword,
|
||||
...(this.enableLuaUdp ? ["--enable-lua-udp", String(this.luaUdpPort)] : []),
|
||||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||||
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
||||
...(this.verboseLogging ? ["--verbose"] : []),
|
||||
@@ -1401,6 +1510,27 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send UDP message to the server
|
||||
*
|
||||
* Sends a UDP packet to the Lua UDP port of the Factorio server, which
|
||||
* is delivered in-game as an on_udp_packet_received event. Delivery is
|
||||
* not guaranteed. Requires Lua UDP to be enabled.
|
||||
*
|
||||
* @param message - payload of the packet to send.
|
||||
*/
|
||||
async sendUdp(message: string | Buffer) {
|
||||
this._check(["running", "stopping"]);
|
||||
if (!this._udpSocket) {
|
||||
throw new Error("UDP socket is not open");
|
||||
}
|
||||
|
||||
const socket = this._udpSocket;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.send(message, this.luaUdpPort, "127.0.0.1", err => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout in ms to wait after a shutdown is requested before killing the
|
||||
* process. Defaults to 0 meaning no timeout
|
||||
@@ -1436,6 +1566,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (this._rconClient) {
|
||||
this._rconClient.end().catch(() => {});
|
||||
}
|
||||
this._stopUdp();
|
||||
if (this._whitelistWatcher) {
|
||||
this._whitelistWatcher.close();
|
||||
}
|
||||
|
||||
@@ -482,6 +482,8 @@ export interface InstanceConfigFields {
|
||||
"factorio.host_assigned_game_port": number | null;
|
||||
"factorio.rcon_port": number | null;
|
||||
"factorio.rcon_password": string | null;
|
||||
"factorio.enable_lua_udp": boolean;
|
||||
"factorio.lua_udp_port": number | null;
|
||||
"factorio.player_online_autosave_slots": number;
|
||||
"factorio.mod_pack_id": number | null;
|
||||
"factorio.enable_save_patching": boolean;
|
||||
@@ -606,6 +608,21 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "string",
|
||||
optional: true,
|
||||
},
|
||||
"factorio.enable_lua_udp": {
|
||||
description:
|
||||
"Enable UDP messaging between the Factorio server and the host. " +
|
||||
"Required by plugins using UDP. Requires Factorio 2.1.10 or later.",
|
||||
restartRequired: true, // Because of the cli option "--enable-lua-udp"
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.lua_udp_port": {
|
||||
description: "UDP port for the Factorio server to receive Lua UDP messages on, uses a random port if null",
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.greaterThanEqualZero),
|
||||
},
|
||||
"factorio.player_online_autosave_slots": {
|
||||
description:
|
||||
"Rename autosaves where players have been online since the previous autosave into a separate " +
|
||||
@@ -795,7 +812,12 @@ export function addPluginFieldDefinitions(
|
||||
fieldDef.dependsOn.push("factorio.enable_script_commands");
|
||||
}
|
||||
|
||||
if (scriptCommands || savePatching) {
|
||||
const luaUdp = pluginInfo.features.includes("LuaUdp");
|
||||
if (luaUdp) {
|
||||
fieldDef.dependsOn.push("factorio.enable_lua_udp");
|
||||
}
|
||||
|
||||
if (scriptCommands || savePatching || luaUdp) {
|
||||
fieldDef.validator = (enabled, config) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
@@ -806,6 +828,9 @@ export function addPluginFieldDefinitions(
|
||||
if (scriptCommands && !config.get("factorio.enable_script_commands")) {
|
||||
throw new Error(`Plugin ${pluginInfo.name} requires script commands`);
|
||||
}
|
||||
if (luaUdp && !config.get("factorio.enable_lua_udp")) {
|
||||
throw new Error(`Plugin ${pluginInfo.name} requires Lua UDP`);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export const PluginFeatureFlags = [
|
||||
"SavePatching",
|
||||
/** The plugin requires access to script commands over rcon */
|
||||
"ScriptCommands",
|
||||
/** The plugin requires UDP messaging with the Factorio server */
|
||||
"LuaUdp",
|
||||
] as const;
|
||||
|
||||
/** Used to define the plugin export in plugins */
|
||||
|
||||
@@ -66,6 +66,12 @@ describe("host/src/BaseInstancePlugin", function() {
|
||||
await assert.rejects(instancePlugin.sendRcon("a"), new Error("ref"));
|
||||
});
|
||||
});
|
||||
describe("sendUdp", function() {
|
||||
it("should send message to the instance", async function() {
|
||||
await instancePlugin.sendUdp("a");
|
||||
assert.deepEqual(instancePlugin.instance.server.udpMessages, ["a"]);
|
||||
});
|
||||
});
|
||||
describe("sendOrderedRcon", function() {
|
||||
it("should send commands in order", async function() {
|
||||
instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" });
|
||||
|
||||
@@ -240,6 +240,18 @@ describe("class Instance", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".sendUdp()", function() {
|
||||
it("should throw when Lua UDP is disabled", async function() {
|
||||
await assert.rejects(instance.sendUdp("data"), /Lua UDP is disabled/);
|
||||
assert.deepEqual(instance.server.udpMessages, []);
|
||||
});
|
||||
it("should send to the server when enabled", async function() {
|
||||
instance.config.set("factorio.enable_lua_udp", true);
|
||||
await instance.sendUdp("data");
|
||||
assert.deepEqual(instance.server.udpMessages, ["data"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("._recordPlayerJoin()", function() {
|
||||
it("should add player to playersOnline", function() {
|
||||
instance._recordPlayerJoin("player");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import dgram from "node:dgram";
|
||||
import events from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -336,6 +337,213 @@ describe("host/server", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lua UDP", function() {
|
||||
let udpServer;
|
||||
let received;
|
||||
before(async function() {
|
||||
// Stand-in for the Factorio server's --enable-lua-udp socket
|
||||
udpServer = dgram.createSocket("udp4");
|
||||
received = [];
|
||||
udpServer.on("message", (msg, rinfo) => received.push([msg, rinfo]));
|
||||
await events.once(udpServer.bind(0, "127.0.0.1"), "listening");
|
||||
server.luaUdpPort = udpServer.address().port;
|
||||
});
|
||||
after(function() {
|
||||
udpServer.close();
|
||||
});
|
||||
afterEach(function() {
|
||||
server._stopUdp();
|
||||
server._state = "init";
|
||||
});
|
||||
function sendFromGame(message) {
|
||||
udpServer.send(Buffer.from(message), server.hostUdpPort, "127.0.0.1");
|
||||
}
|
||||
function captureLogger(level) {
|
||||
const logger = server._logger;
|
||||
const lines = [];
|
||||
server._logger = { [level]: msg => lines.push(msg) };
|
||||
return { lines, restore: () => { server._logger = logger; } };
|
||||
}
|
||||
|
||||
it("should refuse to start on Factorio before 2.1.10", async function() {
|
||||
const version = server._version;
|
||||
server._version = "2.1.9";
|
||||
server.enableLuaUdp = true;
|
||||
try {
|
||||
const error = new Error("Lua UDP requires Factorio 2.1.10 or later");
|
||||
await assert.rejects(server.start("save.zip"), error);
|
||||
await assert.rejects(server.startScenario("test"), error);
|
||||
assert.equal(server._state, "init");
|
||||
} finally {
|
||||
server._version = version;
|
||||
server.enableLuaUdp = false;
|
||||
}
|
||||
});
|
||||
it("should open the socket when RCON starts", async function() {
|
||||
const startRcon = server._startRcon;
|
||||
server._startRcon = async () => {};
|
||||
server.enableLuaUdp = true;
|
||||
try {
|
||||
server._handleOutput(Buffer.from(
|
||||
" 0.500 Info RemoteCommandProcessor.cpp:133: Starting RCON interface"
|
||||
), "stdout");
|
||||
while (!server._udpSocket) {
|
||||
await wait(1);
|
||||
}
|
||||
} finally {
|
||||
server._startRcon = startRcon;
|
||||
server.enableLuaUdp = false;
|
||||
}
|
||||
assert(server.hostUdpPort > 0, "socket was not opened");
|
||||
});
|
||||
it("should not have a host port when not running", function() {
|
||||
assert.equal(server.hostUdpPort, undefined);
|
||||
});
|
||||
it("should reject sendUdp when the socket is not open", async function() {
|
||||
server._state = "running";
|
||||
await assert.rejects(server.sendUdp("data"), new Error("UDP socket is not open"));
|
||||
});
|
||||
it("should send packets to the Lua UDP port", async function() {
|
||||
await server._startUdp();
|
||||
server._state = "running";
|
||||
assert(server.hostUdpPort > 0, "hostUdpPort not set");
|
||||
|
||||
await server.sendUdp("hello");
|
||||
await server.sendUdp(Buffer.from("world"));
|
||||
while (received.length < 2) {
|
||||
await wait(1);
|
||||
}
|
||||
assert.equal(received[0][0].toString(), "hello");
|
||||
assert.equal(received[1][0].toString(), "world");
|
||||
assert.equal(received[0][1].port, server.hostUdpPort);
|
||||
});
|
||||
it("should emit udp events for packets from the game", async function() {
|
||||
await server._startUdp();
|
||||
let udpReceived = [];
|
||||
let onUdp = data => udpReceived.push(data);
|
||||
// Escaped characters in the channel name are unescaped
|
||||
server.on("udp-udp?channel", onUdp);
|
||||
let waiter = events.once(server, "udp-udp?channel");
|
||||
// Packets not from the Factorio server's port are ignored
|
||||
let other = dgram.createSocket("udp4");
|
||||
try {
|
||||
await events.once(other.bind(0, "127.0.0.1"), "listening");
|
||||
other.send(Buffer.from("udp\\x3fchannel?bad"), server.hostUdpPort, "127.0.0.1");
|
||||
sendFromGame("udp\\x3fchannel?spam?eggs");
|
||||
await waiter;
|
||||
} finally {
|
||||
other.close();
|
||||
server.off("udp-udp?channel", onUdp);
|
||||
}
|
||||
assert(Buffer.isBuffer(udpReceived[0]), "data is not a Buffer");
|
||||
assert.deepEqual(udpReceived.map(String), ["spam?eggs"]);
|
||||
});
|
||||
it("should warn on malformed packets and unhandled channels", async function() {
|
||||
await server._startUdp();
|
||||
const { lines, restore } = captureLogger("warn");
|
||||
try {
|
||||
sendFromGame("no separator");
|
||||
sendFromGame("unhandled?data");
|
||||
while (lines.length < 2) {
|
||||
await wait(1);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.deepEqual(lines, [
|
||||
'Ignoring malformed UDP packet "no separator"',
|
||||
"Warning: Unhandled udp-unhandled",
|
||||
]);
|
||||
});
|
||||
it("should invoke handlers registered with handleUdp", async function() {
|
||||
let handled = [];
|
||||
server.handleUdp("handled", async data => { handled.push(data.toString()); });
|
||||
server.handleUdp("failing", async () => { throw new Error("boom"); });
|
||||
const { lines, restore } = captureLogger("error");
|
||||
try {
|
||||
server.emit("udp-handled", Buffer.from("data"));
|
||||
server.emit("udp-failing", Buffer.from("data"));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.deepEqual(handled, ["data"]);
|
||||
assert.equal(lines.length, 1);
|
||||
assert(lines[0].startsWith("Error handling udp event:\nError: boom"), lines[0]);
|
||||
});
|
||||
it("should log errors thrown synchronously by handleUdp handlers", async function() {
|
||||
await server._startUdp();
|
||||
server.handleUdp("sync", () => { throw new Error("sync boom"); });
|
||||
const { lines, restore } = captureLogger("error");
|
||||
try {
|
||||
sendFromGame("sync?data");
|
||||
while (lines.length < 1) {
|
||||
await wait(1);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert(lines[0].startsWith("Error handling udp event:\nError: sync boom"), lines[0]);
|
||||
});
|
||||
it("should forward socket errors", async function() {
|
||||
await server._startUdp();
|
||||
let waiter = events.once(server, "error");
|
||||
server._udpSocket.emit("error", new Error("socket failed"));
|
||||
let [err] = await waiter;
|
||||
assert.equal(err.message, "socket failed");
|
||||
});
|
||||
it("should reject sendUdp when sending fails", async function() {
|
||||
await server._startUdp();
|
||||
server._state = "running";
|
||||
// Larger than the maximum size of a UDP datagram
|
||||
await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" });
|
||||
});
|
||||
it("should throw if the socket is already open", async function() {
|
||||
await server._startUdp();
|
||||
await assert.rejects(server._startUdp(), new Error("UDP socket is already open"));
|
||||
});
|
||||
it("should log an error if opening the socket fails", async function() {
|
||||
const createSocket = dgram.createSocket;
|
||||
dgram.createSocket = (...args) => {
|
||||
const socket = createSocket(...args);
|
||||
socket.bind = () => process.nextTick(() => socket.emit("error", new Error("bind failed")));
|
||||
return socket;
|
||||
};
|
||||
const { lines, restore } = captureLogger("error");
|
||||
try {
|
||||
await server._startUdp();
|
||||
} finally {
|
||||
dgram.createSocket = createSocket;
|
||||
restore();
|
||||
}
|
||||
assert.equal(server._udpSocket, null);
|
||||
assert(lines[0].startsWith("Failed to open UDP socket:\nError: bind failed"), lines[0]);
|
||||
});
|
||||
it("should close the socket when the server exits", async function() {
|
||||
await server._startUdp();
|
||||
server._server = new events.EventEmitter();
|
||||
server._state = "stopping";
|
||||
server._watchExit();
|
||||
server._server.emit("exit", 0, null);
|
||||
assert.equal(server._udpSocket, null);
|
||||
assert.equal(server._state, "init");
|
||||
});
|
||||
it("should close the socket when the server is killed for hanging", async function() {
|
||||
await server._startUdp();
|
||||
let killed = false;
|
||||
server._server = { kill: () => { killed = true; } };
|
||||
const { restore } = captureLogger("error");
|
||||
try {
|
||||
server.onStopTimeout();
|
||||
} finally {
|
||||
restore();
|
||||
server._server = null;
|
||||
}
|
||||
assert(killed, "server was not killed");
|
||||
assert.equal(server._udpSocket, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".stop()", function() {
|
||||
it("should handle server quitting on its own during stop", async function() {
|
||||
server.shutdownTimeoutMs = 20;
|
||||
|
||||
@@ -138,6 +138,10 @@ export function hasFactorio() {
|
||||
return haveFactorioInstall;
|
||||
}
|
||||
|
||||
export function getFactorioVersion() {
|
||||
return latestFactorioVersion;
|
||||
}
|
||||
|
||||
export async function get(urlPath) {
|
||||
const url = new URL("https://localhost:4443");
|
||||
url.pathname = urlPath;
|
||||
@@ -170,6 +174,7 @@ let controllerProcess;
|
||||
let hostProcess;
|
||||
let control;
|
||||
let haveFactorioInstall;
|
||||
let latestFactorioVersion;
|
||||
|
||||
const baseHostConfig = loadJSON("config-host.json");
|
||||
|
||||
@@ -356,7 +361,7 @@ before(async function() {
|
||||
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
|
||||
|
||||
const factorioVersions = (await _listFactorioVersions(factorioDir)).versions;
|
||||
const latestFactorioVersion = [...factorioVersions.values()]
|
||||
latestFactorioVersion = [...factorioVersions.values()]
|
||||
.sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0];
|
||||
haveFactorioInstall = factorioVersions.size > 0;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import path from "node:path";
|
||||
import dgram from "node:dgram";
|
||||
import events from "node:events";
|
||||
import assert from "node:assert/strict";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import fs from "node:fs/promises";
|
||||
@@ -6,7 +8,7 @@ import fs from "node:fs/promises";
|
||||
import { slowTest, testMatrix } from "../common.js";
|
||||
import {
|
||||
execCtl, execCtlProcess, sendRcon, getControl,
|
||||
requiresFactorio, hasFactorio, instancesDir,
|
||||
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir,
|
||||
} from "./index.js";
|
||||
|
||||
const instId = 48;
|
||||
@@ -21,6 +23,19 @@ const requireApi = [
|
||||
"or package.loaded['__level__/modules/clusterio/api.lua']", // 2.0.0
|
||||
].join(" ");
|
||||
|
||||
// Lua UDP needs Factorio 2.1.10, see docs/configuration.md
|
||||
function hasLuaUdp() {
|
||||
return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.1.10");
|
||||
}
|
||||
|
||||
async function freeUdpPort() {
|
||||
const socket = dgram.createSocket("udp4");
|
||||
await events.once(socket.bind(0, "127.0.0.1"), "listening");
|
||||
const port = socket.address().port;
|
||||
socket.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
function getUser(name) {
|
||||
return getControl().send(new lib.UserGetRequest(name));
|
||||
}
|
||||
@@ -105,12 +120,16 @@ describe("Clusterio Instance", function() {
|
||||
}
|
||||
}
|
||||
describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() {
|
||||
let luaUdpPort;
|
||||
before(async function() {
|
||||
this.timeout(20000);
|
||||
// Create and start a patched save
|
||||
await execCtl(`${instSetConfig} factorio.enable_whitelist true`);
|
||||
await execCtl(`${instSetConfig} factorio.enable_save_patching ${savePatchingEnabled}`);
|
||||
await execCtl(`${instSetConfig} factorio.enable_script_commands ${scriptCommandsEnabled}`);
|
||||
await execCtl(`${instSetConfig} factorio.enable_lua_udp ${hasLuaUdp()}`);
|
||||
luaUdpPort = await freeUdpPort();
|
||||
await execCtl(`${instSetConfig} factorio.lua_udp_port ${luaUdpPort}`);
|
||||
await execCtl(`instance save create ${instName}`);
|
||||
await execCtl(`instance start ${instName}`);
|
||||
});
|
||||
@@ -125,6 +144,46 @@ describe("Clusterio Instance", function() {
|
||||
assert.equal(res === "Disabled\n", scriptCommandsEnabled);
|
||||
});
|
||||
});
|
||||
if (savePatchingEnabled) {
|
||||
describe("Lua UDP", function() {
|
||||
it("should drop send_udp before the module is initialised", async function() {
|
||||
// Mods can call send_udp from on_init, before the first tick sets up script data
|
||||
await allowSendingScriptCommands();
|
||||
const res = await sendRcon(instId,
|
||||
`/sc ${requireApi} local data = storage or global ` +
|
||||
"local saved = data.clusterio data.clusterio = nil " +
|
||||
"local module_ok = pcall(api.send_udp, 'early', 'data') " +
|
||||
"local mod_ok, port = pcall(remote.call, 'clusterio_api', 'get_host_udp_port') " +
|
||||
"data.clusterio = saved " +
|
||||
"rcon.print(tostring(module_ok) .. ' ' .. tostring(mod_ok) .. ' ' .. tostring(port))"
|
||||
);
|
||||
assert.equal(res, "true true nil\n");
|
||||
});
|
||||
it("should dispatch packets sent to the game", async function() {
|
||||
if (!hasLuaUdp()) {
|
||||
this.skip();
|
||||
}
|
||||
await allowSendingScriptCommands();
|
||||
await sendRcon(instId,
|
||||
"/sc script.on_event(defines.events.on_udp_packet_received, " +
|
||||
"function(event) storage.udp_test = event.payload end)"
|
||||
);
|
||||
const socket = dgram.createSocket("udp4");
|
||||
let payload;
|
||||
try {
|
||||
for (let i = 0; i < 10 && payload !== "hello\n"; i++) {
|
||||
socket.send("hello", luaUdpPort, "127.0.0.1");
|
||||
await lib.wait(10);
|
||||
// The server does not tick without players, commands make it tick
|
||||
payload = await sendRcon(instId, "/sc rcon.print(tostring(storage.udp_test))");
|
||||
}
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
assert.equal(payload, "hello\n");
|
||||
});
|
||||
});
|
||||
}
|
||||
describe("Player events", function() {
|
||||
if (savePatchingEnabled) {
|
||||
// IPC expects save patching to be enabled
|
||||
|
||||
@@ -125,6 +125,8 @@ describe("Integration of host/src/server", function() {
|
||||
let mapPath = server.writePath("saves", "test.zip");
|
||||
await assert.doesNotReject(fs.access(mapPath), "save is missing");
|
||||
|
||||
// Lua UDP needs Factorio 2.1.10, see docs/configuration.md
|
||||
server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.1.10");
|
||||
await server.start("test.zip");
|
||||
});
|
||||
});
|
||||
@@ -159,6 +161,56 @@ describe("Integration of host/src/server", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lua UDP", function() {
|
||||
it("emits udp events for packets sent from the game", async function() {
|
||||
slowTest(this);
|
||||
if (!server.enableLuaUdp) {
|
||||
this.skip();
|
||||
}
|
||||
log("Lua UDP from game");
|
||||
|
||||
assert(server.hostUdpPort > 0, "host UDP socket is not open");
|
||||
let waiter = events.once(server, "udp-test_channel");
|
||||
await server.sendRcon(`/sc helpers.send_udp(${server.hostUdpPort}, "test_channel?hello", 0)`);
|
||||
let [data] = await waiter;
|
||||
assert.equal(data.toString(), "hello");
|
||||
});
|
||||
it("delivers packets sent with sendUdp to the game", async function() {
|
||||
slowTest(this);
|
||||
if (!server.enableLuaUdp) {
|
||||
this.skip();
|
||||
}
|
||||
log("Lua UDP to game");
|
||||
|
||||
// Poll from on_tick like the clusterio module does
|
||||
await server.sendRcon(
|
||||
"/sc script.on_event(defines.events.on_udp_packet_received, " +
|
||||
"function(event) print('udp:' .. event.payload) end) " +
|
||||
"script.on_nth_tick(1, function() helpers.recv_udp(0) end)"
|
||||
);
|
||||
let pass = false;
|
||||
function filter(output) {
|
||||
if (output.message === "udp:hello") {
|
||||
pass = true;
|
||||
}
|
||||
}
|
||||
server.on("output", filter);
|
||||
|
||||
await server.sendUdp("hello");
|
||||
// The server does not tick without players, commands make it tick
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await server.sendRcon("/sc rcon.print('tick')");
|
||||
await lib.wait(10);
|
||||
if (pass) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
server.off("output", filter);
|
||||
assert(pass, "server did not output the packet payload");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".stop()", function() {
|
||||
it("stops the server", async function() {
|
||||
slowTest(this);
|
||||
|
||||
@@ -340,6 +340,35 @@ describe("lib/config/definitions/validators", function() {
|
||||
/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 };
|
||||
|
||||
@@ -95,6 +95,11 @@ export class MockServer extends events.EventEmitter {
|
||||
reset() {
|
||||
this.rconCommands = [];
|
||||
this.rconCommandResults = new Map();
|
||||
this.udpMessages = [];
|
||||
}
|
||||
|
||||
async sendUdp(message) {
|
||||
this.udpMessages.push(message);
|
||||
}
|
||||
|
||||
async sendRcon(command) {
|
||||
@@ -144,6 +149,10 @@ export class MockInstance extends lib.Link {
|
||||
async sendRcon(command, expectEmpty, plugin) {
|
||||
return await this.server.sendRcon(command);
|
||||
}
|
||||
|
||||
async sendUdp(message) {
|
||||
await this.server.sendUdp(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockHost extends lib.Link {
|
||||
|
||||
Reference in new issue
Block a user