Extractor.js

const Promise = require("bluebird");
const fs = require("fs-extra");
const path = require("path");
const ModuleLoader = require("./ModuleLoader");
const nodelua = require("nodelua");

/**
 * `Array.map()` for objects.  Walks through each property in the object and creates an object after running each key
 * through `cb`.
 * @param {Object} obj the object to iterate through.
 * @param {Function} cb called for each property in `obj`.  Return `undefined` to remove a key.  Otherwise, the returned
 *   value will be added to the output object under the same key.
 * @return {Object} The result of running each property in `obj` through `cb`.
 * @todo Move to a static method or seperate file.
 * @todo Write unit tests.
*/
function objMap(obj, cb) {
  let out = {};
  for(const k in obj) {
    if(!obj.hasOwnProperty(k)) { continue; }
    const val = cb(k, obj[k], obj);
    if(val !== undefined) { out[k] = val; }
  }
  return out;
}

/**
 * Creates a camel-cased version of a string.
 * @param {String} str the incoming text
 * @return {String} a camel-cased version of the string.
 * @todo Move to a static method or seperate file.
 * @todo Write unit tests.
*/
function camelCase(str) {
  return str.replace(/([-_][a-zA-Z])/g, (s) => s[1].toUpperCase());
}

/**
 * Extracts data from a Factorio save.
*/
class Extractor {

  /**
   * A script that shims Factorio core utilities.
   * Adapted from Foreman's
   * {@link https://bitbucket.org/Nicksaurus/foreman/src/46053df/Foreman/DataCache.cs#DataCache.cs-103|DataCache.cs}
   * @type {String}
   * @private
  */
  static get utilScript() {
    return `
      function module(modname, ...)
      end

      require "util"
      util = {}
      util.table = {}
      util.table.deepcopy = table.deepcopy
      util.multiplystripes = multiplystripes
      util.by_pixel = by_pixel
      util.format_number = format_number
      util.increment = increment

      function log(...)
      end

      defines = {}
      defines.difficulty_settings = {}
      defines.difficulty_settings.recipe_difficulty = {}
      defines.difficulty_settings.technology_difficulty = {}
      defines.difficulty_settings.recipe_difficulty.normal = 1
      defines.difficulty_settings.technology_difficulty.normal = 1
      defines.direction = {}
      defines.direction.north = 1
      defines.direction.east = 2
      defines.direction.south = 3
      defines.direction.west = 4

      data.raw["gui-style"] = {}
      data.raw["gui-style"]["default"] = {}`;
  }

  /**
   * The Lua scripts that contain data information.
   * @type {Array<String>}
  */
  static get dataFiles() {
    return this._dataFiles || [
      "data.lua",
      "data-updates.lua",
      "data-final-fixes.lua",
    ];
  }

  //static set dataFiles(files) { this._dataFiles = files; }

  /**
   * The different categories of items in Factorio.
   * @type {Array<String>}
  */
  static get itemTypes() {
    return [ "item", "fluid", "capsule", "module", "ammo", "gun", "armor", "blueprint", "deconstruction-item",
      "mining-tool", "repair-tool", "tool" ];
  }

  static get dataTypes() {
    return [ "recipe", "assembling-machine", "furnace", "mining-drill", "resource", "module" ];
  }

  /**
   * @param {String} gamePath the path to the Factorio directory - contains "mods", "config", "data", etc.
   * @param {String} output the target output directory to dump a JSON of the Factorio data into.
   * @todo Write unit tests.
  */
  constructor(gamePath, output) {
    this.gamePath = gamePath;
    this.output = output || path.join(__dirname, "_extracted.json");
    this.lua = Promise.promisifyAll(new nodelua.LuaState("lua"));
    this._mods = [];
  }

  get modDirectory() {
    return path.join(this.gamePath, "mods");
  }

  get modSettingsFile() {
    return path.join(this.modDirectory, "mod-settings.json");
  }

