diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 6a943554..b1d86f90 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -5,6 +5,8 @@ The save patching is based on the built in [event_handler](https://github.com/wu 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. +To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it. +Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario. 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 save patching instead. diff --git a/packages/host/README.md b/packages/host/README.md index fa510ca2..541c67f8 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -99,6 +99,31 @@ See docs/configuration.md in the main repositiory for the available configuratio Shows the value for a single config entry. +### `create-scenario ` + +Create a scenario with the Clusterio modules patched into it and write it to `output`. +This is the same patching that is applied to saves when an instance starts, see docs/how-it-works.md in the main repository. +The result can be used as a regular scenario outside of Clusterio by copying it to the `scenarios` folder of Factorio or into a mod. +The IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected. + +By default the freeplay scenario from the latest Factorio install in `host.factorio_directory` is patched with the modules from all plugins that are enabled in the host config. + + * `--scenario ` Scenario directory, zipped scenario or save to patch instead of freeplay. + This has the same restrictions as save patching, the scenario must either use the event_handler library and have been seen by Clusterio before, or already have been patched by Clusterio. + + * `--factorio-version ` Factorio version to take the freeplay scenario from. + Defaults to `latest`. + + * `--plugins ...` Only patch in modules from the given plugins. + + * `--zip` Write the scenario as a zip file instead of a directory. + Factorio does not load zipped scenarios, extract it before use. + +For example, creating a scenario with only the inventory_sync module: + + npx clusteriohost create-scenario ~/.factorio/scenarios/inventory_sync --plugins inventory_sync + + #### `config list` Lists up all configuration entries with their currently configured values. diff --git a/packages/host/host.ts b/packages/host/host.ts index 24b880ba..cd050cb0 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -24,6 +24,7 @@ import * as lib from "@clusterio/lib"; import { ConsoleTransport, levels, logger } from "@clusterio/lib"; import Host from "./src/Host.js"; +import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario.js"; let host: Host | undefined; @@ -119,6 +120,11 @@ async function startHost() { .option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false }) .command("plugin", "Manage available plugins", lib.pluginCommand) .command("config", "Manage Host config", lib.configCommand) + .command( + "create-scenario ", + "Create a scenario patched with modules for use outside Clusterio", + createScenarioCommand, + ) .command("run", "Run host", yargs => { yargs.option("can-restart", { type: "boolean", nargs: 0, default: false, @@ -185,6 +191,11 @@ async function startHost() { return; } + if (command === "create-scenario") { + await handleCreateScenarioCommand(args, hostConfig, pluginInfos); + return; + } + // From this point on the host should run await hostConfigLock.acquire(); diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index c06673c7..696d1da8 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -10,7 +10,7 @@ const execAsync = util.promisify(exec); import * as lib from "@clusterio/lib"; import { FactorioServer } from "./server.js"; -import { SaveModule, patch } from "./patch.js"; +import { loadModules, patch } from "./patch.js"; import { exportData } from "./export.js"; import type Host from "./Host.js"; import { BaseInstancePlugin, InstanceHooks } from "./BaseInstancePlugin.js"; @@ -1007,29 +1007,7 @@ end`.replace(/\r?\n/g, " "); // Patch save with lua modules from plugins this.logger.verbose("Patching save"); - // Find plugin modules to patch in - let modules: Map = new Map(); - for (const pluginInfo of this.loadedPlugins) { - let module = await SaveModule.fromPluginInfo(pluginInfo); - if (!module) { - continue; - } - modules.set(module.info.name, module); - } - - // Find stand alone modules to load - // XXX for now only the included clusterio module is loaded - let modulesDirectory = path.join(import.meta.dirname, "..", "..", "..", "modules"); - for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) { - if (entry.isDirectory()) { - if (modules.has(entry.name)) { - throw new Error(`Module with name ${entry.name} already exists in a plugin`); - } - let module = await SaveModule.fromDirectory(path.join(modulesDirectory, entry.name)); - modules.set(module.info.name, module); - } - } - + let modules = await loadModules(this.loadedPlugins); await patch(this.path("saves", saveName), [...modules.values()]); return saveName; } diff --git a/packages/host/src/create_scenario.ts b/packages/host/src/create_scenario.ts new file mode 100644 index 00000000..6a49d168 --- /dev/null +++ b/packages/host/src/create_scenario.ts @@ -0,0 +1,171 @@ +// Implementation of the create-scenario command +import fs from "node:fs/promises"; +import os from "node:os"; +import JSZip from "jszip"; +import path from "path"; + +import * as lib from "@clusterio/lib"; +import { logger } from "@clusterio/lib"; + +import { loadModules, patch, SaveModule } from "./patch.js"; +import { findVersion } from "./server.js"; + + +/** + * Create a scenario patched with the given modules + * + * Runs the save patching on the scenario at scenarioPath and writes the + * result to outputPath. The scenario can be a directory, a zipped + * scenario or a save. + * + * @param scenarioPath - Path to the scenario to patch. + * @param outputPath - Path to write the patched scenario to. + * @param modules - Description of the modules to patch. + * @param zip - Write a zip file instead of a directory. + */ +export async function createScenario( + scenarioPath: string, + outputPath: string, + modules: SaveModule[], + zip = false, +) { + try { + await fs.stat(outputPath); + throw new Error(`${outputPath} already exists`); + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-scenario-")); + try { + const tempPath = path.join(tempDir, "scenario.zip"); + if ((await fs.stat(scenarioPath)).isDirectory()) { + await zipDirectory(scenarioPath, tempPath); + } else { + await fs.copyFile(scenarioPath, tempPath); + } + + await patch(tempPath, modules); + if (zip) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.copyFile(tempPath, outputPath); + } else { + await extractZip(tempPath, outputPath); + } + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function zipDirectory(dirPath: string, zipPath: string) { + const root = path.basename(path.resolve(dirPath)); + const zip = new JSZip(); + let dirs: [string, string][] = [[dirPath, root]]; + while (dirs.length) { + let [dir, relativeDir] = dirs.pop()!; + for (let entry of await fs.readdir(dir, { withFileTypes: true })) { + let fsPath = path.join(dir, entry.name); + let relativePath = path.posix.join(relativeDir, entry.name); + if (entry.isFile()) { + zip.file(relativePath, await fs.readFile(fsPath)); + } else if (entry.isDirectory()) { + dirs.push([fsPath, relativePath]); + } + } + } + await fs.writeFile(zipPath, await zip.generateAsync({ type: "nodebuffer" })); +} + +async function extractZip(zipPath: string, dirPath: string) { + const zip = await JSZip.loadAsync(await fs.readFile(zipPath)); + const root = lib.findRoot(zip); + for (let file of Object.values(zip.files)) { + if (file.dir || !file.name.startsWith(`${root}/`)) { + continue; + } + const fsPath = path.join(dirPath, file.name.slice(root.length + 1)); + await fs.mkdir(path.dirname(fsPath), { recursive: true }); + await fs.writeFile(fsPath, await file.async("nodebuffer")); + } +} + +/** + * Yargs create-scenario command + * + * @param yargs - yargs command builder. + */ +export function createScenarioCommand(yargs: any) { + yargs + .positional("output", { + describe: "Path to write the scenario to", + type: "string", + }) + .option("zip", { + type: "boolean", nargs: 0, default: false, + describe: "Write the scenario as a zip file instead of a directory", + }) + .option("scenario", { + nargs: 1, + describe: + "Path to a scenario directory, zipped scenario or save to patch. " + + "Defaults to the freeplay scenario of the Factorio install in host.factorio_directory", + type: "string", + }) + .option("factorio-version", { + nargs: 1, + describe: "Factorio version to take the freeplay scenario from", + default: "latest", + type: "string", + }) + .option("plugins", { + describe: "Plugins to patch modules from, defaults to all plugins enabled in the host config", + type: "array", + }) + ; +} + +/** + * Handle yargs create-scenario command + * + * @param args - yargs args object. + * @param hostConfig - Host config. + * @param pluginInfos - Plugins available to the host. + */ +export async function handleCreateScenarioCommand( + args: Record, + hostConfig: lib.HostConfig, + pluginInfos: lib.PluginNodeEnvInfo[], +) { + let selected: lib.PluginNodeEnvInfo[]; + if (args.plugins) { + selected = (args.plugins as string[]).map(name => { + const pluginInfo = pluginInfos.find(info => info.name === name); + if (!pluginInfo) { + throw new Error(`Plugin ${name} is not in the plugin list`); + } + return pluginInfo; + }); + } else { + selected = pluginInfos.filter( + info => info.instanceEntrypoint && hostConfig.get(`${info.name}.load_plugin`) + ); + } + + let scenarioPath = args.scenario as string | undefined; + if (scenarioPath === undefined) { + const factorioDir = hostConfig.get("host.factorio_directory"); + const version = args.factorioVersion as lib.TargetVersion; + if (!lib.isTargetVersion(version)) { + throw new Error(`Invalid Factorio version ${version}`); + } + const [dataDir] = await findVersion(factorioDir, version); + scenarioPath = path.join(dataDir, "base", "scenarios", "freeplay"); + } + + const modules = await loadModules(selected); + logger.info(`Patching ${scenarioPath} with modules ${[...modules.keys()].join(", ")}`); + await createScenario(scenarioPath, args.output as string, [...modules.values()], Boolean(args.zip)); + logger.info(`Created scenario ${args.output}`); +} diff --git a/packages/host/src/patch.ts b/packages/host/src/patch.ts index 2904a8b9..f4ed658c 100644 --- a/packages/host/src/patch.ts +++ b/packages/host/src/patch.ts @@ -145,6 +145,40 @@ export class SaveModule { } } +/** + * Load the modules to patch into a save + * + * Collects the modules provided by the given plugins together with the + * modules bundled with the host package. + * + * @param pluginInfos - Plugins to load modules from. + * @returns Map of module name to module. + */ +export async function loadModules(pluginInfos: Iterable) { + let modules: Map = new Map(); + for (let pluginInfo of pluginInfos) { + let module = await SaveModule.fromPluginInfo(pluginInfo); + if (!module) { + continue; + } + modules.set(module.info.name, module); + } + + // Find stand alone modules to load + // XXX for now only the included clusterio module is loaded + let modulesDirectory = path.join(import.meta.dirname, "..", "..", "..", "modules"); + for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (modules.has(entry.name)) { + throw new Error(`Module with name ${entry.name} already exists in a plugin`); + } + let module = await SaveModule.fromDirectory(path.join(modulesDirectory, entry.name)); + modules.set(module.info.name, module); + } + } + return modules; +} + export class PatchInfo { static currentVersion = 1; diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 0057446d..943660be 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -181,9 +181,11 @@ async function downloadAndExtractTar(url: string, targetDir: string) { * Version to look for, supports the special value "latest" for the * latest version available. * @returns Array with path to data dir and version found. - * @internal */ -async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> { +export async function findVersion( + factorioDir: string, + targetVersion: lib.TargetVersion, +): Promise<[string, lib.FullVersion]> { // Check if this is a direct installation dir const directVersion = await getFactorioVersion(factorioDir); if (directVersion !== null) { @@ -1609,7 +1611,6 @@ export class FactorioServer extends events.EventEmitter { // For testing only export const _getFactorioVersion = getFactorioVersion; export const _versionOrder = versionOrder; -export const _findVersion = findVersion; export const _listFactorioVersions = listFactorioVersions; export const _downloadAndExtractZip = downloadAndExtractZip; export const _downloadAndExtractTar = downloadAndExtractTar; diff --git a/test/host/create_scenario.js b/test/host/create_scenario.js new file mode 100644 index 00000000..2324c198 --- /dev/null +++ b/test/host/create_scenario.js @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import JSZip from "jszip"; +import path from "node:path"; + +import * as lib from "@clusterio/lib"; +import { + createScenario, createScenarioCommand, handleCreateScenarioCommand, +} from "@clusterio/host/dist/node/src/create_scenario.js"; +import { SaveModule, PatchInfo } from "@clusterio/host/dist/node/src/patch.js"; + + +describe("host/create_scenario", function() { + const tempDir = path.join("temp", "test", "create_scenario"); + const patchInfo = new PatchInfo(0, new lib.ModuleInfo("test", "1.0.0", [], ["scenario"]), []); + const scenarioFiles = new Map([ + ["scenario.lua", "-- scenario\n"], + ["description.json", "{}\n"], + ["locale/en/test.cfg", "test=Test\n"], + ["clusterio.json", JSON.stringify(patchInfo)], + ]); + const module = new SaveModule( + new lib.ModuleInfo("spam", "1.0.0", ["spam.lua"]), + new Map([["modules/spam/spam.lua", Buffer.from("return {}\n")]]), + ); + + async function readDir(dirPath) { + const files = new Map(); + for (const entry of await fs.readdir(dirPath, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) { + const fsPath = path.join(entry.parentPath, entry.name); + files.set(path.relative(dirPath, fsPath).split(path.sep).join("/"), await fs.readFile(fsPath, "utf8")); + } + } + return files; + } + + function checkOutput(files) { + assert(files.get("control.lua").includes('event_handler.add_lib(require("modules/spam/spam"))')); + assert.equal(JSON.parse(files.get("clusterio.json")).patch_number, 1); + assert.equal(files.get("modules/spam/spam.lua"), "return {}\n"); + for (const name of ["scenario.lua", "description.json", "locale/en/test.cfg"]) { + assert.equal(files.get(name), scenarioFiles.get(name)); + } + assert.equal(files.size, scenarioFiles.size + 2); + } + + before(async function() { + await fs.rm(tempDir, { recursive: true, force: true }); + await fs.mkdir(path.join(tempDir, "scenario"), { recursive: true }); + const zip = new JSZip(); + for (const [name, content] of scenarioFiles) { + const fsPath = path.join(tempDir, "scenario", name); + await fs.mkdir(path.dirname(fsPath), { recursive: true }); + await fs.writeFile(fsPath, content); + zip.file(`scenario/${name}`, content); + } + await fs.writeFile(path.join(tempDir, "scenario.zip"), await zip.generateAsync({ type: "nodebuffer" })); + }); + + it("should patch a scenario directory", async function() { + const output = path.join(tempDir, "from_dir"); + await createScenario(path.join(tempDir, "scenario"), output, [module]); + checkOutput(await readDir(output)); + }); + + it("should patch a zipped scenario", async function() { + const output = path.join(tempDir, "from_zip"); + await createScenario(path.join(tempDir, "scenario.zip"), output, [module]); + checkOutput(await readDir(output)); + }); + + it("should write a zip file when asked to", async function() { + const output = path.join(tempDir, "zipped.zip"); + await createScenario(path.join(tempDir, "scenario"), output, [module], true); + const zip = await JSZip.loadAsync(await fs.readFile(output)); + const files = new Map(); + for (const file of Object.values(zip.files)) { + if (!file.dir) { + files.set(file.name.replace(/^scenario\//, ""), await file.async("string")); + } + } + checkOutput(files); + }); + + it("should not modify the input scenario", async function() { + const files = await readDir(path.join(tempDir, "scenario")); + assert.deepEqual(files, scenarioFiles); + }); + + it("should throw if the output already exists", async function() { + const output = path.join(tempDir, "from_dir"); + await assert.rejects( + createScenario(path.join(tempDir, "scenario"), output, [module]), + new Error(`${output} already exists`), + ); + }); + + it("should throw on unknown scenario", async function() { + await fs.mkdir(path.join(tempDir, "unknown")); + await fs.writeFile(path.join(tempDir, "unknown", "control.lua"), "-- unknown\n"); + await assert.rejects( + createScenario(path.join(tempDir, "unknown"), path.join(tempDir, "from_unknown"), [module]), + new Error("Unable to patch save, unknown scenario (3acc3be3861144e55604f5ac2f2555071885ebc4)") + ); + }); + + describe("createScenarioCommand()", function() { + it("should define the output positional and options", function() { + const positionals = []; + const options = []; + const yargs = { + positional(name) { positionals.push(name); return yargs; }, + option(name) { options.push(name); return yargs; }, + }; + createScenarioCommand(yargs); + assert.deepEqual(positionals, ["output"]); + assert.deepEqual(options, ["zip", "scenario", "factorio-version", "plugins"]); + }); + }); + + describe("handleCreateScenarioCommand()", function() { + const factorioDir = path.join(tempDir, "factorio"); + const scenario = path.join(tempDir, "scenario"); + let hostConfig; + let pluginInfos; + before(async function() { + // Fake install with the test scenario as freeplay + await fs.cp(path.join("test", "file", "factorio", "0.1.2"), factorioDir, { recursive: true }); + const freeplay = path.join(factorioDir, "data", "base", "scenarios", "freeplay"); + await fs.cp(scenario, freeplay, { recursive: true }); + pluginInfos = await lib.loadPluginInfos(new Map([ + ["research_sync", path.resolve("plugins/research_sync")], + ["test_plugin", path.resolve("test/file/test_plugin")], + ])); + lib.addPluginConfigFields(pluginInfos); + hostConfig = new lib.HostConfig("host", { "host.factorio_directory": factorioDir }); + }); + + it("should patch freeplay with the enabled plugins by default", async function() { + const output = path.join(tempDir, "default"); + await handleCreateScenarioCommand({ output, factorioVersion: "latest" }, hostConfig, pluginInfos); + const files = await readDir(output); + assert.equal(files.get("scenario.lua"), scenarioFiles.get("scenario.lua")); + const info = JSON.parse(files.get("clusterio.json")); + assert.deepEqual(info.modules.map(m => m.name), ["clusterio", "research_sync"]); + }); + + it("should skip plugins disabled in the host config", async function() { + const output = path.join(tempDir, "disabled"); + hostConfig.set("research_sync.load_plugin", false); + try { + await handleCreateScenarioCommand({ output, factorioVersion: "latest" }, hostConfig, pluginInfos); + } finally { + hostConfig.set("research_sync.load_plugin", true); + } + const info = JSON.parse((await readDir(output)).get("clusterio.json")); + assert.deepEqual(info.modules.map(m => m.name), ["clusterio"]); + }); + + it("should use the given scenario and plugins", async function() { + const output = path.join(tempDir, "selected"); + await handleCreateScenarioCommand( + { output, scenario, plugins: ["research_sync"] }, hostConfig, pluginInfos + ); + const info = JSON.parse((await readDir(output)).get("clusterio.json")); + assert.deepEqual(info.modules.map(m => m.name), ["clusterio", "research_sync"]); + }); + + it("should throw on unknown plugin", async function() { + await assert.rejects( + handleCreateScenarioCommand( + { output: path.join(tempDir, "unknown_plugin"), scenario, plugins: ["nope"] }, + hostConfig, + pluginInfos, + ), + new Error("Plugin nope is not in the plugin list") + ); + }); + + it("should throw on invalid Factorio version", async function() { + await assert.rejects( + handleCreateScenarioCommand( + { output: path.join(tempDir, "bad_version"), factorioVersion: "1..2" }, hostConfig, pluginInfos + ), + new Error("Invalid Factorio version 1..2") + ); + }); + }); +}); diff --git a/test/host/patch.js b/test/host/patch.js index 1e11aaca..8262e5b6 100644 --- a/test/host/patch.js +++ b/test/host/patch.js @@ -225,6 +225,41 @@ describe("host/patch", function() { }); }); + 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", path.resolve("test/file/test_plugin")]]) + ); + 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 zip = new JSZip(); diff --git a/test/host/server.js b/test/host/server.js index dd4789dc..c8965ed6 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -41,26 +41,26 @@ describe("host/server", function() { describe("direct install", function() { it("should search given directory for latest Factorio install", async function() { const installDir = path.join("test", "file", "factorio", "0.1.2"); - const [dir, version] = await hostServer._findVersion(installDir, "latest"); + const [dir, version] = await hostServer.findVersion(installDir, "latest"); assert.equal(dir, path.join(installDir, "data")); assert.equal(version, "0.1.2"); }); it("should search given directory for given Factorio install", async function() { const installDir = path.join("test", "file", "factorio", "0.1.1"); - const [dir, version] = await hostServer._findVersion(installDir, "0.1.1"); + const [dir, version] = await hostServer.findVersion(installDir, "0.1.1"); assert.equal(dir, path.join(installDir, "data")); assert.equal(version, "0.1.1"); }); it("should search given directory for partly given Factorio install", async function() { const installDir = path.join("test", "file", "factorio", "0.1.2"); - const [dir, version] = await hostServer._findVersion(installDir, "0.1"); + const [dir, version] = await hostServer.findVersion(installDir, "0.1"); assert.equal(dir, path.join(installDir, "data")); assert.equal(version, "0.1.2"); }); it("should reject if the version does not match", async function() { let installDir = path.join("test", "file", "factorio", "0.1.1"); await assert.rejects( - hostServer._findVersion(installDir, "0.1.2"), + hostServer.findVersion(installDir, "0.1.2"), new Error( `Unable to find Factorio version 0.1.2: ${installDir} is a direct (single-version) ` + `install of 0.1.1. Use a versioned layout, where ${installDir} contains a subdirectory ` + @@ -73,33 +73,33 @@ describe("host/server", function() { it("should reject if no factorio install with the given version was found", async function() { let installDir = path.join("test", "file", "factorio"); await assert.rejects( - hostServer._findVersion(installDir, "0.1.3"), + hostServer.findVersion(installDir, "0.1.3"), new Error("Unable to find Factorio version 0.1.3") ); }); it("should search given directory for given Factorio install", async function() { const installDir = path.join("test", "file", "factorio"); - const [dir, version] = await hostServer._findVersion(installDir, "0.1.1"); + const [dir, version] = await hostServer.findVersion(installDir, "0.1.1"); assert.equal(dir, path.join(installDir, "0.1.1", "data")); assert.equal(version, "0.1.1"); }); it("should search given directory for partly given Factorio install", async function() { const installDir = path.join("test", "file", "factorio"); - const [dir, version] = await hostServer._findVersion(installDir, "0.1"); + const [dir, version] = await hostServer.findVersion(installDir, "0.1"); assert.equal(dir, path.join(installDir, "0.1.2", "data")); assert.equal(version, "0.1.2"); }); it("should reject if no factorio install with the given version was found", async function() { let installDir = path.join("test", "file", "factorio"); await assert.rejects( - hostServer._findVersion(installDir, "0.1.3"), + hostServer.findVersion(installDir, "0.1.3"), new Error("Unable to find Factorio version 0.1.3") ); }); it("should reject if no factorio install was found", async function() { let installDir = path.join("test", "file"); await assert.rejects( - hostServer._findVersion(installDir, "0.0.0"), + hostServer.findVersion(installDir, "0.0.0"), new Error(`Unable to find any Factorio install in ${installDir}`) ); }); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 3274fade..ee11003f 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -263,6 +263,19 @@ describe("Integration of Clusterio", function() { }); describe("clusteriohost", function() { + describe("create-scenario", function() { + it("should create a patched scenario", async function() { + slowTest(this); + requiresFactorio(this); + const output = path.join("temp", "test", "patched-scenario"); + await fs.rm(output, { recursive: true, force: true }); + await execHost("create-scenario patched-scenario --plugins research_sync"); + const control = await fs.readFile(path.join(output, "control.lua"), "utf8"); + assert(control.includes('require("modules/research_sync/sync")')); + await fs.access(path.join(output, "scenario.lua")); + }); + }); + describe("hostUpdateEventHandler()", function() { it("should trigger when a new host is added", async function() { slowTest(this);