@clusterio/host
Version:
Implementation of Clusterio host server
422 lines • 17.5 kB
JavaScript
;
// 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