  /**
   * Extract all of the game data, and save into a `json` file.
   * @return {Promise} resolves once all tasks have been run.
   * @todo Include list of mods in output.
   * @todo Parse images linked by resources, and (optionally) load them into the data.
  */
  extract() {
    let basePath;
    let lualib = path.join(this.gamePath, "data", "core", "lualib");
    let dataloader = path.join(lualib, "dataloader.lua");
    let moduleLoader = new ModuleLoader({ factorioPath: this.gamePath });
    return Promise.resolve()
      .then(() => this.addLuaPath(lualib))
      .then(() => this.getLuaPath())
      .then(path => basePath = path)
      .then(() => this.lua.doFileAsync(dataloader))
      .then(() => this.lua.doStringAsync(this.constructor.utilScript))
      .then(() => this.readModSettings())
      .then(settings => this.lua.doStringAsync(settings))
      .then(() => moduleLoader.enabled())
      .then(mods => moduleLoader.sortedDependencies(mods))
      .then(mods => {
        this._mods = mods;
        this._luaErrors = 0;
        let task = Promise.resolve();
        for(const dataFile of this.constructor.dataFiles) {
          console.log(`Running ${dataFile}`);
          for(const mod of mods) {
            let scriptPath = path.join(mod.dir, dataFile);
            task = task
              .then(() => this.setLuaPath(basePath))
              .then(() => this.addLuaPath(mod.dir))
              .then(() => this.clearLoadedPackages())
              .then(() => fs.readFile(scriptPath, "utf8"))
              .then((script) => this.lua.doStringAsync(script))
              .catch({code: "ENOENT"}, () => { return true; })
              .catch((e) => {
                this._luaErrors++;
                console.log(e.message);
                console.log("Continuing.");
                return true;
              });
          }
        }
        return task;
      })
      .then(() => console.log(`Ignorning ${this._luaErrors} Lua errors.`))
      .then(() => {
        const raw = this.lua.getGlobal("data").raw;
        let data = {
          items: {},
        };
        for(const type of this.constructor.itemTypes) {
          data.items[type] = this.filterFields(raw[type], { type: type, subgroup: type });
        }
        for(const type of this.constructor.dataTypes) {
          data[camelCase(type)+"s"] = this.filterFields(raw[type], { type: type });
        }
        return data;
      })
      .then(data => fs.writeFile(this.output, JSON.stringify(data)))
      .finally(() => Promise.all(this._mods.map(mod => mod.cleanup())));
  }

  async readModSettings() {

    let settings = `
      settings = {}
      settings.startup = {}
    `;

    let modSettings;
    try {
      modSettings = require(this.modSettingsFile);
    }
    catch (err) {
      console.error(`Couldn't read 'mod-settings.json': ${err}.  Continuing.`);
      return settings;
    }

    for(var section in modSettings.startup) {
      if(!modSettings.startup.hasOwnProperty(section)) { continue; }
      const val = modSettings.startup[section].value;
      settings = `
        ${settings}
        settings.startup["${section}"] = {};
        settings.startup["${section}"].value = ${value.toLowerCase()}
      `;
    }

    return settings;
  }

  /**
   * Add a path to the Lua package lookup.
   * @param {String} addition the Lua source file, without a file extension.
   * @return {Promise} resolves when the path has been added to the current Lua scope.
   * @todo Write unit tests.
  */
  addLuaPath(addition) {
    return this.lua.doStringAsync(`package.path = package.path .. ';${addition}/?.lua'`);
  }

  /**
   * Set the Lua path.
   * @param {String} path the new package path.
   * @return {Promise} resolves when the Lua path has been set.
   * @todo Write unit tests.
  */
  setLuaPath(path) {
    this.lua.setGlobal("package.path", path);
    return Promise.resolve();
  }

  /**
   * Get the current Lua path.
   * @return {Promise<String>} the current Lua package path.
   * @todo Write unit tests.
  */
  getLuaPath() {
    return Promise.resolve(this.lua.getGlobal("package.path"));
  }

  /**
   * Because many mods use the same path to refer to different files, we need to clear the 'loaded' table so Lua doesn't
   * think they're already loaded
   * @return {Promise} resolves when all packages have been cleared.
   * @todo Write unit tests.
  */
  clearLoadedPackages() {
    return this.lua.doStringAsync(`
      for k, v in pairs(package.loaded) do
        package.loaded[k] = false
      end`);
  }

  /**
   * Filters out repetitive fields to reduce the file output size.
   * @param {Object} objs the object to filter
   * @param {Object} filters fields to filter out.  `{<key>: <value>}` any keys matching `key` will be checked against
   *   `value`.  If `value` is a string and matches, then the key/value will be deleted from `obj`.  If `value` is a
   *   function, it will be provided the current value in the object, and it's return value will be used as the new
   *   value in `obj`.  Return `undefined` to delete the property from `obj`.
   * @return {Object} `obj` after the filters have been executed.
   * @todo Write unit tests.
  */
  filterFields(objs, filters) {
    return objMap(objs, (i, item) => {
      return objMap(item, (k, v) => {
        if(k === "name" && v === i) { return undefined; }
        if(typeof filters[k] === "string" && v === filters[k]) { return undefined; }
        if(typeof filters[k] === "function") { return filters[k](v); }
        return v;
      });
    });
  }

}

module.exports = Extractor;