diff --git a/clusterctl.js b/clusterctl.js index b1ae99bd..fe3ddc9b 100644 --- a/clusterctl.js +++ b/clusterctl.js @@ -383,6 +383,7 @@ commands.push(new Command({ yargs.options({ "instance": { describe: "Instance to start", nargs: 1, type: "string", demandOption: true }, "save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" }, + "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, }); }], handler: async function(args, control) { @@ -395,6 +396,24 @@ commands.push(new Command({ }, })); +commands.push(new Command({ + definition: ["load-scenario", "Start instance by loading a scenario", (yargs) => { + yargs.options({ + "instance": { describe: "Instance to start", nargs: 1, type: "string", demandOption: true }, + "scenario": { describe: "Scenario to load", nargs: 1, type: "string" }, + "keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false }, + }); + }], + handler: async function(args, control) { + let instanceId = await resolveInstance(control, args.instance); + await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] }); + let response = await link.messages.loadScenario.send(control, { + instance_id: instanceId, + scenario: args.scenario || null, + }); + }, +})); + commands.push(new Command({ definition: ["stop-instance", "Stop instance", (yargs) => { yargs.options({ @@ -735,10 +754,12 @@ async function startControl() { if (commands.has(commandName)) { let command = commands.get(commandName); + let keepOpen = Boolean(args.keepOpen); try { await command.run(args, control); } catch (err) { + keepOpen = false; if (err instanceof errors.CommandError) { console.error(`Error running command: ${err.message}`); process.exitCode = 1; @@ -752,7 +773,9 @@ async function startControl() { } } finally { - await control.shutdown(); + if (!keepOpen) { + await control.shutdown(); + } } } } diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3e835624..ad4084b0 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -9,6 +9,10 @@ based on the built in library to Factorio and requires scenarios to be coded specifically for it. Mods on the other hand work without modifications. +This can be disabled with the `factorio.enable_save_patching` config on +a per instance basis, however disabling it means that modules are not +loaded into the game and most plugins will not function. + 1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 94b32f17..83b7fdf4 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -195,6 +195,20 @@ where appropriate. See [Configuration System](configuration-system.md) for more details on how this system works. +### Handling Invalid Configuration + +If the plugin requires a certain feature to be enabled to function it +should throw an error during init if this is not the case. The most +common such feature is the save patching, which can be disabled to run +vanilla or scenarios not compatible with Clusterio. For example: + + async init() { + if (!this.instance.config.get("factorio.enable_save_patching")) { + throw new Error("foo_frobber plugin requires save patching."); + } + } + + Communicating with Factorio --------------------------- diff --git a/lib/config/definitions.js b/lib/config/definitions.js index 02f2ee6d..e098c66b 100644 --- a/lib/config/definitions.js +++ b/lib/config/definitions.js @@ -239,6 +239,12 @@ FactorioGroup.define({ type: "string", optional: true, }); +FactorioGroup.define({ + name: "enable_save_patching", + description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.", + type: "boolean", + initial_value: true, +}); FactorioGroup.define({ name: "settings", description: "Settings overridden in server-settings.json", diff --git a/lib/link/messages.js b/lib/link/messages.js index fd0d0876..9a795317 100644 --- a/lib/link/messages.js +++ b/lib/link/messages.js @@ -418,6 +418,16 @@ messages.createSave = new Request({ forwardTo: "instance", }); +messages.loadScenario = new Request({ + type: "load_scenario", + links: ["control-master", "master-slave", "slave-instance"], + permission: "core.instance.load_scenario", + forwardTo: "instance", + requestProperties: { + "scenario": { type: "string" }, + }, +}); + messages.exportData = new Request({ type: "export_data", links: ["control-master", "master-slave", "slave-instance"], diff --git a/lib/users.js b/lib/users.js index 11ef3fde..1ad2a8bf 100644 --- a/lib/users.js +++ b/lib/users.js @@ -318,6 +318,11 @@ definePermission({ title: "Start instance", description: "Start instances.", }); +definePermission({ + name: "core.instance.load_scenario", + title: "Load scenario", + description: "Start instances by loading a scenario.", +}); definePermission({ name: "core.instance.stop", title: "Stop instance", diff --git a/plugins/research_sync/instance.js b/plugins/research_sync/instance.js index d87b0b29..4a9f56c0 100644 --- a/plugins/research_sync/instance.js +++ b/plugins/research_sync/instance.js @@ -13,6 +13,10 @@ function unexpectedError(err) { class InstancePlugin extends plugin.BaseInstancePlugin { async init() { + if (!this.instance.config.get("factorio.enable_save_patching")) { + throw new Error("research_sync plugin requires save patching."); + } + this.instance.server.on("ipc-research_sync:contribution", (tech) => { this.researchContribution(tech).catch(unexpectedError); }); diff --git a/plugins/statistics_exporter/instance.js b/plugins/statistics_exporter/instance.js index b76ae99b..2e179d1f 100644 --- a/plugins/statistics_exporter/instance.js +++ b/plugins/statistics_exporter/instance.js @@ -22,6 +22,12 @@ const instanceGameFlowStatistics = new Gauge( class InstancePlugin extends plugin.BaseInstancePlugin { + async init() { + if (!this.instance.config.get("factorio.enable_save_patching")) { + throw new Error("statistics_exporter plugin requires save patching."); + } + } + async onMetrics() { let string = await this.instance.server.sendRcon("/sc statistics_exporter.export()"); let stats; diff --git a/slave.js b/slave.js index 41466a2f..0ce609ec 100644 --- a/slave.js +++ b/slave.js @@ -288,17 +288,11 @@ class Instance extends link.Link{ } /** - * Prepare a save for starting + * Prepare instance for starting * - * Writes server settings, links mods, creates a new save if no save is - * passed, and patches it with modules. - * - * @param {String|null} saveName - - * Save to prepare from the instance saves directory. Creates a new - * save if null. - * @returns {String} Name of the save prepared. + * Writes server settings and links mods. */ - async prepare(saveName) { + async prepare() { console.log("Clusterio | Writing server-settings.json"); await this.writeServerSettings(); @@ -315,7 +309,19 @@ class Instance extends link.Link{ }catch(e){} await symlinkMods(this, "sharedMods", console); + } + /** + * Prepare a save for starting + * + * Creates a new save if no save is passed and patches it with modules. + * + * @param {String|null} saveName - + * Save to prepare from the instance saves directory. Creates a new + * save if null. + * @returns {String} Name of the save prepared. + */ + async prepareSave(saveName) { // Use latest save if no save was specified if (saveName === null) { saveName = await fileOps.getNewestFile( @@ -330,6 +336,10 @@ class Instance extends link.Link{ saveName = "world.zip"; } + if (!this.config.get("factorio.enable_save_patching")) { + return saveName; + } + // Patch save with lua modules from plugins console.log("Clusterio | Patching save"); @@ -409,8 +419,33 @@ class Instance extends link.Link{ this.server.on("exit", () => this.notifyExit()); await this.server.start(saveName); - await this.server.disableAchievements(); - await this.updateInstanceData(); + + if (this.config.get("factorio.enable_save_patching")) { + await this.server.disableAchievements(); + await this.updateInstanceData(); + } + + await plugin.invokeHook(this.plugins, "onStart"); + + this._running = true; + link.messages.instanceStarted.send(this, { instance_id: this.config.get("instance.id") }); + } + + /** + * Start Factorio server by loading a scenario + * + * Launches the Factorio server for this instance with the given + * scenario. + * + * @param {String} scenario - Name of scenario to load. + */ + async startScenario(scenario) { + this.server.on("rcon-ready", () => { + console.log("Clusterio | RCON connection established"); + }); + + this.server.on("exit", () => this.notifyExit()); + await this.server.startScenario(scenario); await plugin.invokeHook(this.plugins, "onStart"); @@ -483,14 +518,36 @@ class Instance extends link.Link{ async startInstanceRequestHandler(message) { let saveName = message.data.save; try { - saveName = await this.prepare(saveName); + await this.prepare(); + saveName = await this.prepareSave(saveName); } catch (err) { this.notifyExit(); throw err; } try { - await this.start(saveName, this.config, this.socket); + await this.start(saveName); + } catch (err) { + await this.stop(); + throw err; + } + } + + async loadScenarioRequestHandler(message) { + if (this.config.get("factorio.enable_save_patching")) { + this.notifyExit(); + throw new errors.RequestError("Load scenario cannot be used with save patching enabled"); + } + + try { + await this.prepare(); + } catch (err) { + this.notifyExit(); + throw err; + } + + try { + await this.startScenario(message.data.scenario); } catch (err) { await this.stop(); throw err; @@ -945,6 +1002,12 @@ class Slave extends link.Link { return await request.send(instanceConnection, message.data); } + async loadScenarioRequestHandler(message, request) { + let instanceId = message.data.instance_id; + let instanceConnection = await this._connectInstance(instanceId); + return await request.send(instanceConnection, message.data); + } + async createSaveRequestHandler(message, request) { let instanceId = message.data.instance_id; let instanceConnection = await this._connectInstance(instanceId); diff --git a/test/mock.js b/test/mock.js index c919d57c..bc77c051 100644 --- a/test/mock.js +++ b/test/mock.js @@ -68,6 +68,7 @@ class MockInstance extends link.Link { this.config = { get: (name) => { if (name === "instance.id") { return 7357; } + if (name === "factorio.enable_save_patching") { return true; } throw Error("Not implemented"); }, };