mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 23:24:52 +09:00
Merge pull request #242 from Hornwitser/finish-factorio-server
Rewrite Factorio server spawning and communication
This commit is contained in:
26 files changed
+1562
-921
No files matched your search
@@ -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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
...require('lib/factorio/locale'),
|
||||
...require('lib/factorio/server'),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
---------------------------------------------------------------------------------------------------
|
||||
Date: 04. 09. 2019
|
||||
Changes:
|
||||
- Bad changelog with no version
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
---------------------------------------------------------------------------------------------------
|
||||
Version: 0.1.1
|
||||
About: Dummy changelog.txt for FactorioServer tests
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" } });
|
||||
});
|
||||
});;
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user