diff --git a/client.js b/client.js index d0e6f96d..09c12488 100644 --- a/client.js +++ b/client.js @@ -157,7 +157,7 @@ class Instance extends link.Link{ // Find plugin modules to patch in let modules = new Map(); - for (let pluginName of this.plugins.keys()) { + for (let [pluginName, plugin] of this.plugins) { let modulePath = path.join('plugins', pluginName, 'module'); if (!await fs.pathExists(modulePath)) { continue; @@ -174,12 +174,16 @@ class Instance extends link.Link{ } module = Object.assign({ + version: plugin.info.version, + dependencies: {}, path: modulePath, load: [], }, module); modules.set(module.name, module); } + // Find stand alone modules to load + // XXX for now it's assumed all available modules should be loaded. for (let entry of await fs.readdir('modules', { withFileTypes: true })) { if (entry.isDirectory()) { if (modules.has(entry.name)) { @@ -198,13 +202,14 @@ class Instance extends link.Link{ module = Object.assign({ path: path.join('modules', entry.name), + dependencies: {}, load: [], }, module); modules.set(module.name, module); } } - await factorio.patch(this.path("saves", saveName), modules.values()); + await factorio.patch(this.path("saves", saveName), [...modules.values()]); this.server.on('rcon-ready', () => { console.log("Clusterio | RCON connection established"); diff --git a/docs/developing-for-clusterio.md b/docs/developing-for-clusterio.md index 6083285e..41e9ac5f 100644 --- a/docs/developing-for-clusterio.md +++ b/docs/developing-for-clusterio.md @@ -198,6 +198,10 @@ structure: ```json { "name": "my_module", + "version": "1.2.0", + "dependencies": { + "foo": ">=0.4.2" + }, "load": ["foo.lua"] } ``` @@ -208,6 +212,19 @@ The following entries are supported in the module.json file: Name of the module, must match the folder the module is located in for stand alone modules or the name of the plugin the module is located in. +- `version`: + Version of the module. Must be compatible with [Semantic Versioning + 2.0.0](https://semver.org/). In plugin modules this defaults to the + version of the plugin otherwise it's required. +- `dependencies`: + Optional mapping of modules this module depends on and their + version. In the example `>=0.4.2` means that the foo module must be + at least version 0.4.2. See the [Ranges + syntax](https://www.npmjs.com/package/semver#ranges) of the + node-semver package for a full description of what operators are + supported. The events for dependencies is invoked before the events + of the dependent, and starting an instance will fail if the + dependencies cannot be satisfied. - `load`: Lua files to load with the `event_handler` lib. They should be specified as paths that's relative to the module and uses forward diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 9926e845..9a0f65af 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -52,7 +52,9 @@ The following properties are recognized: Brief description of what the plugin does. Currently not used. **version**: - Semver compatible version string of the plugin. Currently not used. + Semver compatible version string of the plugin. Currently only + servers as the default for the Clusterio module embedded in the + plugin, if any. **instanceEntrypoint**: Path to a Node.js module relative to the plugin directory which diff --git a/lib/factorio/patch.js b/lib/factorio/patch.js index 41ebf1c0..93c51bc0 100644 --- a/lib/factorio/patch.js +++ b/lib/factorio/patch.js @@ -8,6 +8,7 @@ const events = require("events"); const fs = require("fs-extra"); const jszip = require("jszip"); const path = require("path"); +const semver = require("semver"); const hash = require("lib/hash"); @@ -94,6 +95,99 @@ function generateLoader(patchInfo) { return lines.join('\n'); } +/** + * Reorders modules to satisfy their dependencies + * + * Looks through and reorders the array of module definitions in order to + * satisfy the property that dependencies are earlier in the array than + * their dependents. Throws an error if this is not possible. + * + * @param {Array} modules - Array of modules to reorder + */ +function reorderDependencies(modules) { + let index = 0; + let present = new Map(); + let hold = new Map(); + reorder: while (index < modules.length) { + let module = modules[index]; + if (semver.valid(module.version) === null) { + throw new Error(`Invalid version '${module.version}' for module ${module.name}`); + } + + for (let [dependency, requirement] of Object.entries(module.dependencies)) { + if (semver.validRange(requirement) === null) { + throw new Error( + `Invalid version range '${requirement}' for dependency ${dependency} on module ${module.name}` + ); + } + + if (present.has(dependency)) { + if (!semver.satisfies(present.get(dependency), requirement)) { + throw new Error(`Module ${module.name} requires ${dependency} ${requirement}`); + } + + // We have an unmet dependency, take it out and continue + } else { + if (hold.has(dependency)) { + hold.get(dependency).push(module); + } else { + hold.set(dependency, [module]); + } + modules.splice(index, 1); + continue reorder; + } + } + + // No unmet dependencies, record and continue + present.set(module.name, module.version); + index++; + + if (hold.has(module.name)) { + modules.splice(index, 0, ...hold.get(module.name)); + hold.delete(module.name); + } + } + + if (!hold.size) { + return; + } + + // There are three reasons for a module to end up being held: The module depends + // on a module that is missing, the module is part of a dependency loop, or the + // the module depends on a module that satisfy any of these conditions. + + let remaining = new Map(); + for (let modules of hold.values()) { + for (let module of modules) { + remaining.set(module.name, module); + } + } + + // Start with a random module from the remaining modules + for (let module of remaining.values()) { + let cycle = []; + while (true) { + // Find an unmet dependency + let dependency = Object.keys(module.dependencies).find(name => !present.has(name)); + + if (!remaining.has(dependency)) { + // There's no module being held up by this dependency, the + // dependency is missing. + throw new Error(`Missing dependency ${dependency} for module ${module.name}`); + } + + if (cycle.includes(module.name)) { + cycle.push(module.name); + cycle.splice(0, cycle.indexOf(module.name)) + throw new Error(`Module dependency loop detected: ${cycle.join(" -> ")}`); + } + + cycle.push(module.name); + module = remaining.get(dependency); + } + } +} + /** * Patch a save with the given modules * @@ -135,6 +229,8 @@ async function patch(savePath, modules) { zip.remove(file.name); } + reorderDependencies(modules); + // Add the modules to the save. for (let module of modules) { let moduleDir = root.folder(`modules/${module.name}`); @@ -182,4 +278,5 @@ module.exports = { // For testing only _findRoot: findRoot, _generateLoader: generateLoader, + _reorderDependencies: reorderDependencies, } diff --git a/package.json b/package.json index 2ea38232..e596efdf 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "rcon-client": "^4.2.0", "request": "^2.83.0", "sanitizer": "^0.1.3", + "semver": "^7.1.1", "set-blocking": "^2.0.0", "simple-git": "^1.96.0", "single-line-log": "^1.1.2", diff --git a/test/integration/patch.js b/test/integration/patch.js index 9897bc28..8fc96c23 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -23,10 +23,14 @@ describe("Integration of lib/factorio/patch", function() { slowTest(this); await factorio.patch(savePath, [{ name: "test", + version: "1.0.0", + dependencies: {}, path: "test/file/modules/test", load: [], }, { name: "subdir", + version: "1.0.0", + dependencies: {}, path: "test/file/modules/subdir", load: [], }]); diff --git a/test/lib/factorio/patch.js b/test/lib/factorio/patch.js index 3815a852..75f07553 100644 --- a/test/lib/factorio/patch.js +++ b/test/lib/factorio/patch.js @@ -78,6 +78,132 @@ describe("lib/factorio", function() { }); }); + describe("reorderDependencies()", function() { + it("should reorder to satisfy simple dependency", function() { + let modules = [ + { name: 'a', version: '1.0.0', dependencies: {'b': '*'} }, + { name: 'b', version: '1.0.0', dependencies: {} }, + ]; + factorio._reorderDependencies(modules); + assert(modules[0].name === 'b', "Dependency was not reorederd"); + }); + it("should throw on invalid version", function() { + let modules = [ + { name: 'a', version: 'foo', dependencies: {} }, + ]; + assert.throws( + () => factorio._reorderDependencies(modules), + new Error("Invalid version 'foo' for module a") + ); + }); + it("should throw on invalid version range", function() { + let modules = [ + { name: 'a', version: '1.0.0', dependencies: {'b': 'invalid'} }, + ]; + assert.throws( + () => factorio._reorderDependencies(modules), + new Error("Invalid version range 'invalid' for dependency b on module a") + ); + }); + it("should throw on missing dependency", function() { + let modules = [ + { name: 'a', version: '1.0.0', dependencies: {'b': '*'} }, + ]; + assert.throws( + () => factorio._reorderDependencies(modules), + new Error("Missing dependency b for module a") + ); + modules = [ + { name: 'a', version: '1.0.0', dependencies: {'b': '*'} }, + { name: 'b', version: '1.0.0', dependencies: {'c': '*'} }, + { name: 'c', version: '1.0.0', dependencies: {'d': '*'} }, + ]; + assert.throws( + () => factorio._reorderDependencies(modules), + new Error("Missing dependency d for module c") + ); + }); + it("should throw on outdated dependency", function() { + let modules = [ + { name: 'a', version: '1.0.0', dependencies: {} }, + { name: 'b', version: '1.0.0', dependencies: {'a': '>=2'} }, + ]; + assert.throws( + () => factorio._reorderDependencies(modules), + new Error("Module b requires a >=2") + ); + }); + it("should throw on dependency loops", function() { + let modules = [ + { name: 'a', version: '1.0.0', dependencies: {'b': '*'} }, + { name: 'b', version: '1.0.0', dependencies: {'a': '*'} }, + ]; + assert.throws( + () => factorio._reorderDependencies(modules), + new Error("Module dependency loop detected: a -> b -> a") + ); + modules = [ + { name: 'd', version: '1.0.0', dependencies: {'b': '*'} }, + { name: 'a', version: '1.0.0', dependencies: {'b': '*'} }, + { name: 'b', version: '1.0.0', dependencies: {'c': '*'} }, + { name: 'c', version: '1.0.0', dependencies: {'a': '*'} }, + ]; + assert.throws( + () => factorio._reorderDependencies(modules), + new Error("Module dependency loop detected: b -> c -> a -> b") + ); + }); + it("should reorder correctly for multiple dependencies", function() { + function validate(modules) { + let present = new Set(); + for (let module of modules) { + for (let dependency of Object.keys(module.dependencies)) { + if (!present.has(dependency)) { + assert.fail(`${module.name} depends on ${dependency}, but was ordered before it`); + } + } + present.add(module.name); + } + } + + let names = ['a', 'b', 'c']; + let successCount = 0; + const bitCount = names.length * (names.length - 1); + for (let i=0; i < 2**bitCount; i++) { + let value = i; + let bits = []; + for (let j=0; j < bitCount; j++) { + bits.push(value & 1); + value >>= 1; + } + let modules = []; + for (let j=0; j < names.length; j++) { + let name = names[j]; + let dependencies = {}; + for (let k=0; k < names.length - 1; k++) { + if (bits[k + j * (names.length - 1)]) { + dependencies[names[k + (k >= j)]] = '*'; + } + } + modules.push({ name, version: '1.0.0', dependencies }); + } + let success = false; + try { + factorio._reorderDependencies(modules); + success = true; + } catch (err) { } + if (success) { + validate(modules); + successCount++; + } + } + + // Deriving the formula for the number of graphs without + // dependencies loops is left as an excercise for the reader. + assert(successCount === 25, "Incorrect number of successful orderings"); + }); + }); + describe("patch()", function() { it("should throw on unknown scenario", async function() { let zip = new jszip();