UNPKG

@clusterio/host

Version:

Implementation of Clusterio host server

422 lines 17.5 kB
"use strict"; // Library for patching Factorio saves with scenario code. var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports._reorderDependencies = exports._generateLoader = exports.PatchInfo = exports.SaveModule = void 0; exports.patch = patch; const events_1 = __importDefault(require("events")); const fs_extra_1 = __importDefault(require("fs-extra")); const jszip_1 = __importDefault(require("jszip")); const path_1 = __importDefault(require("path")); const semver_1 = __importDefault(require("semver")); const typebox_1 = require("@sinclair/typebox"); const lib = __importStar(require("@clusterio/lib")); /** * Describes a module that can be patched into a save */ class SaveModule { info; files; constructor( /** Module */ info, /** Files and their content that will be patched into the save */ files = new Map()) { this.info = info; this.files = files; } static moduleFilePath(filePath, moduleName) { // Map locale files to the save's locale folder if (filePath.startsWith("locale/")) { const slashes = filePath.match(/\//g).length; if (slashes > 1) { const secondSlash = filePath.indexOf("/", "locale/".length); return `${filePath.slice(0, secondSlash)}/${moduleName}-${filePath.slice(secondSlash + 1)}`; } const period = `${filePath}.`.indexOf("."); return `${filePath.slice(0, period)}/${moduleName}${filePath.slice(period)}`; } // Map all other files into modules/name in the save. return path_1.default.posix.join("modules", moduleName, filePath); } async loadFiles(moduleDirectory) { let dirs = [[moduleDirectory, ""]]; while (dirs.length) { let [dir, relativeDir] = dirs.pop(); for (let entry of await fs_extra_1.default.readdir(dir, { withFileTypes: true })) { let fsPath = path_1.default.join(dir, entry.name); let relativePath = path_1.default.posix.join(relativeDir, entry.name); if (entry.isFile()) { let savePath = SaveModule.moduleFilePath(relativePath, this.info.name); this.files.set(savePath, await fs_extra_1.default.readFile(fsPath)); if (relativePath === "module_exports.lua") { this.files.set(`modules/${this.info.name}.lua`, Buffer.from(`return require("modules/${this.info.name}/module_exports")`, "utf-8")); } } else if (entry.isDirectory()) { dirs.push([fsPath, relativePath]); } } } } static jsonSchema = typebox_1.Type.Object({ ...lib.ModuleInfo.jsonSchema.properties, "files": typebox_1.Type.Array(typebox_1.Type.String()), }); toJSON() { return { ...this.info.toJSON(), files: [...this.files.keys()], }; } static async fromSave(json, root) { const module = new this(lib.ModuleInfo.fromJSON(json)); module.files = new Map(await Promise.all(json.files .map(filename => ({ filename, file: root.file(filename) })) .filter(({ filename, file }) => { if (file === null) { lib.logger.warn(`Missing file ${filename} in save`); } return file !== null; }) .map(async ({ filename, file }) => [filename, await file.async("nodebuffer")]))); return module; } static async fromPlugin(plugin) { let pluginPackagePath = require.resolve(path_1.default.posix.join(plugin.info.requirePath, "package.json")); let moduleDirectory = path_1.default.join(path_1.default.dirname(pluginPackagePath), "module"); if (!await fs_extra_1.default.pathExists(moduleDirectory)) { return null; } let moduleJsonPath = path_1.default.join(moduleDirectory, "module.json"); let moduleJson; try { moduleJson = { name: plugin.info.name, version: plugin.info.version, dependencies: { "clusterio": "*" }, ...JSON.parse(await fs_extra_1.default.readFile(moduleJsonPath, "utf8")), }; } catch (err) { throw new Error(`Loading module/module.json in plugin ${plugin.info.name} failed: ${err.message}`); } if (!lib.ModuleInfo.validate(moduleJson)) { throw new Error(`module/module.json in plugin ${plugin.info.name} failed validation:\n` + `${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}`); } let module = new SaveModule(lib.ModuleInfo.fromJSON(moduleJson)); await module.loadFiles(moduleDirectory); return module; } static async fromDirectory(moduleDirectory) { let name = path_1.default.basename(moduleDirectory); let moduleJsonPath = path_1.default.join(moduleDirectory, "module.json"); let moduleJson; try { moduleJson = JSON.parse(await fs_extra_1.default.readFile(moduleJsonPath, "utf8")); } catch (err) { throw new Error(`Loading ${name}/module.json failed: ${err.message}`); } if (!lib.ModuleInfo.validate(moduleJson)) { throw new Error(`${name}/module.json failed validation:\n` + `${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}`); } if (moduleJson.name !== name) { throw new Error(`Expected name of module ${moduleJson.name} to match the directory name ${name}`); } let module = new SaveModule(lib.ModuleInfo.fromJSON(moduleJson)); await module.loadFiles(moduleDirectory); return module; } } exports.SaveModule = SaveModule; class PatchInfo { patchNumber; scenario; modules; version; static currentVersion = 1; constructor(patchNumber, scenario, modules, version = PatchInfo.currentVersion) { this.patchNumber = patchNumber; this.scenario = scenario; this.modules = modules; this.version = version; } static jsonSchema = typebox_1.Type.Object({ "version": typebox_1.Type.Number(), "patch_number": typebox_1.Type.Number(), "scenario": lib.ModuleInfo.jsonSchema, "modules": typebox_1.Type.Array(SaveModule.jsonSchema), }); toJSON() { return { version: PatchInfo.currentVersion, patch_number: this.patchNumber, scenario: this.scenario.toJSON(), modules: this.modules.map(m => m.toJSON()), }; } static async fromSave(json, root) { if (json.version === undefined) { const info = json; return new this(info.patch_number, new lib.ModuleInfo(info.scenario.name, "0.0.0", info.scenario.modules), [], 0); } return new this(json.patch_number, lib.ModuleInfo.fromJSON(json.scenario), await Promise.all(json.modules.map(m => SaveModule.fromSave(m, root)))); } } exports.PatchInfo = PatchInfo; /** * Generates control.lua code for loading the Clusterio modules * * @param patchInfo - The patch info files's json content * @returns Generated control.lua code. * @internal */ function generateLoader(patchInfo) { let lines = [ "-- Auto generated scenario module loader created by Clusterio", "-- Modifications to this file will be lost when loaded in Clusterio", `clusterio_patch_number = ${patchInfo.patchNumber}`, "", 'local event_handler = require("event_handler")', "", "-- Scenario modules", ]; for (let requirePath of patchInfo.scenario.load) { lines.push(`event_handler.add_lib(require("${requirePath}"))`); } for (let requirePath of patchInfo.scenario.require) { lines.push(`require("${requirePath}")`); } lines.push(...[ "", "-- Clusterio modules", ]); for (let module of patchInfo.modules) { for (let file of module.info.load) { let requirePath = `modules/${module.info.name}/${file.replace(/\.lua$/i, "")}`; lines.push(`event_handler.add_lib(require("${requirePath}"))`); } for (let file of module.info.require) { let requirePath = `modules/${module.info.name}/${file.replace(/\.lua$/i, "")}`; lines.push(`require("${requirePath}")`); } } // End last line with a newline lines.push(""); 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 modules - Array of modules to reorder * @internal */ 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_1.default.valid(module.info.version) === null) { throw new Error(`Invalid version '${module.info.version}' for module ${module.info.name}`); } for (let [dependency, requirement] of module.info.dependencies) { if (semver_1.default.validRange(requirement) === null) { throw new Error(`Invalid version range '${requirement}' for dependency ${dependency} on module ${module.info.name}`); } if (present.has(dependency)) { if (!semver_1.default.satisfies(present.get(dependency), requirement)) { throw new Error(`Module ${module.info.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.info.name, module.info.version); index += 1; if (hold.has(module.info.name)) { modules.splice(index, 0, ...hold.get(module.info.name)); hold.delete(module.info.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 heldModules of hold.values()) { for (let module of heldModules) { remaining.set(module.info.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 = [...module.info.dependencies.keys()].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.info.name}`); } if (cycle.includes(module.info.name)) { cycle.push(module.info.name); cycle.splice(0, cycle.indexOf(module.info.name)); throw new Error(`Module dependency loop detected: ${cycle.join(" -> ")}`); } cycle.push(module.info.name); module = remaining.get(dependency); } } } const knownScenarios = { // First seen in 0.17.63 "4e866186ebe297f1038fd325b09df1a1f5e2fdd1": new lib.ModuleInfo("freeplay", "0.17.63", [], ["scenario"]), // First seen in 2.0 "bcbdde18ce4ec16ebfd93bd694cd9b12ef969c9a": new lib.ModuleInfo("freeplay", "2.0.0", [], ["scenario"]), // First seen in 2.0.29 "3af547d2f4db3728b75ac5d1b3a4f47830dc48e7": new lib.ModuleInfo("freeplay", "2.0.29", [], ["scenario"]), }; /** * Patch a save with the given modules * * Adds the modules given by the modules parameter to the save located * at savePath and rewrites the control.lua in the save to load the * modules that were added. Will also remove any previous module * located in the save. * * @param savePath - Path to the Factorio save to patch. * @param modules - Description of the modules to patch. */ async function patch(savePath, modules) { let zip = await jszip_1.default.loadAsync(await fs_extra_1.default.readFile(savePath)); let root = zip.folder(lib.findRoot(zip)); let patchInfoFile = root.file("clusterio.json"); let patchInfo; if (patchInfoFile !== null) { let content = await patchInfoFile.async("string"); patchInfo = await PatchInfo.fromSave(JSON.parse(content), root); if (patchInfo.version > PatchInfo.currentVersion) { throw new Error(`Save patch version ${patchInfo.version} is newer than the patch version this ` + `version of Clusterio can load (${PatchInfo.currentVersion})`); } // No info file present, try to detect if it's a known compatible scenario. } else { let controlFile = root.file("control.lua"); if (!controlFile) { throw new Error("Unable to patch save, missing control.lua file."); } let controlStream = controlFile.nodeStream("nodebuffer"); let controlHash = await lib.hashStream(controlStream); if (controlHash in knownScenarios) { patchInfo = new PatchInfo(0, knownScenarios[controlHash], []); root.file("scenario.lua", controlFile.nodeStream("nodebuffer")); } else { throw new Error(`Unable to patch save, unknown scenario (${controlHash})`); } } // Increment patch number patchInfo.patchNumber = patchInfo.patchNumber + 1; // Remove any existing modules from the save if (patchInfo.version === 0) { for (let file of root.file(/^modules\//)) { zip.remove(file.name); } } else { for (let module of patchInfo.modules) { for (let filepath of module.files.keys()) { const file = root.file(filepath); if (file !== null) { zip.remove(file.name); } } } patchInfo.modules = []; } reorderDependencies(modules); // Add the modules to the save. for (let module of modules) { for (let [relativePath, contents] of module.files) { root.file(relativePath, contents); } patchInfo.modules.push(module); } // Add loading code and patch info root.file("control.lua", generateLoader(patchInfo)); root.file("clusterio.json", JSON.stringify(patchInfo, null, "\t")); // Write back the save let tempSavePath = savePath.replace(/(\.zip)?$/, ".tmp.zip"); let stream = zip.generateNodeStream({ compression: "DEFLATE" }); let fd = await fs_extra_1.default.open(tempSavePath, "w"); try { let pipe = stream.pipe(fs_extra_1.default.createWriteStream("", { fd, autoClose: false })); await events_1.default.once(pipe, "finish"); await fs_extra_1.default.fsync(fd); } finally { await fs_extra_1.default.close(fd); } await fs_extra_1.default.rename(tempSavePath, savePath); } // For testing only exports._generateLoader = generateLoader; exports._reorderDependencies = reorderDependencies; //# sourceMappingURL=patch.js.map