Merge pull request #242 from Hornwitser/finish-factorio-server

Rewrite Factorio server spawning and communication
This commit is contained in:
Hornwitser authored and GitHub committed 2019-10-24 22:48:19 +02:00
commit e6d995d97a
26 files changed
+1562 -921

No files matched your search

+1
View File
@@ -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/
+13
View File
@@ -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
+187 -312
View File
@@ -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) {
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
...require('lib/factorio/locale'),
...require('lib/factorio/server'),
};
+28
View File
@@ -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,
};
+724
View File
@@ -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,
};
-167
View File
@@ -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();
});
});
});
}
}
+21 -37
View File
@@ -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
-53
View File
@@ -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]);
}
}
-13
View File
@@ -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,
}
+12 -5
View File
@@ -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<string, 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 */
+1 -3
View File
@@ -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",
+13 -1
View File
@@ -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);
+49 -27
View File
@@ -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");
});
});
});
+4
View File
@@ -0,0 +1,4 @@
---------------------------------------------------------------------------------------------------
Date: 04. 09. 2019
Changes:
- Bad changelog with no version
+13
View File
@@ -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
@@ -0,0 +1,4 @@
; Test data for getLocale
[test]
key-a=1
key-b=2
+3
View File
@@ -0,0 +1,3 @@
---------------------------------------------------------------------------------------------------
Version: 0.1.1
About: Dummy changelog.txt for FactorioServer tests
+62
View File
@@ -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)
});
});
});
+169
View File
@@ -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");
});
});
});
});
+18
View File
@@ -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" } });
});
});;
});
+208
View File
@@ -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 <eof>",
{
format: 'date',
time: '2019-01-20 10:30:14',
type: 'generic',
message: "Cannot execute command. Error: [string \"blah\"]:1: syntax error near <eof>",
}
],
[
"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"));
});
});
});
});
-115
View File
@@ -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;
});
});
});
+28 -66
View File
@@ -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;
-74
View File
@@ -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)
});
});
});
-48
View File
@@ -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")
});
});
});