/** * Library for patching Factorio saves with scenario code. * @author Hornwitser */ "use strict"; const events = require("events"); const fs = require("fs-extra"); const jszip = require("jszip"); const hash = require("lib/hash"); const knownScenarios = { // First seen in 0.17.63 "4e866186ebe297f1038fd325b09df1a1f5e2fdd1": { "name": "freeplay", "modules": [ "freeplay", "silo-script", ], }, }; /** * Returns the root folder in the zip file * * Returns the name of the folder that all files in the zip file is * contained in. Throws an error if there are multiple such folders. * * @returns {string} name of the root folder. */ function findRoot(zip) { let root = null; zip.forEach((relativePath, file) => { let index = relativePath.indexOf("/"); if (index === -1) { throw new Error(`Zip contains file '${relativePath}' in root dir`); } let pathRoot = relativePath.slice(0, index); if (root === null) { root = pathRoot; } else if (root !== pathRoot) { throw new Error(`Zip contains multiple root folders`); } }); if (root === null) { throw new Error(`Empty zip file`); } return root; } /** * Generates control.lua code for loading the Clusterio modules * * @param {Object} patchInfo - The patch info files's json content */ function generateLoader(patchInfo) { let lines = [ '-- Auto generated scenario module loader created by Clusterio', '-- Modifications to this file will be lost when loaded in Clusterio', '', 'local event_handler = require("event_handler")', '', '-- Scenario modules', ]; for (let moduleName of patchInfo["scenario"]["modules"]) { lines.push('event_handler.add_lib(require("'+moduleName+'"))'); } lines.push(...[ '', '-- Clusterio modules', ]); for (let module of patchInfo["modules"]) { for (let file of module["files"]) { if (file["load"]) { let requirePath = "modules/" + file["path"].slice(0, -4); lines.push('event_handler.add_lib(require("'+requirePath+'"))'); } } } // End last line with a newline lines.push(''); return lines.join('\n'); } /** * 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 {string} savePath - Path to the Factorio save to patch. * @param {Array} modules - Description of the modules to patch. */ async function patch(savePath, modules) { let zip = await jszip.loadAsync(await fs.readFile(savePath)); let root = zip.folder(findRoot(zip)); let patchInfoFile = root.file("clusterio.json"); let patchInfo; if (patchInfoFile !== null) { let content = await patchInfoFile.async("string"); patchInfo = JSON.parse(content); // No info file present, try to detect if it's a known compatible scenario. } else { let controlStream = root.file("control.lua").nodeStream("nodebuffer"); let controlHash = await hash.hashStream(controlStream); if (controlHash in knownScenarios) { patchInfo = { "scenario": knownScenarios[controlHash], }; } else { throw new Error(`Unable to patch save, unknown scenario (${controlHash})`); } } // Remove any existing modules from the save patchInfo["modules"] = []; for (let file of root.file(/^modules\//)) { zip.remove(file.name); } // Add the modules to the save. let modulesDir = root.folder("modules"); for (let module of modules) { let moduleInfo = { "name": module.name, "files": [], } for (let file of module.files) { modulesDir.file(file.path, file.content); moduleInfo["files"].push({ "path": file.path, "load": Boolean(file.load), }); } patchInfo["modules"].push(moduleInfo); } // Add loading code and patch info root.file("control.lua", generateLoader(patchInfo)); root.file("clusterio.json", JSON.stringify(patchInfo, null, 4)); // Write back the save let stream = zip.generateNodeStream({compression: "DEFLATE"}); let pipe = stream.pipe(fs.createWriteStream(savePath)); await events.once(pipe, "finish"); } module.exports = { patch, // For testing only _findRoot: findRoot, _generateLoader: generateLoader, }