diff --git a/.gitignore b/.gitignore index c3f4099c..687b2488 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ static/pictures/spritesheet.js static/pictures/spritesheet.png static/external saves +/test/temp/ # weird pattern to ignore sharedPlugins except not ignore some core modules !sharedPlugins/ diff --git a/CHANGELOG.md b/CHANGELOG.md index afd8f639..f77e9fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ Version 2.0.0 - Added plugins directory to the views path. This makes it possible for plugins to render their own ejs views or pages in their own folders by using paths of the format "pluginName/path/to/page-or-view". +- Replaced the per instance copy of the shared Factorio mods with + symlinks. On Windows hard links are used instead due to the + privileges requirements of symlinks. +- Changed the per instance scenario folder to be linked instead of + copied on instance creation. +- Instance id is no longer derived from the rcon password, instead it's + generated upon instance creation and stored in the instance config. +- The game port, RCON port and RCON password instance config entries are are + now null by default, indicating that a random one will be generated every + time the instance is started. ### Breaking Changes @@ -25,6 +35,9 @@ Version 2.0.0 - Removed config management from the command line and the server manager. - Moved ejs templates into views folder and changed their extension to .ejs. Breaks playerManager. +- Mods are no longer copied from the per instance instanceMods directory. + If you need per instance mods you can now place them directly in the mods + directory inside the instance folder. Version 1.2.2 diff --git a/client.js b/client.js index ddeec91b..d4dd56db 100644 --- a/client.js +++ b/client.js @@ -6,23 +6,19 @@ const child_process = require('child_process'); const path = require('path'); const syncRequest = require('sync-request'); const request = require("request"); -const ncp = require('ncp').ncp; -const Rcon = require('rcon-client-fork').Rcon; const deepmerge = require("deepmerge"); const getMac = require('getmac').getMac; -const rmdirSync = require('rmdir-sync'); const ioClient = require("socket.io-client"); const asTable = require("as-table").configure({delimiter: ' | '}); const util = require("util"); -const crypto = require("crypto"); // internal libraries const objectOps = require("lib/objectOps.js"); const fileOps = require("lib/fileOps"); -const stringUtils = require("lib/stringUtils.js"); const pluginManager = require("lib/manager/pluginManager"); const modManager = require("lib/manager/modManager"); const hashFile = require('lib/hash').hashFile; +const factorio = require("lib/factorio"); // Uhm... var global = {}; @@ -107,100 +103,6 @@ function needleOptionsWithTokenAuthHeader(config) { }; } - -var instanceInfo = {}; -var commandBuffer=[]; - -// function to handle sending commands into the game -async function messageInterfaceInternal(command, callback, resolve, reject) { - // try to save us if you send a buffer instead of string - if(typeof command == "object") { - command = command.toString('utf8'); - } - - if(false && process.platform == "linux" && typeof command == "string" && serverprocess) { - /* - to send to stdin, use: - serverprocess.stdin.write("/c command;\n") - */ - serverprocess.stdin.write(command+"\n"); - if(typeof callback == "function"){ - callback(); - } - resolve(); - } else if(typeof command == "string" && client && client.send && typeof client.send == "function") { - try { - let str = await client.send(command+"\n"); - if(typeof callback == "function") callback(str) - resolve(str) - } catch (err) { - console.log("RCON failed, server might be paused or shutting down"); - // console.log(err); - // reject(err); - if(typeof callback == "function"){ - callback(); - } - reject(err) - } - } -} -function messageInterface(command, callback) { - return new Promise((resolve,reject) => { - commandBuffer.push([command,callback, resolve, reject]); - }); -} - -/** - * Give a random dynamic port - * - * Returns a random port number in the Dynamic Ports range as defined by - * RFC 6335. - * - * @return {number} a number in the range 49152 to 65535. - */ -function randomDynamicPort() { - const start = 49152; - const end = 65535 + 1; - - return Math.floor(Math.random() * (end - start) + start) -} - -/** - * Generate a secure random password of the given length - * - * Uses crypto.randomBytes to generate a secure alphanumeric password of - * the given length. - * - * @param {number} length - the length of the password to generate. - * @return {string} password of the given length - */ -async function generatePassword(length) { - function validChar(byte) { - const ranges = ['az', 'AZ', '09']; - return ranges.some(range => - range.codePointAt(0) <= byte && byte <= range.codePointAt(1) - ); - } - let randomBytesAsync = util.promisify(crypto.randomBytes); - - let password = ""; - while (true) { - let bytes = await randomBytesAsync((length - password.length) * 3); - for (let byte of bytes) { - - // Crop to ASCII values only - byte = byte & 0x7f; - - if (validChar(byte)) { - password += String.fromCharCode(byte); - if (password.length == length) { - return password; - } - } - } - } -} - function printUsage() { console.error("Usage: "); console.error("node client.js start [instance name]"); @@ -326,34 +228,85 @@ async function downloadMod() { } } +/** + * Create and update symlinks for shared mods in an instance + * + * Creates symlinks for .zip and .dat files that are not present in the + * instance mods directory but is present in the sharedMods directory, + * and removes any symlinks that don't point to a file in the instance + * mods directory. If the instance mods directory doesn't exist it will + * be created. + * + * Note that on Windows this creates hard links instead of symbolic + * links as the latter requires elevated privileges. This unfortunately + * means the removal of mods from the shared mods dir can't be detected. + * + * @param {Instance} instance - Instance to link mods for + * @param {string} sharedMods - Path to folder to link mods from. + * @param {object} logger - console like logging interface. + */ +async function symlinkMods(instance, sharedMods, logger) { + await fs.ensureDir(instance.path("mods")); + + // Remove broken symlinks in instance mods. + for (let entry of await fs.readdir(instance.path("mods"), { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + if (!await fs.pathExists(instance.path("mods", entry.name))) { + logger.log(`Removing broken symlink ${entry.name}`); + await fs.unlink(instance.path("mods", entry.name)); + } + } + } + + // Link entries that are in sharedMods but not in instance mods. + let instanceModsEntries = new Set(await fs.readdir(instance.path("mods"))); + for (let entry of await fs.readdir(sharedMods, { withFileTypes: true })) { + if (entry.isFile()) { + if (['.zip', '.dat'].includes(path.extname(entry.name))) { + if (!instanceModsEntries.has(entry.name)) { + logger.log(`linking ${entry.name} from ${sharedMods}`); + let target = path.join(sharedMods, entry.name); + let link = instance.path("mods", entry.name); + + if (process.platform !== "win32") { + await fs.symlink(path.relative(path.dirname(link), target), link); + + // On Windows symlinks require elevated privileges, which is + // not something we want to have. For this reason the mods + // are hard linked instead. This has the drawback of not + // being able to identify when mods are removed from the + // sharedMods directory, or which mods are linked. + } else { + await fs.link(target, link); + } + } + + } else { + logger.warning(`Warning: ignoring file '${entry.name}' in sharedMods`); + } + + } else { + logger.warning(`Warning: ignoring non-file '${entry.name}' in sharedMods`); + } + } +} + async function createInstance(config, args, instance) { - // if instance does not exist, create it - console.log("Creating instance..."); - fs.mkdirSync(instance.path()); - fs.mkdirSync(instance.path("script-output")); - fs.writeFileSync(instance.path("script-output", "output.txt"), ""); - fs.writeFileSync(instance.path("script-output", "orders.txt"), ""); - fs.writeFileSync(instance.path("script-output", "txbuffer.txt"), ""); - fs.mkdirSync(instance.path("mods")); - fs.mkdirSync(instance.path("instanceMods")); - fs.mkdirSync(instance.path("scenarios")); - ncp("lib/scenarios", instance.path("scenarios"), err => { - if (err) console.error(err) - }); + console.log(`Creating ${instance.path()}`); + await fs.ensureDir(instance.path()); + await fs.ensureDir(instance.path("script-output")); - // fs.symlinkSync('../../../sharedMods', instance.path("mods"), 'junction') // This is broken because it can only take a file as first argument, not a folder - fs.writeFileSync(instance.path("config.ini"), `[path]\r\n -read-data=${ path.resolve(config.factorioDirectory, "data") }\r\n -write-data=${ instance.path() }\r\n - `); + let target = path.join("lib", "scenarios"); + let link = instance.path("scenarios"); + await fs.symlink(path.relative(path.dirname(link), target), link, 'junction'); - // this line is probably not needed anymore but Im not gonna remove it - fs.copySync('sharedMods', instance.path("mods")); + await symlinkMods(instance, "sharedMods", console); let instconf = { - "factorioPort": args.port || process.env.FACTORIOPORT || randomDynamicPort(), - "clientPort": args["rcon-port"] || process.env.RCONPORT || randomDynamicPort(), - "__comment_clientPassword": "This is the rcon password. Its also used for making an instanceID. Make sure its unique and not blank.", - "clientPassword": args["rcon-password"] || await generatePassword(10), + "id": Math.random() * 2 ** 31 | 0, + "factorioPort": args.port || process.env.FACTORIOPORT || null, + "clientPort": args["rcon-port"] || process.env.RCONPORT || null, + "__comment_clientPassword": "This is the rcon password. Will be randomly generated if null.", + "clientPassword": args["rcon-password"] || null, "info": {} } console.log("Clusterio | Created instance with settings:") @@ -362,11 +315,16 @@ write-data=${ instance.path() }\r\n // create instance config fs.writeFileSync(instance.path("config.json"), JSON.stringify(instconf, null, 4)); + let server = new factorio.FactorioServer(path.join("factorio", "data"), instance.path(), {}); + await server.init(); + + let serverSettings = await server.exampleSettings(); let name = "Clusterio instance: " + instance.name; if (config.username) { name = config.username + "'s clusterio " + instance.name; } - let serversettings = { + + let overrides = { "name": name, "description": config.description, "tags": ["clusterio"], @@ -375,70 +333,63 @@ write-data=${ instance.path() }\r\n "username": config.username, "token": config.token, "game_password": config.game_password, - "verify_user_identity": config.verify_user_identity, - "admins": [config.username], + "require_user_verification": config.verify_user_identity, "allow_commands": config.allow_commands, "autosave_interval": 10, "autosave_slots": 5, "afk_autokick_interval": 0, "auto_pause": config.auto_pause, }; - fs.writeFileSync(instance.path("server-settings.json"), JSON.stringify(serversettings, null, 4)); - console.log("Server settings: "+JSON.stringify(serversettings, null, 4)); - console.log("Creating save ....."); - let factorio = child_process.spawn( - './' + config.factorioDirectory + '/bin/x64/factorio', [ - '-c', instance.path("config.ini"), - // '--create', instance.path("saves", "save.zip"), - '--start-server-load-scenario', 'Hotpatch', - '--server-settings', instance.path("server-settings.json"), - '--rcon-port', Number(process.env.RCONPORT) || instconf.clientPort, - '--rcon-password', instconf.clientPassword, - ], { - 'stdio': ['pipe', 'pipe', 'pipe'], - 'detached': process.platform === "linux", - } - ); - factorio.stdout.on("data", data => { - data = data.toString("utf8").replace(/(\r\n\t|\n|\r\t)/gm,""); - console.log(data); - if(data.includes("Starting RCON interface")){ - let client = new Rcon({ - packetResponseTimeout: 200000, - maxPending: 5 - }); - client.connect({ - host: 'localhost', - port: Number(process.env.RCONPORT) || instconf.clientPort, - password: instconf.clientPassword - }); - client.onDidAuthenticate(() => { - console.log('Clusterio | RCON Authenticated!'); - }); - client.onDidConnect(() => { - console.log('Clusterio | RCON Connected, starting save'); - client.send("/c game.server_save('hotpachSave')"); - }); - } - if(data.includes("Saving finished")){ - console.log("Map saved as hotpachSave.zip, exiting..."); - console.log("Instance created!") - process.exit(0); - } - if(data.includes("Downloading from auth server failed")){ - console.error("Instance creation failed, unable to establish auth server connection."); - console.error("Deleting broken instance..."); - - fileOps.deleteFolderRecursiveSync(path.resolve(config.instanceDirectory, process.argv[3])); - - process.exit(0); + + for (let [name, value] of Object.entries(overrides)) { + if (!Object.hasOwnProperty.call(serverSettings, name)) { + throw Error(`Expected server settings to have a ${name} property`); } + serverSettings[name] = value; + } + + await fs.writeFile(instance.path("server-settings.json"), JSON.stringify(serverSettings, null, 4)); + console.log("Server settings: ", serverSettings); + console.log("Creating save ....."); + + server.on('output', function(output) { + console.log("Fact: " + output.message); }); + + server.on('rcon-ready', function() { + console.log("Clusterio | RCON connection established"); + }); + + let waitUntilSaved = new Promise((resolve, reject) => { + let checkSaved = (output) => { + if (output.type === 'log' && output.message === "Saving finished") { + resolve(); + server.off('output', checkSaved); + } else if (output.message === "Goodbye") { + reject(new Error("Server failed to save")); + server.off('output', checkSaved); + } + }; + + server.on('output', checkSaved); + }); + + await server.startScenario("Hotpatch"); + await server.sendRcon("/c game.server_save('hotpatchSave')"); + await waitUntilSaved; + + await server.stop(); + console.log("Clusterio | Successfully created instance"); } async function startInstance(config, args, instance) { var instanceconfig = JSON.parse(await fs.readFile(instance.path("config.json"))); - instanceconfig.unique = stringUtils.hashCode(instanceconfig.clientPassword); + if (typeof instanceconfig.id !== "number" || isNaN(instanceconfig.id)) { + throw new Error(`${instance.path("config.json")} is missing id`); + } + // Temporary measure for backwards compatibility + instanceconfig.unique = instanceconfig.id; + if (process.env.FACTORIOPORT) { instanceconfig.factorioPort = process.env.FACTORIOPORT; } @@ -465,132 +416,61 @@ async function startInstance(config, args, instance) { console.log(`Log rotated as ${logFilename}`); } }catch(e){} - // Math.floor(Date.now()/1000) - // (new Date).toGMTString() - - // move mods from ./sharedMods to the instances mod directory - try{fs.mkdirSync(instance.path("instanceMods"));}catch(e){} - try{rmdirSync(instance.path("mods"));}catch(e){} - try { - // mods directory that will be emptied (deleted) when closing the server to facilitate seperation of instanceMods and sharedMods - fs.mkdirSync(instance.path("mods")); - } catch(e){} - console.log("Clusterio | Moving shared mods from sharedMods/ to instance/mods..."); - fs.copySync('sharedMods', instance.path("mods")); - console.log("Clusterio | Moving instance specific mods from instance/instanceMods to instance/mods..."); - fs.copySync(instance.path("instanceMods"), instance.path("mods")); - - // Set paths for factorio so it reads and writes from the correct place even if the instance is imported from somewhere else - fs.writeFileSync(instance.path("config.ini"), `[path]\r\n -read-data=${ path.resolve(config.factorioDirectory, "data") }\r\n -write-data=${ instance.path() }\r\n - `); - - // Spawn factorio server - //var serverprocess = child_process.exec(commandline); - fileOps.getNewestFile(instance.path("saves"), fs.readdirSync(instance.path("saves")),function(err, latestSave) { - if(err) { - console.error("ERROR!"); - console.error("Your savefile seems to be missing. This might because you created an instance without having factorio\ - installed and configured properly. Try installing factorio and adding your savefile to instances/[instancename]/saves/\n"); - throw err; - } - // implicit global - serverprocess = child_process.spawn( - './' + config.factorioDirectory + '/bin/x64/factorio', [ - '-c', instance.path("config.ini"), - '--start-server', latestSave.file, - '--rcon-port', args["rcon-port"] || Number(process.env.RCONPORT) || instanceconfig.clientPort, - '--rcon-password', args["rcon-password"] || instanceconfig.clientPassword, - '--server-settings', instance.path("server-settings.json"), - '--port', args.port || Number(process.env.FACTORIOPORT) || instanceconfig.factorioPort - ], { - 'stdio': ['pipe', 'pipe', 'pipe'], - 'detached': process.platform === "linux", - } - ); - serverprocess.on('close', code => { - console.log(`child process exited with code ${code}`); + await symlinkMods(instance, "sharedMods", console); + + // Spawn factorio server + let latestSave = await fileOps.getNewestFile(instance.path("saves")); + if (latestSave === null) { + throw new Error( + "Your savefile seems to be missing. This might because you created an\n"+ + "instance without having factorio installed and configured properly.\n"+ + "Try installing factorio and adding your savefile to\n"+ + "instances/[instancename]/saves/" + ); + } + + let options = { + gamePort: args.port || Number(process.env.FACTORIOPORT) || instanceconfig.factorioPort, + rconPort: args["rcon-port"] || Number(process.env.RCONPORT) || instanceconfig.clientPort, + rconPassword: args["rcon-password"] || instanceconfig.clientPassword, + }; + + let server = new factorio.FactorioServer(path.join("factorio", "data"), instance.path(), options); + await server.init(); + + // FactorioServer.init may have generated a random port or password + // if they were null. + instanceconfig.factorioPort = server.gamePort + instanceconfig.clientPort = server.rconPort + instanceconfig.clientPassword = server.rconPassword + + server.on('output', function(output) { + console.log("Fact: " + output.message); + }); + + server.on('rcon-ready', function() { + console.log("Clusterio | RCON connection established"); + instanceManagement(config, instance, instanceconfig, server); // XXX async function + }); + + let secondSigint = false + process.on('SIGINT', () => { + if (secondSigint) { + console.log("Caught second interrupt, terminating immediately"); + process.exit(1); + } + + secondSigint = true; + console.log("Caught interrupt signal, shutting down"); + server.stop().then(() => { + // There's currently no shutdown mechanism for instance plugins so + // they keep the event loop alive. process.exit(); }); - serverprocess.stdout.on("data", data => { - // log("Stdout: " + data); - if(data.toString('utf8').includes("Couldn't parse RCON data: Maximum payload size exceeded")){ - console.error("ERROR: RCON CONNECTION BROKE DUE TO TOO LARGE PACKET!"); - console.error("Attempting reconnect..."); - client.disconnect(); - client.connect(); - } - // we have to do this to make logs visible on linux and in powershell. Causes log duplication for people with CMD. - console.log('Fact: ' + data.toString("utf8").replace("\n", "")); - }); - serverprocess.stderr.on('data', (chunk) => { - console.log('ERR: ' + chunk); - }); - - // connect to the server with rcon - if(true || process.platform != "linux"){ - // IP, port, password - client = new Rcon({ - packetResponseTimeout: 200000, // 200s, should allow for commands up to 1250kB in length - maxPending: 5 - }); - // check the logfile to see if the RCON interface is running as there is no way to continue without it - // we read the log every 2 seconds and stop looping when we start connecting to factorio - function checkRcon() { - fs.readFile(instance.path("factorio-current.log"), function (err, data) { - // if (err) console.error(err); - if(data && data.indexOf('Starting RCON interface') > 0){ - client.connect({ - host: 'localhost', - port: args["rcon-port"] || Number(process.env.RCONPORT) || instanceconfig.clientPort, - password: args["rcon-password"] || instanceconfig.clientPassword, - }); - } else { - setTimeout(function(){ - checkRcon(); - },5000); - } - }); - } - setTimeout(checkRcon, 5000); - - client.onDidAuthenticate(() => { - console.log('Clusterio | RCON Authenticated!'); - instanceManagement(config, instance, instanceconfig); // start using rcons - }); - client.onDidConnect(() => { - console.log('Clusterio | RCON Connected!'); - // getID(); - }); - client.onDidDisconnect(() => { - console.log('Clusterio | RCON Disconnected!'); - // process.exit(0); // exit because RCON disconnecting is undefined behaviour and we rather just wanna restart now - }); - process.on('SIGINT', function () { - console.log("Caught interrupt signal, disconnecting RCON"); - client.disconnect().then(()=>{ - console.log("Rcon disconnected, Sending ^C"); - // We don't actually do this on windows, because ctrl+c in windows CMD sends it to all subprocesses as well. Doing it twice will abort factorios save. - if(process.platform == "linux"){ - serverprocess.kill("SIGINT"); - } - }); - }); - } else if(process.platform == "linux"){ - // don't open an RCON connection and just use stdio instead, does not work on windows. - instanceManagement(config, instance, instanceconfig); - process.on('SIGINT', function () { - console.log("Caught interrupt signal, sending ^C"); - serverprocess.kill("SIGINT"); - }); - } - - // set some globals - confirmedOrders = []; - lastSignalCheck = Date.now(); }); + + await server.start(latestSave); } async function startClient() { @@ -641,14 +521,6 @@ async function startClient() { return; } - // messageInterface Management - setInterval(function(){ - let command=commandBuffer.shift(); - if(command){ - messageInterfaceInternal(command[0], command[1], command[2], command[3]); - } - },config.msBetweenCommands || 50); - // handle commandline parameters if (!command || command == "help" || command == "--help") { printUsage(); @@ -679,7 +551,7 @@ async function startClient() { // ensure instancemanagement only ever runs once var _instanceInitialized; -async function instanceManagement(config, instance, instanceconfig) { +async function instanceManagement(config, instance, instanceconfig, server) { if (_instanceInitialized) return; _instanceInitialized = true; @@ -719,14 +591,18 @@ async function instanceManagement(config, instance, instanceconfig) { console.log("Clusterio | "+ pluginsToLoad[i].name + " | " + data.toString('utf8')); return true; } else if (data && data.toString('utf8')[0] == "/"){ - return messageInterface(data.toString('utf8'), callback); + let result = await server.sendRcon(data.toString('utf8')); + if (typeof callback === "function") { + callback(result); + } + return result; } }, { // extra functions to pass in object. Should have done it like this from the start, but won't break backwards compat. socket, // socket.io connection to master (and ES6 destructuring, yay) }); if(plugins[i].factorioOutput && typeof plugins[i].factorioOutput === "function"){ // when factorio logs a line, send it to the plugin. This includes things like autosaves, chat, errors etc - serverprocess.stdout.on("data", data => plugins[i].factorioOutput(data.toString())); + server.on('stdout', data => plugins[i].factorioOutput(data.toString())); } if(pluginConfig.scriptOutputFileSubscription && typeof pluginConfig.scriptOutputFileSubscription == "string"){ if(global.subscribedFiles[pluginConfig.scriptOutputFileSubscription]) { @@ -801,7 +677,7 @@ async function instanceManagement(config, instance, instanceconfig) { setInterval(getID, 10000); getID(); function getID() { - messageInterface("/silent-command rcon.print(#game.connected_players)", function(playerCount) { + server.sendRcon("/silent-command rcon.print(#game.connected_players)").then((playerCount) => { var payload = { time: Date.now(), rconPort: instanceconfig.clientPort, @@ -921,8 +797,7 @@ module.exports = { // For testing only _Instance: Instance, _checkFilename: checkFilename, - _randomDynamicPort: randomDynamicPort, - _generatePassword: generatePassword, + _symlinkMods: symlinkMods, }; if (module === require.main) { diff --git a/lib/factorio/index.js b/lib/factorio/index.js new file mode 100644 index 00000000..f682bbd2 --- /dev/null +++ b/lib/factorio/index.js @@ -0,0 +1,4 @@ +module.exports = { + ...require('lib/factorio/locale'), + ...require('lib/factorio/server'), +}; diff --git a/lib/factorio/locale.js b/lib/factorio/locale.js new file mode 100644 index 00000000..36ee8a1e --- /dev/null +++ b/lib/factorio/locale.js @@ -0,0 +1,28 @@ +/** + * Function for parsing factorio locale from a local factorio install + */ + +"use strict"; +const fs = require("fs-extra"); +const path = require("path"); +const ini = require("ini"); + +/** + * Gets factorios locale as an object, does not respect mods. + * + * @param {string} factorioDirectory + * @param {string} languageCode + * @param {function} callback + */ +async function getLocale(dataDir, languageCode) { + if (typeof dataDir !== "string") throw new TypeError("dataDir must be a string"); + if (typeof languageCode !== "string") throw new TypeError("languageCode must be a string"); + + let localeFilePath = path.join(dataDir, "base", "locale", languageCode, "base.cfg"); + let content = await fs.readFile(localeFilePath, "utf8"); + return ini.parse(content); +} + +module.exports = { + getLocale, +}; diff --git a/lib/factorio/server.js b/lib/factorio/server.js new file mode 100644 index 00000000..aaad92b4 --- /dev/null +++ b/lib/factorio/server.js @@ -0,0 +1,724 @@ +/** + * Factorio interfaces + * + * @module + */ + +"use strict"; +const fs = require("fs-extra"); +const child_process = require("child_process"); +const path = require("path"); +const events = require("events"); +const util = require("util"); +const crypto = require("crypto"); + +const ini = require("ini"); +const rconClient = require("rcon-client"); + +/** + * Determines the version of Factorio the datadir is pointing to by + * reading the changelog.txt in it. + */ +async function getVersion(changelogPath) { + let changelog = await fs.readFile(changelogPath, "utf-8"); + for (let line of changelog.split(/[\r\n]+/)) { + let index = line.indexOf(":"); + if (index !== -1) { + let name = line.slice(0, index).trim(); + if (name.toLowerCase() === "version") { + return line.slice(index + 1).trim(); + } + } + } + + throw new Error("Unable to determine the version of Factorio"); +} + +/** + * Give a random dynamic port + * + * Returns a random port number in the Dynamic Ports range as defined by + * RFC 6335. + * + * @return {number} a number in the range 49152 to 65535. + */ +function randomDynamicPort() { + const start = 49152; + const end = 65535 + 1; + + return Math.floor(Math.random() * (end - start) + start) +} + +/** + * Generate a secure random password of the given length + * + * Uses crypto.randomBytes to generate a secure alphanumeric password of + * the given length. + * + * @param {number} length - the length of the password to generate. + * @return {string} password of the given length + */ +async function generatePassword(length) { + function validChar(byte) { + const ranges = ['az', 'AZ', '09']; + return ranges.some(range => + range.codePointAt(0) <= byte && byte <= range.codePointAt(1) + ); + } + let randomBytesAsync = util.promisify(crypto.randomBytes); + + let password = ""; + while (true) { + let bytes = await randomBytesAsync((length - password.length) * 3); + for (let byte of bytes) { + + // Crop to ASCII values only + byte = byte & 0x7f; + + if (validChar(byte)) { + password += String.fromCharCode(byte); + if (password.length == length) { + return password; + } + } + } + } +} + +/** + * Line splitter for chunked data + * + * Splits a stream of bytes into lines terminated by either line feed or + * carriage return followed by linefeed. Each line sepparated is passed + * to the callback one at a time without the line terminator included. + * + * Data is streamed via the data method, and once the stream ends the + * end method should be called. This ensures an unterminated line at + * the end of the stream is passed to the callback. + */ +class LineSplitter { + + /** + * Create a line splitter + * + * @param {function(Buffer)} callback - Function called for every line. + */ + constructor(callback) { + this._callback = callback; + this._partial = null + } + + /** + * Stream in bytes + * + * Input bytes to split lines over. Will invoke the callback for every + * line found in buf and keep any potential partial line at the end of + * buf for the next invocation. + * + * @param {Buffer} buf - Data to stream in. + */ + data(buf) { + if (this._partial) { + buf = Buffer.concat([this._partial, buf]); + this._partial = null; + } + + while (buf.length) { + let end = buf.indexOf("\n"); + if (end === -1) { + this._partial = buf; + break; + } + + let next = end + 1; + // Eat carriage return as well if present + if (end >= 1 && buf[end-1] === "\r".charCodeAt(0)) { + end -= 1; + } + + let line = buf.slice(0, end); + buf = buf.slice(next); + this._callback(line); + } + } + + /** + * Mark end of stream + * + * Signal the end of the bytestream. If the previous invocation of the + * data method ended with a partial line this will invoke the callback + * with this line. + */ + end() { + if (this._partial) { + this._callback(this._partial); + this._partial = null; + } + } +} + +/** + * Interpret lines of output from Factorio + * + * Parses a line of output from Factorio and interprets the content + * based on the broad categories of output that it produces. + * + * TODO document output format. + * + * @param {string} line - A line of output not including the line terminator + * @param {string} source - Passed into the output structure as source + * + * @returns {object} - An object with interpeted data. + */ +function parseOutput(line, source) { + let output = { + source, + received: Date.now(), + } + + // There are three broad categories of output from Factorio, the first kind + // starts with the seconds since the server started and has a format of + // " 0.704 message" + const secRegex = /^ {0,3}(\d+\.\d+) (.*)$/ + let secMatch = secRegex.exec(line); + if (secMatch) { + output.format = "seconds"; + output.time = secMatch[1]; + let secContent = secMatch[2]; + + // A seconds output has two different kinds of formats: The first is a + // log level and source location and has a format of + // "Level File.cpp:123: message" + const secLogRegex = /^(\w+) (\w+\.cpp:\d+): (.*)$/ + let secLogMatch = secLogRegex.exec(secContent); + if (secLogMatch) { + output.type = "log"; + output.level = secLogMatch[1]; + output.file = secLogMatch[2]; + output.message = secLogMatch[3]; + + // The other possibility is that the content is a generic message + } else { + output.type = "generic"; + output.message = secContent; + } + + // The second category of output starts with a date stamp of the format + // "yyyy-mm-dd hh:mm:ss message" + } else { + const dateRegex = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (.*)$/ + let dateMatch = dateRegex.exec(line); + if (dateMatch) { + output.format = "date"; + output.time = dateMatch[1]; + let dateContent = dateMatch[2]; + + // A date output has two general formats. The first is an action + // followed by a message and has a format of "[ACTION] message" + const dateActionRegex = /^\[(\w+)\] (.*)$/ + let dateActionMatch = dateActionRegex.exec(dateContent); + if (dateActionMatch) { + output.type = "action"; + output.action = dateActionMatch[1]; + output.message = dateActionMatch[2]; + + // The other format is a generic message + } else { + output.type = "generic"; + output.message = dateContent; + } + + // The last category of output is simply a generic message with no + // formating. + } else { + output.format = "none"; + output.type = "generic"; + output.message = line; + } + } + + return output; +} + +// These are filters applied to the output from Factorio. The filter +// syntax is a set of properties to test for in the parsed output. If +// the property is a RegExp it's tested using the RegExp. If it's an +// array it's tested using the includes method of the array. Otherwise +// the property must compare strictly equal. +// +// If the filter matches the action is called with the FactorioServer +// instance bound as this. +const outputHeuristics = [ + // Message indicating the RCON interface has started + { + filter: { type: 'log', message: /^Starting RCON interface/ }, + action: function(output) { + this._startRcon().catch((err) => { this.emit('error', err); }); + }, + }, + + // Message indicating the server is done starting up + { + filter: { type: 'log', message: /^updateTick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ }, + action: function(output) { + this._notifyGameReady().catch((err) => { this.emit('error', err); }); + } + }, + + // Message indicating the server has finished saving + { + filter: { + type: 'log', + message: [ + "Saving finished", + + // Outputted when saving failed from --server-start-load-scenario + "Can't save to default location: Default location not known", + ], + }, + action: function(output) { + this.emit('_saved'); + } + }, + + // Messages that might be tha cause of an unexpected shutdown. + { + filter: { + type: 'log', + level: 'Error', + message: [ + "MultiplayerManager failed: \"Binding IPv4 socket failed: Permission denied\"", + "MultiplayerManager failed: Host address is already in use.", + ], + }, + action: function(output) { + this._unexpected.push(`Factorio failed to bind to game port: ${output.message}`); + }, + }, + { + filter: { + type: 'log', + level: 'Error', + message: [ + "Can't bind socket: Address already in use", + "Can't bind socket: Permission denied", + ], + }, + action: function(output) { + this._unexpected.push(`Factorio failed to bind to RCON port: ${output.message}`); + }, + }, +]; + + +/** + * Factorio Server interface + * + * Handles the interactions with a Factorio server, including running, + * stopping and sending commands to the server. It does not deal with + * creating or managing servers, or downloading Factorio. + * + * This is an events emitter with the following events: + * stdout - invoked when Factorio outputs a line to stdout + * stderr - invoked when Factorio outputs a line to stderr + * output - invoked with parsed output from Factorio + * rcon-ready - invoked when the RCON client has connected + * game-ready - invoked when the server is finished starting up + * stopped - invoked when the sterver has exited + */ +class FactorioServer extends events.EventEmitter { + /** + * Create a Factorio server interface + * + * @param {string} dataDir - Data directory of the Factorio install. + * @param {string} writeDir - Directory to write runtime data to. + * @param {object} options - Optional parameters. + */ + constructor(dataDir, writeDir, options) { + super(); + + this._dataDir = dataDir; + this._writeDir = writeDir; + + this.gamePort = options.gamePort || randomDynamicPort(); + this.rconPort = options.rconPort || randomDynamicPort(); + this.rconPassword = options.rconPassword; + this._state = "new"; + this._server = null; + this._rconClient = null; + this._rconReady = false; + this._gameReady = false; + + // Array of possible causes for an unexpected shutdown + this._unexpected = []; + + this._stdout = new LineSplitter((line) => { this._handleOutput(line, 'stdout'); }); + this._stderr = new LineSplitter((line) => { this._handleOutput(line, 'stderr'); }); + } + + _check(expectedStates) { + if (!expectedStates.includes(this._state)) { + throw new Error( + `Expected state ${expectedStates} but state is ${this._state}` + ); + } + } + + _handleOutput(line, source) { + this.emit(source, line); + + let output = parseOutput(line.toString('utf-8'), source); + heuristicLoop: for (let heuristic of outputHeuristics) { + for (let [name, expected] of Object.entries(heuristic.filter)) { + if (!Object.hasOwnProperty.call(output, name)) { + continue heuristicLoop; + } + + if (expected instanceof RegExp) { + if (!expected.test(output[name])) { + continue heuristicLoop; + } + } else if (expected instanceof Array) { + if (!expected.includes(output[name])) { + continue heuristicLoop; + } + } else { + if (expected !== output[name]) { + continue heuristicLoop; + } + } + } + + // If we get here the filter matched the output + heuristic.action.call(this, output); + } + + this.emit('output', output); + } + + async _startRcon() { + if (this._state === "stopping") { + return; + } + + if (this._rconClient !== null) { + throw Error("RCON client is already started"); + } + + let config = { + host: "localhost", + port: this.rconPort, + password: this.rconPassword, + timeout: 200000, // 200s, should allow for commands up to 1250kB in length + maxPending: 5, + }; + + this._rconClient = new rconClient.Rcon(config); + this._rconClient.on('error', (err) => this.emit('error', err)); + this._rconClient.on('authenticated', () => { this._rconReady = true; this.emit('rcon-ready'); }); + this._rconClient.on('end', () => {/* XXX TODO */}); + await this._rconClient.connect(); + } + + /** + * Initialize class instance + * + * Must be called before instances of this class can be used. + */ + async init() { + this._check(["new"]); + this._version = await getVersion(this.dataPath("changelog.txt")); + this.rconPassword = this.rconPassword || await generatePassword(10); + this._state = "init"; + } + + _attachStdio() { + this._server.stdout.on('data', chunk => { this._stdout.data(chunk); }); + this._server.stdout.on('close', () => { this._stdout.end(); }); + this._server.stderr.on('data', chunk => { this._stderr.data(chunk); }); + this._server.stderr.on('close', () => { this._stderr.end(); }); + } + + _watchExit() { + this._server.on('exit', (code, signal) => { + if (this._state !== "stopping") { + if (this._unexpected.length === 0) { + this.emit('error', new Error("Factorio server unexpectedly shut down")); + + } else if (this._unexpected.length === 1) { + this.emit('error', new Error(this._unexpected[0])); + + } else { + this.emit('error', new Error( + "Factorio server unexpectedly shut down. Possible causes:\n- "+ + this._unexpected.join("\n- ") + )); + } + } + + // Reset server state + this._state = "init"; + this._server = null; + this._rconClient = null; + this._rconReady = false; + this._gameReady = false; + this._unexpected = []; + + this.emit('stopped'); + }); + } + + async _notifyGameReady() { + this._gameReady = true; + this.emit('game-ready'); + } + + async _waitForReady() { + if (!this._gameReady) { + await events.once(this, 'game-ready'); + } + } + + /** + * Create a new save + * + * Spawns the Factorio server with the --create argument to create a new + * map save with the given name. + * + * @param {string} name - + * Name of the save to create. Should end with ".zip". + */ + async create(name) { + this._check(["init"]); + this._state = "create"; + + await this._writeConfigIni(); + this._server = child_process.spawn( + this.dataPath('..', 'bin', 'x64', 'factorio'), + [ + '--config', this.writePath('config.ini'), + '--create', this.writePath('saves', name), + ], + { + detached: true, + stdio: 'pipe', + } + ); + + this._attachStdio(); + + try { + let [code, signal] = await events.once(this._server, 'exit'); + if (code !== 0) { + throw new Error(`Factorio exited with status ${code}`); + } + } finally { + this._state = "init"; + } + } + + /** + * Start server + * + * Spawn the Factorio server with the --start-server argument to + * start the given save + * + * @param {string} save - Name of the save to run. + */ + async start(save) { + this._check(["init"]); + this._state = "running"; + + await this._writeConfigIni(); + this._server = child_process.spawn( + this.dataPath('..', 'bin', 'x64', 'factorio'), + [ + '--config', this.writePath('config.ini'), + '--start-server', this.writePath('saves', save), + '--port', this.gamePort, + '--rcon-port', this.rconPort, + '--rcon-password', this.rconPassword, + ], + { + detached: true, + stdio: 'pipe', + } + ); + + this._attachStdio(); + this._watchExit(); + await this._waitForReady(); + } + + /** + * Start scenario + * + * Spawn the Factorio server with the --start-server-load-scenario + * argument to start the given scenario. + * + * @param {string} scenario - Name of the scenario to run. + */ + async startScenario(scenario) { + this._check(["init"]); + this._state = "running"; + + await this._writeConfigIni(); + this._server = child_process.spawn( + this.dataPath('..', 'bin', 'x64', 'factorio'), + [ + '--config', this.writePath('config.ini'), + '--start-server-load-scenario', scenario, + '--port', this.gamePort, + '--rcon-port', this.rconPort, + '--rcon-password', this.rconPassword, + ], + { + detached: true, + stdio: 'pipe', + } + ); + + this._attachStdio(); + this._watchExit(); + await this._waitForReady(); + } + + + /** + * Send message over RCON + * + * If the rcon connection hasn't been established yet, this will + * wait until it is establied and then send the message. + * + * @param {string} message - message to send to server over RCON. + * @returns {string} response from server. + */ + async sendRcon(message) { + this._check(["init", "running"]) + if (!this._rconReady) { + await events.once(this, 'rcon-ready'); + } + + return await this._rconClient.send(message); + } + + /** + * Stop the server + * + * Send stop signal to the server process and wait for it to shut + * down. + */ + async stop() { + this._check(["running"]); + + // On Windows we need to save the map as there's no graceful shutdown. + if (this._rconClient && process.platform === "win32") { + let saved = events.once(this, '_saved'); + await this.sendRcon("/server-save"); + await saved; + } + + this._state = "stopping"; + if (this._rconClient) { + await this._rconClient.end(); + + // Unfortunately version 4.1.0 of rcon-client doesn't send an + // end event on .end() and causes an error if the close operation + // fails. Wait a bit here to ensure it's closed before killing + // Factorio. This is only an issue on Windows where Factorio is + // terminated without cleanup. + await new Promise((resolve, reject) => { + setTimeout(resolve, 100); + }); + } + + // On linux this sends SIGTERM to the process. On windows this + // will terminate the process with no cleanup. You can send your + // complaint for the lack of graceful shutdown to the Factorio + // developers. Rcon does not recognize /quit, stdin is not + // recognized, and there's no "send CTRL+C to process" on Windows. + this._server.kill(); + await events.once(this._server, 'exit'); + } + + /** + * Disable achievements on the running server + * + * Ensures achievements are disabled on the save that's running. This is + * necessary in order to run any commands at all. + * + * @returns {boolean} + * True if acheivements got disabled and false if they already where + * disabled. + */ + async disableAchievements() { + this._check(["running"]); + + let check = await this.sendRcon("/sc rcon.print('disabled')"); + if (check === 'disabled\n') { + return false; + } + + // Factorio will print a warning to the console and ask for the command + // to be runned again before disabling achievements and allowing commands. + check = await this.sendRcon("/sc rcon.print('disabled')"); + if (check === 'disabled\n') { + return true; + } + + throw new Error("An error occured trying to disable acheivements"); + } + + /** + * Return path in data directory + * + * Creates a path using path.join with the given parts that's relative to + * the data directory of the Factorio server. + */ + dataPath(...parts) { + return path.join(this._dataDir, ...parts); + } + + /** + * Return path in write directory + * + * Creates a path using path.join with the given parts that's relative to + * the write directory of the Factorio server. + */ + writePath(...parts) { + return path.join(this._writeDir, ...parts); + } + + /** + * Get example server settings + * + * Loads server-settings.example.json from the data dir. + * + * @returns {object} the parsed server-settings. + */ + async exampleSettings() { + return JSON.parse(await fs.readFile(this.dataPath("server-settings.example.json"), "utf-8")); + } + + async _writeConfigIni() { + let content = ini.encode({ + path: { + "read-data": this.dataPath(), + "write-data": this.writePath(), + } + }); + await fs.outputFile(this.writePath("config.ini"), content); + } +} + + +module.exports = { + FactorioServer, + + // For testing only + _getVersion: getVersion, + _randomDynamicPort: randomDynamicPort, + _generatePassword: generatePassword, + _LineSplitter: LineSplitter, + _parseOutput: parseOutput, +}; diff --git a/lib/factorioServer/index.js b/lib/factorioServer/index.js deleted file mode 100644 index b6e175f8..00000000 --- a/lib/factorioServer/index.js +++ /dev/null @@ -1,167 +0,0 @@ -/** - -class factorioServer - -This class has the responsiblity of : - -* spawning a factorio server -* Handling input via stdin or RCON - - -*/ - -const fs = require("fs"); -const child_process = require("child_process"); -const path = require("path"); - -const mkdirp = require("mkdirp"); - -const fileOps = require("lib/fileOps"); - -module.exports = class factorioServer { - constructor({ - factorioDirectory = "factorio", - instanceDirectory = "instances/data", - instanceName = "Default server name", - factorioPort = Math.floor(Math.random() * 65535), - serversettings = {}, - onProgress = function(type, a, b, c){ - if(type == "progress") console.log("Progress: ",a," of ",b," unit: ",c); - if(type == "log") console.log(a); - }, - } = {}){ - this.factorioDirectory = factorioDirectory; - this.instanceDirectory = instanceDirectory; - this.instanceName = instanceName; - this.factorioPort = factorioPort; - this.serversettings = Object.assign({ - "name": "Clusterio instance: " + this.instanceName, - "description": "Public factorio server running clusterio", - "tags": ["clusterio"], - "max_players": "20", - "visibility": "public", - "username": "", - "token": "", - "game_password": "", - "verify_user_identity": true, - "admins": ["Danielv123"], - "allow_commands": true, - "autosave_interval": 10, - "autosave_slots": 5, - "afk_autokick_interval": 0, - "auto_pause": false, - }, serversettings); - this.onProgress = (type, a, b, c) => { - if(typeof onProgress == "function") onProgress(type, a, b, c); - }; - } - async initialize(){ - if(!await this.isValidInstance()){ - await this.createInstance(); - } - return true; - } - async createInstance(path = this.instanceDirectory){ - if(await this.isValidInstance(path)){ - throw new Error("Instance already exists!"); - } - - // create folders - let folders = [ - "/script-output/", - "/saves/", - "/mods/", - "/instanceMods/", - ]; - mkdirp.sync(path); - folders.forEach(folder => mkdirp.sync(path + folder)); - - // create files - let files = [ - {name: "/script-output/output.txt", content: ""}, - {name: "/script-output/orders.txt", content: ""}, - {name: "/script-output/txbuffer.txt", content: ""}, - {name: "/config.ini", content: "[path]\r\n\ read-data=__PATH__executable__/../../data\r\n\ write-data=__PATH__executable__/../../../" + this.instanceDirectory + "\r\n\ "}, - {name: "/config.json", content: JSON.stringify({ - factorioPort: this.factorioPort, - clientPassword: Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 8), - }, null, 4)}, - {name: "/server-settings.json", content: JSON.stringify(this.serversettings, null, 4)}, - ]; - files.forEach(file => { - fs.writeFile(path + file.name, file.content, doneWritingHandler); - // console.log("Created: ",path+file.name); - }); - - let filesToWrite = files.length; - function doneWritingHandler(){ - if(!--filesToWrite){ - return path; - } - } - } - async deleteInstance(path = this.instanceDirectory){ - if(await this.isValidInstance(path)){ - await fileOps.deleteFolderRecursive(path); - return true; - } else { - return false; - } - } - isValidInstance(path = this.instanceDirectory){ - return new Promise((resolve, reject) => { - fs.stat(path, (err, stat) => { - if(err){ - // console.log(err) - resolve(false); - } else { - // the instance folder exists, do some more checks - fs.readdir(path, (err, files) => { - if(files && Array.isArray(files)){ - let foldersThatShouldBeThere = [ - "script-output", - "saves", - "mods", - "instanceMods", - ]; - let instanceValidity = true; - foldersThatShouldBeThere.forEach(folder => { - if(!files.includes(folder)){ - instanceValidity = false; - } - }); - resolve(instanceValidity); - } - }); - } - }); - }); - } - createMap(name = "map.zip"){ - return new Promise((resolve, reject) => { - this.isValidInstance().then(isValid => { - if(!isValid){ - return createInstance(); - } - }).then(()=>{ - this.onProgress("log", "Starting map creation"); - // console.log(this.factorioDirectory + '/bin/x64/factorio') - let proc = child_process.spawn( - path.join(this.factorioDirectory, 'bin', 'x64', 'factorio'), [ - '-c', path.join(this.instanceDirectory,'config.ini'), - '--create', path.join(this.instanceDirectory, 'saves',name), - '--server-settings', path.join(this.instanceDirectory, 'server-settings.json'), - ],{ - cwd:process.cwd(), - } - ); - // console.log(proc.stdout.toString("utf8")); - proc.stdout.on("data", data => this.onProgress("log", data.toString("utf8"))); - proc.on("close", code => { - this.onProgress("log", "Map created! "+code); - resolve(); - }); - }); - }); - } -} diff --git a/lib/fileOps/fileOps.js b/lib/fileOps/fileOps.js index 014f5577..1bcbe039 100644 --- a/lib/fileOps/fileOps.js +++ b/lib/fileOps/fileOps.js @@ -1,44 +1,28 @@ -const fs = require("fs"); +const fs = require("fs-extra"); const path = require("path"); -// gets newest file in a directory -// dir is directory -// files is array of filenames -// callback(err, filename string); -var getNewestFile = function(dir, files, callback) { - if (!callback) throw new Error("Missing callback"); - if (!files || (files && files.length === 0)) { - callback(new Error("No files")); - return; - } - // we are appending the file name to the directory, without the - // "/" we would get paths like /folderfile.txt instead of /folder/file.txt - if(dir[dir.length-1] != "/"){ - dir += "/"; - } - var newest = { file: files[0] }; - var checked = 0; - fs.stat(dir + newest.file, function(err, stats) { - if(err) { - callback(err); - } else { - newest.mtime = stats.mtime; - for (var i = 0; i < files.length; i++) { - var file = files[i]; - (function(file) { - fs.stat(file, function(err, stats) { - ++checked; - if (stats.mtime.getTime() > newest.mtime.getTime()) { - newest = { file : file, mtime : stats.mtime }; - } - if (checked == files.length) { - callback(undefined,newest); - } - }); - })(dir + file); +/** + * Returns the newest file in a directory + * + * @param {string} directory - The directory to look for the newest file in. + * @returns {string|null} + * Name of the file with the newest timestamp or null if the + * directory contains no files. + */ +async function getNewestFile(directory) { + let newestTime = new Date(0); + let newestFile = null; + for (let entry of await fs.readdir(directory, { withFileTypes: true })) { + if (entry.isFile()) { + let stat = await fs.stat(path.join(directory, entry.name)); + if (stat.mtime > newestTime) { + newestTime = stat.mtime; + newestFile = entry.name; } } - }); + } + + return newestFile; } // get names of all directories in directory diff --git a/lib/getFactorioLocale.js b/lib/getFactorioLocale.js deleted file mode 100644 index 72d84dd0..00000000 --- a/lib/getFactorioLocale.js +++ /dev/null @@ -1,53 +0,0 @@ -/** -Module for grabbing factorio locale from a local factorio install -@module getFactorioLocale -*/ - -const fs = require("fs"); -const ini = require("ini"); - -const cache = {}; - -/** -Gets raw factorio locale from the .ini file. -Not available outside of module through exports. - -@memberof getFactorioLocale -@inner -@param {string} factorioDirectory -@param {string} languageCode -@param {function} callback -*/ -function getLocale(factorioDirectory, languageCode, callback){ - var localeFile = factorioDirectory + '/data/base/locale/' + languageCode + '/base.cfg'; - fs.readFile(localeFile, "utf8", (err, rawLocale) => { - callback(err, rawLocale); - }); -} - -/** -Gets factorios locale as an object, does not respect mods. - -@param {string} factorioDirectory -@param {string} languageCode -@param {function} callback -*/ -module.exports.asObject = function getLocaleAsObject(factorioDirectory, languageCode, callback){ - if(typeof factorioDirectory != "string" || typeof languageCode != "string" || typeof callback != "function"){ - throw new Error("Error: wrong parameters provided"); - } - - if(!cache[languageCode]){ - getLocale(factorioDirectory, languageCode, (err, rawLocale) => { - if(err){ - callback(err); - } else { - let processedLocale = ini.parse(rawLocale); - cache[languageCode] = processedLocale; - callback(undefined, processedLocale); - } - }); - } else { - callback(undefined, cache[languageCode]); - } -} diff --git a/lib/stringUtils.js b/lib/stringUtils.js deleted file mode 100644 index 7febd82b..00000000 --- a/lib/stringUtils.js +++ /dev/null @@ -1,13 +0,0 @@ -function hashCode(string){ - if(typeof string !== "string") throw "ERROR stringUtils: Not a string"; - var hash = 0; - if (string.length == 0) return hash; - for (let i = 0; i < string.length; i++) { - hash = ((hash << 5) - hash) + string.charCodeAt(i); - hash = hash & hash; // Convert to 32bit integer - } - return hash; -} -module.exports = { - hashCode: hashCode, -} \ No newline at end of file diff --git a/master.js b/master.js index e0a1986c..541752b7 100644 --- a/master.js +++ b/master.js @@ -32,9 +32,9 @@ let config = {}; // homebrew modules const generateSSLcert = require("lib/generateSSLcert"); -const getFactorioLocale = require("lib/getFactorioLocale"); const pluginManager = require("lib/manager/pluginManager.js"); const database = require("lib/database"); +const factorio = require("lib/factorio"); // homemade express middleware for token auth const authenticate = require("lib/authenticate"); @@ -670,19 +670,26 @@ app.post("/api/runCommand", authenticate.middleware, function(req,res) { res.status(400).send({auth: true, message: "Error: invalid request.body"}); } }); + +var localeCache; /** GET endpoint. Returns factorio's base locale as a JSON object. @memberof clusterioMaster @instance @alias api/getFactorioLocale -@returns {object{}} 2 deep nested object with base game factorio locale as key:value pairs +@returns {object} 2 deep nested object with base game factorio locale as key:value pairs */ app.get("/api/getFactorioLocale", function(req,res){ endpointHitCounter.labels(req.route.path).inc(); - getFactorioLocale.asObject(config.factorioDirectory, "en", (err, factorioLocale) => { - res.send(factorioLocale); - }); + if (!localeCache) { + factorio.getLocale(path.join(config.factorioDirectory, "data"), "en").then(locale => { + localeCache = locale; + res.send(localeCache); + }); + } else { + res.send(localeCache); + } }); /* Websockets for plugins */ diff --git a/package.json b/package.json index 8570fbce..50420458 100644 --- a/package.json +++ b/package.json @@ -55,14 +55,12 @@ "mkdirp": "^0.5.1", "mkdirp-promise": "^5.0.1", "moment": "^2.22.2", - "ncp": "^2.0.0", "needle": "^2.4.0", "node-factorio-api": "^0.3.8", "node-forge": "^0.7.1", "prom-client": "^10.2.3", - "rcon-client-fork": "^3.2.0", + "rcon-client": "^4.0.4", "request": "^2.83.0", - "rmdir-sync": "^1.0.1", "sanitizer": "^0.1.3", "simple-git": "^1.96.0", "single-line-log": "^1.1.2", diff --git a/sharedPlugins/clusterioMod/index.js b/sharedPlugins/clusterioMod/index.js index 7c2cc492..1a7648c1 100644 --- a/sharedPlugins/clusterioMod/index.js +++ b/sharedPlugins/clusterioMod/index.js @@ -3,15 +3,23 @@ const fs = require("fs-extra"); const objectOps = require("lib/objectOps.js"); const fileOps = require("lib/fileOps"); -const stringUtils = require("lib/stringUtils.js"); const checkHotpatchInstallation = require("lib/clusterTools/checkHotpatchInstallation"); const getLua = require("lib/clusterTools/getLua"); const pluginConfig = require("./config"); const COMPRESS_LUA = false; +function ensureFileSync(path) { + if (!fs.existsSync(path)) { + fs.outputFileSync(path, ""); + } +} + module.exports = class remoteCommands { constructor(mergedConfig, messageInterface, extras){ + // Ugly global + global.confirmedOrders = []; + this.messageInterface = messageInterface; this.config = mergedConfig; this.socket = extras.socket; @@ -27,6 +35,10 @@ module.exports = class remoteCommands { 'x-access-token': this.config.masterAuthToken }, }; + + ensureFileSync(instancedirectory + "/script-output/output.txt"); + ensureFileSync(instancedirectory + "/script-output/orders.txt"); + ensureFileSync(instancedirectory + "/script-output/txbuffer.txt"); (async ()=>{ let hotpatchInstallStatus = await checkHotpatchInstallation(this.messageInterface); diff --git a/test/client.js b/test/client.js index 0260174d..9bc5b6d4 100644 --- a/test/client.js +++ b/test/client.js @@ -1,5 +1,6 @@ const assert = require('assert').strict; const path = require('path'); +const fs = require('fs-extra'); const client = require("../client"); @@ -71,38 +72,59 @@ describe("Client testing", function() { }); }); - describe("randomDynamicPort()", function() { - it("should return a port number", function() { - let port = client._randomDynamicPort() - assert.equal(typeof port, "number"); - assert(Number.isInteger(port)); - assert(0 <= port && port < 2**16); + describe("symlinkMods()", function() { + // Remove previous test output + let testDir = path.join("test", "temp", "symlink"); + fs.removeSync(testDir); + + // Add some test mods + fs.outputFileSync(path.join(testDir, "shared", "mod_a.zip"), "a"); + fs.outputFileSync(path.join(testDir, "shared", "mod_b.zip"), "b"); + fs.outputFileSync(path.join(testDir, "shared", "mod.dat"), "c"); + let instance = new client._Instance(path.join(testDir, "instance"), "test"); + fs.outputFileSync(instance.path("mods", "mod_i.zip"), "i"); + + let discardingLogger = { + warning: function() { }, + log: function() { }, + } + + it("should link mods and data files", async function() { + await client._symlinkMods(instance, path.join(testDir, "shared"), discardingLogger); + + assert.equal(await fs.readFile(instance.path("mods", "mod_a.zip"), "utf-8"), "a"); + assert.equal(await fs.readFile(instance.path("mods", "mod_b.zip"), "utf-8"), "b"); + assert.equal(await fs.readFile(instance.path("mods", "mod.dat"), "utf-8"), "c"); + assert.equal(await fs.readFile(instance.path("mods", "mod_i.zip"), "utf-8"), "i"); }); - it("should return a port number in the dynamic range", function() { - function validate(port) { - return 49152 <= port && port <= 65535; + it("should ignore directories", async function() { + await fs.ensureDir(path.join(testDir, "shared", "dir")); + await client._symlinkMods(instance, path.join(testDir, "shared"), discardingLogger); + + assert(!await fs.exists(instance.path("mods", "dir"), "utf-8"), "dir was unxpectedly linked"); + }); + + it("should ignore files", async function() { + await fs.outputFile(path.join(testDir, "shared", "file"), "f"); + await client._symlinkMods(instance, path.join(testDir, "shared"), discardingLogger); + + assert(!await fs.exists(instance.path("mods", "file"), "utf-8"), "dir was unxpectedly linked"); + }); + + it("should unlink removed mods", async function() { + // This does not work on Windows + if (process.platform == "win32") { + this.skip(); } - for (let i=0; i < 20; i++) { - assert(validate(client._randomDynamicPort())); - } - }); - }); - describe("generatePassword()", function() { - it("should return a string", async function() { - let password = await client._generatePassword(1); - assert.equal(typeof password, "string"); - }); + await fs.unlink(path.join(testDir, "shared", "mod_a.zip")); + await client._symlinkMods(instance, path.join(testDir, "shared"), discardingLogger); - it("should return a string of the given length", async function() { - let password = await client._generatePassword(10); - assert.equal(password.length, 10); - }); - - it("should contain only a-z, A-Z, 0-9", async function() { - let password = await client._generatePassword(10); - assert(/^[a-zA-Z0-9]+$/.test(password), `${password} failed test`); + await assert.rejects(fs.lstat(instance.path("mods", "mod_a.zip")), { code: "ENOENT" }); + assert.equal(await fs.readFile(instance.path("mods", "mod_b.zip"), "utf-8"), "b"); + assert.equal(await fs.readFile(instance.path("mods", "mod.dat"), "utf-8"), "c"); + assert.equal(await fs.readFile(instance.path("mods", "mod_i.zip"), "utf-8"), "i"); }); }); }); diff --git a/test/file/changelog-bad.txt b/test/file/changelog-bad.txt new file mode 100644 index 00000000..6ee53144 --- /dev/null +++ b/test/file/changelog-bad.txt @@ -0,0 +1,4 @@ +--------------------------------------------------------------------------------------------------- +Date: 04. 09. 2019 + Changes: + - Bad changelog with no version diff --git a/test/file/changelog-test.txt b/test/file/changelog-test.txt new file mode 100644 index 00000000..145dfbed --- /dev/null +++ b/test/file/changelog-test.txt @@ -0,0 +1,13 @@ +--------------------------------------------------------------------------------------------------- +NotVersion: Wrong +Version: 0.1.1 +Date: 04. 09. 2019 + Changes: + - Test for changelog + Bugfixes: + - Has the same format as factorio's changelog +--------------------------------------------------------------------------------------------------- +Version: 0.1.0 +Date: 03. 09. 2019 + Changes: + - Nothing diff --git a/test/file/factorio/data/base/locale/en/base.cfg b/test/file/factorio/data/base/locale/en/base.cfg new file mode 100644 index 00000000..d5ee03af --- /dev/null +++ b/test/file/factorio/data/base/locale/en/base.cfg @@ -0,0 +1,4 @@ +; Test data for getLocale +[test] +key-a=1 +key-b=2 diff --git a/test/file/factorio/data/changelog.txt b/test/file/factorio/data/changelog.txt new file mode 100644 index 00000000..2eee5770 --- /dev/null +++ b/test/file/factorio/data/changelog.txt @@ -0,0 +1,3 @@ +--------------------------------------------------------------------------------------------------- +Version: 0.1.1 +About: Dummy changelog.txt for FactorioServer tests diff --git a/test/integration/locale.js b/test/integration/locale.js new file mode 100644 index 00000000..8c42981e --- /dev/null +++ b/test/integration/locale.js @@ -0,0 +1,62 @@ +const assert = require("assert").strict; +const path = require("path"); + +const factorio = require("lib/factorio"); + + +describe("Integration of lib/factorio/locale", function() { + describe("getLocale()", function() { + let locale; + before(async function() { + locale = await factorio.getLocale(path.join("factorio", "data"), "en"); + }); + + it("returns a nested object with base game locale information", async function() { + assert.equal(typeof locale, "object"); + assert.equal(locale["entity-name"]["fish"], "Fish"); + assert.equal(locale["entity-name"]["small-lamp"], "Lamp"); + let empty_keys = 0; + Object.keys(locale).forEach(key => { + // first level of the nested object is always an object + assert.equal(typeof locale[key], "object"); + + Object.keys(locale[key]).forEach(key2 => { + // second level of the nested object is always a string, nearly always truthy + if (!locale[key][key2]) { + empty_keys++; + } + assert.equal(typeof locale[key][key2], "string"); + }); + + }); + assert(empty_keys < 10, `Got unusually high amount (${empty_keys}) of empty keys`); + }); + + it("has some duplicate keys", async function() { + let arrayOfKeys = []; + // collect all keys in a single array + Object.keys(locale).forEach(key => { + arrayOfKeys.push(key); + Object.keys(locale[key]).forEach(key2 => { + arrayOfKeys.push(key2); + }); + }); + + // check for duplicates + let duplicateKeys = []; + let i = 0; + arrayOfKeys.forEach(key => { + let o = 0; + arrayOfKeys.forEach(key2 => { + if(i != o && key == key2){ + duplicateKeys.push(key); + } + o++ + }); + i++; + }); + // we got 704 but this is a very unexact number + assert(duplicateKeys.length > 10) + }); + }); +}); diff --git a/test/integration/server.js b/test/integration/server.js new file mode 100644 index 00000000..b1213be2 --- /dev/null +++ b/test/integration/server.js @@ -0,0 +1,169 @@ +const assert = require("assert").strict; +const fs = require("fs-extra"); +const path = require("path"); + +const factorio = require("lib/factorio"); + + +describe("Integration of lib/factorio/server", function() { + describe("_getVersion()", function() { + it("should get a version from factorio's changelog.txt", async function() { + let version = await factorio._getVersion(path.join("factorio", "data", "changelog.txt")); + if (!/^\d+\.\d+\.\d+$/.test(version)) { + assert.fail(`Detected version '${version}' does not followed the format x.y.z`); + } + }); + }); + + describe("class FactorioServer", function() { + let writePath = path.join("test", "temp", "integration"); + let server = new factorio.FactorioServer(path.join("factorio", "data"), writePath, {}); + let logFile; + + before(async function() { + // Delete result from previous run of these tests + if (fs.existsSync(writePath)) { + await fs.remove(writePath); + } + + await fs.ensureDir(writePath); + logFile = fs.createWriteStream(path.join(writePath, "log.txt"), "utf8"); + server.on('output', function(output) { + logFile.write(JSON.stringify(output) + "\n"); + }); + + // Initialize sever. + await server.init(); + }); + + after(function() { + logFile.end(); + }); + + describe(".exampleSettings()", function() { + let settings; + it("returns an object", async function() { + settings = await server.exampleSettings(); + assert(typeof settings === "object"); + }); + + it("contains the settings used by Clusterio", async function() { + let keysUsed = new Set([ + "name", "description", "tags", "max_players", "visibility", "username", "token", + "game_password", "require_user_verification", "allow_commands", "autosave_interval", + "autosave_slots", "afk_autokick_interval", "auto_pause", + ]); + + for (let key of Object.keys(settings)) { + keysUsed.delete(key); + } + + assert( + keysUsed.size === 0, + `Factorio's server-settings.example.json does not contain the key(s) ${[...keysUsed]}` + ); + }); + }); + + // Mark that this test takes a lot of time, or depeneds on a test + // that takes a lot of time. + function slowTest(test) { + if (process.env.FAST_TEST) { + test.skip(); + } + + test.timeout(20000); + } + + function log(message) { + logFile.write("=================== " + message + "\n"); + } + + describe(".create()", function() { + it("creates a map file at writeDir/saves/name", async function() { + slowTest(this); + log(".create() with new save"); + + // Make sure the test is not fooled by previous data + let mapPath = server.writePath("saves", "test.zip"); + assert(!await fs.exists(mapPath), "save exist before test"); + + await server.create("test.zip"); + assert(await fs.exists(mapPath), "test did not create save"); + }); + }); + + describe(".start()", function() { + it("starts the server", async function() { + slowTest(this); + log(".start()"); + + // Make sure the test does not fail due to create() failing. + let mapPath = server.writePath("saves", "test.zip"); + assert(await fs.exists(mapPath), "save is missing"); + + await server.start("test.zip"); + }); + }); + + describe(".disableAchievments()", function() { + it("disables acheivements", async function() { + slowTest(this); + log(".disableAchievements()"); + assert.equal(await server.disableAchievements(), true); + }); + + it("can tell when acheivements were disabled", async function() { + slowTest(this); + assert.equal(await server.disableAchievements(), false); + }); + }); + + describe(".sendRcon()", function() { + it("returns the result of a command", async function() { + slowTest(this); + log(".sendRcon()"); + + let result = await server.sendRcon("/sc rcon.print('success')"); + assert.equal(result, 'success\n'); + }); + }); + + describe(".stop()", function() { + it("stops the server", async function() { + slowTest(this); + log(".stop()"); + + await server.stop(); + }); + }); + + describe(".startScenario()", function() { + before("Write test_scenario", async function() { + let content = "script.on_init(function() print('test_scenario init') end)\n"; + await fs.outputFile(server.writePath("scenarios", "test_scenario", "control.lua"), content); + }); + + it("runs the given scenario", async function() { + slowTest(this); + log(".startScenario()"); + + let pass = false + function filter(output) { + if (output.message === "test_scenario init") { + pass = true; + } + } + server.on('output', filter); + + await server.startScenario("test_scenario"); + + log(".stop()"); + await server.stop(); + + server.off('output', filter); + assert(pass, "server did not output line from test scenario"); + }); + }); + }); +}); diff --git a/test/lib/factorio/locale.js b/test/lib/factorio/locale.js new file mode 100644 index 00000000..f9322ba2 --- /dev/null +++ b/test/lib/factorio/locale.js @@ -0,0 +1,18 @@ +const assert = require("assert").strict; +const path = require("path"); + +const factorio = require("lib/factorio"); + + +describe("lib/factorio/locale", function() { + describe("getLocale()", function() { + it("verifies types of the arguments", async function() { + await assert.rejects(factorio.getLocale(1, "a"), new TypeError("dataDir must be a string")); + await assert.rejects(factorio.getLocale("a", 1), new TypeError("languageCode must be a string")); + }); + it("returns a nested object with locale definitions", async function() { + let locale = await factorio.getLocale(path.join("test", "file", "factorio", "data"), "en"); + assert.deepEqual(locale, { test: { "key-a": "1", "key-b": "2" } }); + }); + });; +}); diff --git a/test/lib/factorio/server.js b/test/lib/factorio/server.js new file mode 100644 index 00000000..c87dbc31 --- /dev/null +++ b/test/lib/factorio/server.js @@ -0,0 +1,208 @@ +const assert = require("assert").strict; +const fs = require("fs-extra"); +const path = require("path"); + +const factorio = require("lib/factorio"); + + +describe("lib/factorio/server", function() { + describe("_getVersion()", function() { + it("should get the version from a changelog", async function() { + let version = await factorio._getVersion(path.join("test", "file", "changelog-test.txt")); + assert.equal(version, "0.1.1"); + }); + it("should throw if unable to find the version", async function() { + await assert.rejects( + factorio._getVersion(path.join("test", "file", "changelog-bad.txt")), + new Error("Unable to determine the version of Factorio") + ); + }); + }); + + describe("randomDynamicPort()", function() { + it("should return a port number", function() { + let port = factorio._randomDynamicPort() + assert.equal(typeof port, "number"); + assert(Number.isInteger(port)); + assert(0 <= port && port < 2**16); + }); + + it("should return a port number in the dynamic range", function() { + function validate(port) { + return 49152 <= port && port <= 65535; + } + for (let i=0; i < 20; i++) { + assert(validate(factorio._randomDynamicPort())); + } + }); + }); + + describe("generatePassword()", function() { + it("should return a string", async function() { + let password = await factorio._generatePassword(1); + assert.equal(typeof password, "string"); + }); + + it("should return a string of the given length", async function() { + let password = await factorio._generatePassword(10); + assert.equal(password.length, 10); + }); + + it("should contain only a-z, A-Z, 0-9", async function() { + let password = await factorio._generatePassword(10); + assert(/^[a-zA-Z0-9]+$/.test(password), `${password} failed test`); + }); + }); + + describe("class LineSplitter", function() { + function createSplitter(lines) { + return new factorio._LineSplitter(line => lines.push(line.toString("utf-8"))); + } + + it("should split three lines", function() { + let lines = []; + let ls = createSplitter(lines); + ls.data(Buffer.from("line 1\nline 2\nline 3\n")); + ls.end(); + assert.deepEqual(lines, ["line 1", "line 2", "line 3"]); + }); + it("should split three Windows line endings lines", function() { + let lines = []; + let ls = createSplitter(lines); + ls.data(Buffer.from("line 1\r\nline 2\r\nline 3\r\n")); + assert.deepEqual(lines, ["line 1", "line 2", "line 3"]); + }); + it("should give the last non-terminated line on .end()", function() { + let lines = []; + let ls = createSplitter(lines); + ls.data(Buffer.from("line a\nline b")); + assert.deepEqual(lines, ["line a"]); + ls.end(); + assert.deepEqual(lines, ["line a", "line b"]); + }); + it("should handled partial lines", function() { + let lines = []; + let ls = createSplitter(lines); + ls.data(Buffer.from("part 1")); + ls.data(Buffer.from(" part 2 ")); + ls.data(Buffer.from("part 3\n")); + ls.end(); + assert.deepEqual(lines, ["part 1 part 2 part 3"]); + }); + }); + + describe("parseOutput()", function() { + let testLines = new Map([ + [ + " 1.306 Info RemoteCommandProcessor.cpp:131: Starting RCON interface at port 4000", + { + format: 'seconds', + time: '1.306', + type: 'log', + level: 'Info', + file: "RemoteCommandProcessor.cpp:131", + message: "Starting RCON interface at port 4000", + } + ], + [ + " 640.910 Quitting: remote-quit.", + { + format: 'seconds', + time: '640.910', + type: 'generic', + message: "Quitting: remote-quit.", + } + ], + [ + "1641.421 Goodbye", + { + format: 'seconds', + time: '1641.421', + type: 'generic', + message: "Goodbye", + } + ], + [ + "2019-01-20 10:30:04 [JOIN] User joined the game", + { + format: 'date', + time: '2019-01-20 10:30:04', + type: 'action', + action: "JOIN", + message: "User joined the game", + } + ], + [ + "2019-01-20 10:30:07 [CHAT] User: chat message", + { + format: 'date', + time: '2019-01-20 10:30:07', + type: 'action', + action: "CHAT", + message: "User: chat message", + } + ], + [ + "2019-01-20 10:30:14 [COMMAND] User (command): blah", + { + format: 'date', + time: '2019-01-20 10:30:14', + type: 'action', + action: "COMMAND", + message: "User (command): blah", + } + ], + [ + "2019-01-20 10:30:14 Cannot execute command. Error: [string \"blah\"]:1: syntax error near ", + { + format: 'date', + time: '2019-01-20 10:30:14', + type: 'generic', + message: "Cannot execute command. Error: [string \"blah\"]:1: syntax error near ", + } + ], + [ + "2019-01-20 10:30:21 [LEAVE] User left the game", + { + format: 'date', + time: '2019-01-20 10:30:21', + type: 'action', + action: "LEAVE", + message: "User left the game", + } + ], + [ + "Error while running event level::on_nth_tick(1)", + { + format: 'none', + type: 'generic', + message: "Error while running event level::on_nth_tick(1)", + } + ], + ]) + + it("should parse the test lines", function() { + for (let [line, reference] of testLines) { + reference.source = 'test'; + let output = factorio._parseOutput(line, 'test'); + delete output.received; + assert.deepEqual(output, reference); + } + }); + }); + + describe("class FactorioServer", function() { + let writePath = path.join("test", "temp", "should_not_exist"); + let server = new factorio.FactorioServer(path.join("test", "file", "factorio", "data"), writePath, {}); + + describe(".init()", function() { + it("should not throw on first call", async function() { + await server.init(); + }); + + it("should throw if called twice", async function() { + await assert.rejects(server.init(), new Error("Expected state new but state is init")); + }); + }); + }); +}); diff --git a/test/lib/factorioServer/index.js b/test/lib/factorioServer/index.js deleted file mode 100644 index 0203449d..00000000 --- a/test/lib/factorioServer/index.js +++ /dev/null @@ -1,115 +0,0 @@ -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); - -const parallel = require("mocha.parallel"); -const mkdirp = require("mkdirp"); - -const factorioServer = require("lib/factorioServer"); - -describe("class factorioServer/index.js", ()=>{ - it("is a class (or at least a function)", ()=>{ - assert.equal(typeof factorioServer, "function"); - }); - describe(".deleteInstance(instanceName)", ()=>{ - it("deletes an instance, defaulting to the classes instanceDirectory", async () => { - let server = new factorioServer({ - instanceDirectory: path.resolve(__dirname, "_testData_deleteInstance"), - instanceName: "Unit testing instance", - }); - await server.initialize(); - await server.deleteInstance(); - assert.equal(await server.isValidInstance(), false); - return true; - }); - }); - describe(".isValidInstance(instanceName)", ()=>{ - it("tells us whether an instance exists or not", async () => { - let server = new factorioServer({ - instanceDirectory: path.join(__dirname, "_testData_isValidInstance"), - instanceName: "Unit testing instance", - }); - await server.deleteInstance(); - let status = await server.isValidInstance(); - assert.equal(status, false); - let x = await server.initialize(); // creates our instance - let status2 = await server.isValidInstance(); - assert.equal(status2, true); - - await server.deleteInstance(); // clean up folder - return true; - }); - it("does not get tricked by an empty folder", async () => { - let server = new factorioServer({ - instanceDirectory: path.join(__dirname, "_testData_isValidInstance_2"), - instanceName: "Unit testing instance", - }); - assert.equal(await server.isValidInstance(), false); - - mkdirp.sync(path.join(__dirname, "_testData_isValidInstance_2")); // create empty folder simulating instance - assert.equal(await server.isValidInstance(), false); - - await server.initialize(); // create a proper instance - assert.equal(await server.isValidInstance(), true); - - await server.deleteInstance(); // delete the instance and ensure we see that it is in fact deleted - assert.equal(await server.isValidInstance(), false); - - return true; - }).timeout(5000); - }); - describe(".initialize()", ()=>{ - it("creates an instance", done => { - let server = new factorioServer({ - instanceDirectory: path.resolve(__dirname, "_testData_initialize"), - instanceName: "Unit testing instance", - }); - server.initialize().then(()=>{ - return server.deleteInstance(); - }).then(() => { - done(); - }).catch(e => console.log(e)); - }); - }); - describe(".createMap(mapName.zip)", ()=>{ - it("creates a map file as instanceDirectory/saves/map.zip", async function(){ - /* istanbul ignore if */ if (process.env.FAST_TEST) this.skip(); - this.timeout(20000); // these test often takes a lot of time in the map creation step (4500 ms on i3 dev system) - let relativePath = path.relative('.', __dirname); - let server = new factorioServer({ - instanceDirectory: path.join(relativePath, "_testData_createMap"), - instanceName: "Unit testing instance", - onProgress: false, - }); - await server.initialize(); - let maps = fs.readdirSync(path.join(relativePath, "_testData_createMap", "saves")); - assert.equal(maps.length, 0); - await server.createMap(); - let maps2 = fs.readdirSync(path.join(relativePath, "_testData_createMap", "saves")); - assert(maps2.includes("map.zip")); - - await server.deleteInstance(); - return true; - }); - it("supports custom filenames", async function(){ - /* istanbul igoner if */ if (process.env.FAST_TEST) this.skip(); - this.timeout(20000); - let relativePath = path.relative('.', __dirname); - let server = new factorioServer({ - instanceDirectory: path.join(relativePath, "_testData_createMap_2"), - instanceName: "Unit testing instance", - onProgress: false, - }); - await server.initialize(); - - let maps = fs.readdirSync(path.join(relativePath, "_testData_createMap_2", "saves")); - assert.equal(maps.length, 0); - await server.createMap("mapWithCustomName.zip"); - let maps2 = fs.readdirSync(path.join(relativePath, "_testData_createMap_2", "saves")); - assert(maps2.includes("mapWithCustomName.zip")); - - await server.deleteInstance(); - return true; - }); - }); -}); \ No newline at end of file diff --git a/test/lib/fileOps/fileOps.js b/test/lib/fileOps/fileOps.js index 7b9a66aa..74aadfee 100644 --- a/test/lib/fileOps/fileOps.js +++ b/test/lib/fileOps/fileOps.js @@ -1,40 +1,24 @@ -const assert = require('assert'); -const fs = require('fs'); +const assert = require('assert').strict; +const fs = require('fs-extra'); const path = require('path'); const fileOps = require('lib/fileOps'); -// create folders for testing -function setupTestingEnv(){ - if (!fs.existsSync(path.resolve(__dirname, "database"))){ - fs.mkdirSync(path.resolve(__dirname, "database")); - } - if (!fs.existsSync(path.resolve(__dirname, "test"))){ - fs.mkdirSync(path.resolve(__dirname, "test")); - } - if (!fs.existsSync(path.resolve(__dirname, "test/folder"))){ - fs.mkdirSync(path.resolve(__dirname, "test/folder")); - } - if (!fs.existsSync(path.resolve(__dirname, "test/another folder"))){ - fs.mkdirSync(path.resolve(__dirname, "test/another folder")); - } - if (!fs.existsSync(path.resolve(__dirname, "test/file.txt"))){ - fs.writeFileSync(path.resolve(__dirname, "test/file.txt"), "contents"); - } - if (!fs.existsSync(path.resolve(__dirname, "test/another file.txt"))){ - fs.writeFileSync(path.resolve(__dirname, "test/another file.txt"), "more contents"); - } - // we need this xfile to test a branch in fileOps.getNewestFile()s propagation - if (!fs.existsSync(path.resolve(__dirname, "test/xfile.txt"))){ - fs.writeFileSync(path.resolve(__dirname, "test/xfile.txt"), "contents"); - } -} -setupTestingEnv(); -setupTestingEnv(); // run twice to get branch coverage describe("fileOps.js", function(){ + let baseDir = path.join("test", "temp", "fileOps"); + async function setupTestingEnv() { + await fs.ensureDir(path.join(baseDir, "test", "folder")); + await fs.ensureDir(path.join(baseDir, "test", "another folder")); + + await fs.outputFile(path.join(baseDir, "test", "file.txt"), "contents"); + await fs.outputFile(path.join(baseDir, "test", "another file.txt"), "more contents"); + } + + before(setupTestingEnv); + describe("fileOps.getDirectoriesSync()", function(){ it("gets an array of directory names from a path", function(){ - let directories = fileOps.getDirectoriesSync(path.resolve(__dirname, "test")); + let directories = fileOps.getDirectoriesSync(path.join(baseDir, "test")); // because there are no arrays for some reason assert(typeof directories == "object"); @@ -43,7 +27,7 @@ describe("fileOps.js", function(){ assert(directories[0] == "another folder" && directories[1] == "folder"); }); it("does not return any filenames", function(){ - let directories = fileOps.getDirectoriesSync(path.resolve(__dirname, "test")); + let directories = fileOps.getDirectoriesSync(path.join(baseDir, "test")); // this is a function to return directories, not files. So yeah. assert(!directories.includes("file.txt") && !directories.includes("another file.txt")); @@ -56,7 +40,7 @@ describe("fileOps.js", function(){ }); describe("fileOps.getFileNamesSync(path)", function(){ it("gets names of files in directory", function(){ - let files = fileOps.getFileNamesSync(path.resolve(__dirname, "test")); + let files = fileOps.getFileNamesSync(path.join(baseDir, "test")); assert(files.length > 0); assert(files.includes("file.txt") && files.includes("another file.txt")); }); @@ -67,42 +51,20 @@ describe("fileOps.js", function(){ }); }); describe("fileOps.getNewestFile()", function(){ - it("gets the newest file in a directory async", function(done){ - fileOps.getNewestFile(path.resolve(__dirname, "test/"), fileOps.getFileNamesSync(path.resolve(__dirname, "test")), function(err, newest){ - assert(newest && typeof newest.file == "string"); - done(); - }); + it("returns a string in a directory with files", async function() { + let newest = await fileOps.getNewestFile(path.join(baseDir, "test")); + assert.equal(typeof newest, "string"); }); - it("throws if callback is missing", function(){ - assert.throws(function(){ - fileOps.getNewestFile("test", fileOps.getFileNamesSync(path.resolve(__dirname, "test"))); - }, Error); - }); - it("executes callback(err) if no files are provided", function(done){ - fileOps.getNewestFile(path.resolve(__dirname, "test"), [], function(err, newest){ - assert(err && typeof err == "object"); - done(); - }); - }); - it("executes callback(err) if launched without a directory", function(done){ - fileOps.getNewestFile("", fileOps.getFileNamesSync(path.resolve(__dirname, "test")), function(err, newest){ - assert(err); - done(); - }); - }); - it("executes callback(err) when you do stupid shit", function(done){ - fileOps.getNewestFile({thisWontWork: "but not crash either"}, {lolz:"yeah..."}, function(err, newest){ - assert(err); - // ENOENT: no such file or directory, stat 'C:\**\factorioClusterio\lib\test\undefined' - done(); - }); + it("returns null if directory is empty", async function() { + let newest = await fileOps.getNewestFile(path.join(baseDir, "test", "folder")); + assert.equal(newest, null); }); }); describe("fileOps.deleteFolderRecursiveSync(path)", function(){ it("deletes a folder and all files and folders in it", function(){ - let x = fileOps.deleteFolderRecursiveSync(path.resolve(__dirname, "test")); + let x = fileOps.deleteFolderRecursiveSync(path.join(baseDir, "test")); assert.throws(function(){ - fs.statSync(path.resolve(__dirname, "test")); + fs.statSync(path.join(baseDir, "test")); }, Error); assert(x); // give it some extra time because its sometimes very slow @@ -114,11 +76,11 @@ describe("fileOps.js", function(){ }); describe("fileOps.deleteFolderRecursive(path)", function(){ it("deletes a folder and all files and folders in it, just like sync version", async function(){ - setupTestingEnv(); - assert(fs.statSync(path.resolve(__dirname, "test"))); - let x = await fileOps.deleteFolderRecursive(path.resolve(__dirname, "test")); + await setupTestingEnv(); + assert(fs.statSync(path.join(baseDir, "test"))); + let x = await fileOps.deleteFolderRecursive(path.join(baseDir, "test")); assert.throws(function(){ - fs.statSync(path.resolve(__dirname, "test")); + fs.statSync(path.join(baseDir, "test")); }, Error); assert(x === undefined, "Promise version of this function does not have a return value upon success"); return true; diff --git a/test/lib/getFactorioLocale.js b/test/lib/getFactorioLocale.js deleted file mode 100644 index 043d32c7..00000000 --- a/test/lib/getFactorioLocale.js +++ /dev/null @@ -1,74 +0,0 @@ -const assert = require("assert"); -const util = require("util"); - -const getFactorioLocale = require("lib/getFactorioLocale"); -const asObjectAsync = util.promisify(getFactorioLocale.asObject); - -describe("getFactorioLocale.js", function() { - describe("asObject()", function() { - it("requires 2 strings and a function as parameters", function() { - function validate(err) { - return err.message == "Error: wrong parameters provided" - } - let message = "getFactorioLocale is responding incorrectly to invalid arguments" - let callback = (err,object) => true; - - assert.throws(() => getFactorioLocale.asObject(), validate, message); - assert.throws(() => getFactorioLocale.asObject("hello", "I forgot my callback"), validate, message); - assert.throws(() => getFactorioLocale.asObject(undefined, undefined, callback), validate, message); - - // the proper way to do it - getFactorioLocale.asObject("factorio_0.15.27", "en", callback); - }); - it("returns a nested object with base game locale information", async function() { - let object = await asObjectAsync("factorio", "en"); - - assert.equal(typeof object, "object"); - assert.equal(object["entity-name"]["fish"], "Fish"); - assert.equal(object["entity-name"]["small-lamp"], "Lamp"); - let empty_keys = 0; - Object.keys(object).forEach(key => { - // first level of the nested object is always an object - assert.equal(typeof object[key], "object"); - - Object.keys(object[key]).forEach(key2 => { - // second level of the nested object is always a string, nearly always truthy - if (!object[key][key2]) { - empty_keys++; - } - assert.equal(typeof object[key][key2], "string"); - }); - - }); - assert(empty_keys < 10, `Got unusually high amount (${empty_keys}) of empty keys`); - }); - it("has some duplicate keys", async function() { - let object = await asObjectAsync("factorio", "en"); - - let arrayOfKeys = []; - // collect all keys in a single array - Object.keys(object).forEach(key => { - arrayOfKeys.push(key); - Object.keys(object[key]).forEach(key2 => { - arrayOfKeys.push(key2); - }); - }); - - // check for duplicates - let duplicateKeys = []; - let i = 0; - arrayOfKeys.forEach(key => { - let o = 0; - arrayOfKeys.forEach(key2 => { - if(i != o && key == key2){ - duplicateKeys.push(key); - } - o++ - }); - i++; - }); - // we got 704 but this is a very unexact number - assert(duplicateKeys.length > 10) - }); - }); -}); \ No newline at end of file diff --git a/test/lib/stringUtils.js b/test/lib/stringUtils.js deleted file mode 100644 index 03760e08..00000000 --- a/test/lib/stringUtils.js +++ /dev/null @@ -1,48 +0,0 @@ -const assert = require("assert"); -const stringUtils = require("lib/stringUtils"); - -describe("stringUtils.js", function(){ - describe("hashCode", function(){ - it("Produces a 32bit signed int hash from a string", function(){ - let x = stringUtils.hashCode("whatever"); - let y = stringUtils.hashCode(" a much longer string to prove this works for long strings as well and also special characters &!¤(#)¤"); - - assert(typeof x == "number", "hashCode should be returning a 32 bit signed int"); - assert(x.toString() == "1934383586"); - assert(y.toString() == "1648886132"); - }); - let permutations = 1000; - it("Rarely produces same hash for different strings ("+permutations+" hashes with collision check)", function(){ - let keyHashPairs = []; - for(let i = 0; i < permutations; i++){ - let string = Math.random().toString(); - keyHashPairs.push({ - key:string, - hash:stringUtils.hashCode(string), - }); - } - - for(let i = 0; i < permutations; i++){ - for(let o = 0; o < permutations; o++){ - assert(i == o || keyHashPairs[o].key != keyHashPairs[i].key, "Woha, Math.random() generated 2 equal numbers???"); - assert(i == o || keyHashPairs[o].hash != keyHashPairs[i].hash, "Hash collision, as long at it doesn't happen again its fine."); - } - } - }); - it("Returns 0 for 0 length strings", function(){ - let x = stringUtils.hashCode(""); - assert.deepEqual(x, 0); - }); - it("Throws when supplied with anything other than a string", function(){ - assert.throws(function(){ - stringUtils.hashCode([]); - }, "ERROR: Not a string") - assert.throws(function(){ - stringUtils.hashCode({}); - }, "ERROR: Not a string") - assert.throws(function(){ - stringUtils.hashCode(0); - }, "ERROR: Not a string") - }); - }); -}); \ No newline at end of file