Source: hg/StatGroup.js

// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const Stats = require('./Stats.js');
const common = require('../common.js');
const crypto = require('crypto');
const fs = require('fs');
const rimraf = require('rimraf');

/**
 * @description Metadata to store along with a {@link HungryGames~StatGroup}
 * object. These values are user-defined and are not necessarily correct and are
 * not trustworthy for any processing.
 * @typedef {object} HGStatMetadata
 *
 * @property {string} [name] The user-defined name of this stats object.
 * @property {Date} [startTime] The timestamp at which this stats object starts
 * to include information.
 * @property {Date} [endTime] The timestamp of the last time this object
 * includes information for.
 * @property {Date} createDate The timestamp at which this stats object was
 * created.
 * @property {Date} modifiedDate The timestamp at which this stats object was
 * last modified.
 */

/**
 * @description HG stats for a single timeframe.
 * @memberof HungryGames
 * @inner
 */
class StatGroup {
  /**
   * @description Create group.
   * @param {GuildGame} parent The parent instance of this group.
   * @param {HGStatMetadata|string} [metadata] Additional information to store
   * with these stats, or ID if metadata should be read from file since this
   * group already exists.
   */
  constructor(parent, metadata) {
    let id;
    if (typeof metadata === 'string') {
      id = metadata;
      metadata = null;
    }

    // Ensure SQL connection is established.
    common.connectSQL();

    /**
     * @description The ID of this current bot.
     * @public
     * @type {string}
     * @constant
     */
    this.bot = parent.bot;

    /**
     * @description The guild ID where this stat group resides.
     * @public
     * @type {string}
     * @constant
     */
    this.guild = parent.id;

    /**
     * @description The unique ID for this stat group. Unique per-guild.
     * @public
     * @type {string}
     */
    this.id = id;
    if (!this.id) this.id = StatGroup.createID(parent);

    /**
     * @description Queue of callbacks to fire once an object has been read from
     * file. This is used to ensure that if multiple manipulations are requested
     * on a single object at the same time, all modifications will take place on
     * the same instance instead of overwriting eachother. Mapped by ID being
     * fetched.
     * @private
     * @type {object.<Array.<Function>>}
     * @default
     */
    this._fetchQueue = {};
    /**
     * @description Cache of Stats objects that are to be saved to file, and the
     * timeout until it will be saved. Prevents saving the same file multiple
     * times at once.
     * @private
     * @type {object.<{data: HungryGames~Stats, timeout: Timeout}>}
     * @default
     */
    this._saveQueue = {};

    const dir = `${common.guildSaveDir}${parent.id}/hg/stats/`;
    /**
     * @description The directory where all of this group's information is
     * stored.
     * @private
     * @type {string}
     * @constant
     */
    this._dir = `${dir}${this.id}/`;

    this._fetchUser = this._fetchUser.bind(this);
    this.fetchUser = this.fetchUser.bind(this);
    this.setValue = this.setValue.bind(this);
    this.fetchValue = this.fetchValue.bind(this);
    this._saveUser = this._saveUser.bind(this);
    this.setMetaName = this.setMetaName.bind(this);
    this.setMetaStart = this.setMetaStart.bind(this);
    this.setMetaEnd = this.setMetaEnd.bind(this);
    this._fetchMetadata = this._fetchMetadata.bind(this);
    this._saveMetadata = this._saveMetadata.bind(this);
    this.reset = this.reset.bind(this);

    if (metadata) {
      this._saveMetadata(this._parseMetadata(metadata));
    } else {
      this._fetchMetadata((err, meta) => {
        if (err) {
          console.error(err);
          return;
        }
        this._saveMetadata(meta);
      });
    }
  }

  /**
   * @description Fetch stats for a specific user in this group. Returned stats
   * are modifiable, but changes will not persist unless saved to file.
   * @private
   * @param {string} uId The user ID of which to lookup.
   * @param {Function} cb Callback with optional error as first argument,
   * otherwise has stats as second argument.
   */
  _fetchUser(uId, cb) {
    if (typeof uId !== 'string' ||
        (uId !== 'meta' && !uId.match(/^(\d{17,19}|NPC[A-F0-9]+)$/))) {
      throw new TypeError('uId (' + uId + ') is not a valid ID.');
    }
    // Data is queued to be saved, and is still cached, return the cached
    // version instead of reading the stale version from file.
    if (this._saveQueue[uId]) {
      cb(null, this._saveQueue[uId].data);
      return;
    }
    if (!this._fetchQueue[uId]) {
      this._fetchQueue[uId] = [cb];
    } else {
      this._fetchQueue[uId].push(cb);
      return;
    }
    const self = this;
    const done = function(err, data) {
      self._fetchQueue[uId].forEach((el) => {
        try {
          el(err, data);
        } catch (err) {
          console.error(err);
        }
      });
      delete self._fetchQueue[uId];
    };
    if (uId === 'meta') {
      fs.readFile(`${this._dir}${uId}.json`, (err, data) => {
        if (err) {
          if (err.code === 'ENOENT') {
            data = '{}';
          } else {
            done(err);
            return;
          }
        }
        try {
          done(null, this._parseMetadata(JSON.parse(data)));
        } catch (err) {
          done(err);
        }
      });
    } else {
      const toSend = global.sqlCon.format(
          'SELECT * FROM HGStats WHERE ' +
              'botId=? AND guildId=? AND groupId=? AND userId=?',
          [this.bot, this.guild, this.id, uId]);
      global.sqlCon.query(toSend, (err, rows) => {
        if (err) {
          done(err);
          return;
        }
        if (!rows || rows.length == 0) {
          // Fallback to legacy filesytem.
          fs.readFile(`${this._dir}${uId}.json`, (err, data) => {
            if (err) {
              if (err.code === 'ENOENT') {
                data = '{}';
              } else {
                done(err);
                return;
              }
            }
            try {
              const parsed = JSON.parse(data);
              parsed.id = uId;
              done(null, Stats.from(parsed));
            } catch (err) {
              done(err);
            }
          });
        } else {
          const data = rows[0] || {};
          data.id = uId;
          done(null, Stats.from(data));
        }
      });
    }
  }

  /**
   * @description Fetch stats for a specific user in this group. Modified values
   * will not persist. Use functions to modify.
   * @todo Return immutable/frozen copy to enforce no-modify rule.
   * @public
   * @param {string} uId The user ID of which to lookup.
   * @param {Function} cb Callback with optional error as first argument,
   * otherwise has stats as second argument.
   */
  fetchUser(uId, cb) {
    this._fetchUser(uId, (err, stats) => {
      if (err) {
        cb(err);
        return;
      }
      // cb(null, common.deepFreeze(stats));
      cb(null, stats);
    });
  }

  /**
   * @description Options for fetching a group of user stats.
   * @typedef {object} HGStatGroupUserSelectOptions
   *
   * @property {string} [sort='wins'] Column to sort data by.
   * @property {boolean} [ascending=false] Sort ascending or descending order.
   * @property {number} [limit=10] Limit the number of fetched users.
   * @property {number} [offset=0] Offset start index of found users.
   */

  /**
   * @description Fetch stats for a group of users. If array of IDs is given,
   * data will not be sorted.
   * @public
   * @param {HGStatGroupUserSelectOptions|string[]} [opts] Options to specify
   * which users are fetched, or array of user IDs to fetch.
   * @param {Function} cb Callback with optional error as first argument,
   * otherwise has stats as second argument as array of
   * {@link HungryGames~Stats} objects.
   */
  fetchUsers(opts, cb) {
    if (typeof opts === 'function') {
      cb = opts;
      opts = {};
    }
    if (typeof cb !== 'function') {
      throw new TypeError('Callback must be a function');
    }
    if (!opts || typeof opts !== 'object') {
      opts = {};
    }

    const onReply = function(err, rows) {
      if (err) {
        cb(err);
        return;
      }
      try {
        cb(null, rows.map((el) => {
          el.id = el.userId;
          return new Stats(el);
        }));
      } catch (err) {
        cb(err);
      }
    };

    if (Array.isArray(opts)) {
      if (opts.length === 0) {
        cb(null, []);
        return;
      }

      const userList = opts.map(() => `userId=?`).join(' OR ');

      const toSend = global.sqlCon.format(
          'SELECT * FROM HGStats WHERE ' +
              'botId=? AND guildId=? AND groupId=? AND (' + userList + ');',
          [this.bot, this.guild, this.id].concat(opts));
      global.sqlCon.query(toSend, onReply);
    } else {
      if (typeof opts.sort === 'undefined') {
        opts.sort = 'wins';
      } else if (typeof opts.sort !== 'string' || opts.sort.length == 0) {
        opts.sort = null;
      }
      if (typeof opts.limit === 'undefined') opts.limit = 10;
      if (!opts.offset || typeof opts.offset !== 'number' ||
          isNaN(opts.offset)) {
        opts.offset = 0;
      }

      const sort = (typeof opts.sort === 'string' ?
                        ' ORDER BY ?? ' + (opts.ascending ? '' : 'DESC ') :
                        '') +
          ', `userId` ';

      const limit = typeof opts.limit === 'number' && !isNaN(opts.limit) ?
          `LIMIT ${opts.limit}` +
              (opts.offset ? ` OFFSET ${opts.offset}` : '') :
          '';

      const toSend = global.sqlCon.format(
          'SELECT * FROM HGStats WHERE ' +
              'botId=? AND guildId=? AND groupId=?' + sort + limit + ';',
          [this.bot, this.guild, this.id, opts.sort]);

      global.sqlCon.query(toSend, onReply);
    }
  }

  /**
   * @description Set a stat value for a single user.
   * @public
   * @param {string} uId The user ID of which to change.
   * @param {string} key The key of the value to change.
   * @param {*} value The value to store.
   * @param {Function} cb Callback with single optional error argument.
   */
  setValue(uId, key, value, cb) {
    this._fetchUser(uId, (err, data) => {
      if (err && err.code !== 'ENOENT') {
        cb(err);
        return;
      }
      data.set(key, value);
      this._saveUser(data);
      cb();
    });
  }

  /**
   * @description Fetch a stat value for a single user. Immutable.
   * @public
   * @param {string} uId The user ID of which to fetch.
   * @param {string} key The key of the value to fetch.
   * @param {Function} cb Callback with optional error argument, and matched
   * value.
   */
  fetchValue(uId, key, cb) {
    this._fetchUser(uId, (err, data) => {
      if (err) {
        cb(err);
        return;
      }
      cb(null, data.get(key));
    });
  }

  /**
   * @description Increment a value by an amount.
   * @public
   * @param {string} uId The user ID of which to modify.
   * @param {string} key The key of the value to modify.
   * @param {number} [amount=1] Amount to increment by. Can be negative to
   * decrement.
   * @param {Function} [cb] Callback with single optional error argument.
   */
  increment(uId, key, amount = 1, cb) {
    if (typeof amount !== 'number' || isNaN(amount)) {
      throw new TypeError('Amount is not a number.');
    }
    this._fetchUser(uId, (err, data) => {
      if (err) {
        if (typeof cb !== 'function') {
          console.error(err);
        } else {
          cb(err);
        }
        return;
      }
      if (!data.get(key)) data.set(key, 0);
      if (typeof data.get(key) !== 'number') {
        const err = new TypeError('Fetched value is not a number.');
        if (typeof cb !== 'function') {
          console.error(err);
        } else {
          cb(err);
        }
        return;
      }
      data.set(key, data.get(key) + amount);
      this._saveUser(data);
      if (typeof cb === 'function') cb();
    });
  }

  /**
   * @description Save a stats object to file.
   * @private
   * @param {HungryGames~Stats} data The stats object to save.
   * @param {boolean} [immediate=false] Force saving to happen immediately
   * instead of waiting until next event loop.
   */
  _saveUser(data, immediate = false) {
    if (this._saveQueue[data.id]) {
      clearTimeout(this._saveQueue[data.id].timeout);
      this._saveQueue[data.id].timeout = null;
    }
    if (!immediate) {
      this._saveQueue[data.id] = {
        data: data,
        timeout: setTimeout(
            () => this._saveUser(this._saveQueue[data.id].data, true), 1000),
      };
      return;
    }
    delete this._saveQueue[data.id];

    const setList = 'botId=?,guildId=?,groupId=?,userId=?,' +
        data.keys.map((el) => `${el}=?`).join(',');
    const valueList = [this.bot, this.guild, this.id, data.id].concat(
        Object.values(data.serializable));

    const toSend = global.sqlCon.format(
        'INSERT INTO HGStats SET ' + setList + ' ON DUPLICATE KEY UPDATE ' +
            setList + ';',
        valueList.concat(valueList));
    global.sqlCon.query(toSend, (err) => {
      if (err) {
        console.error(err);
        return;
      }
      const fn = `${this._dir}${data.id}.json`;
      common.unlink(fn, (err) => {
        if (err) {
          console.error('Failed to remove legacy user stat file:', fn);
          console.error(err);
        }
      });
    });
    // common.mkAndWrite(
    //     `${this._dir}${data.id}.json`, this._dir, data.serializable);
  }

  /**
   * @description Set the metadata name.
   * @public
   * @param {string} name The new value.
   */
  setMetaName(name) {
    this.fetchMetadata((err, meta) => {
      if (err) {
        console.error(err);
        return;
      }
      meta.name = name;
      this._saveMetadata(meta);
    });
  }
  /**
   * @description Set the metadata start time.
   * @public
   * @param {Date|number|string} startTime Date parsable time.
   */
  setMetaStart(startTime) {
    this.fetchMetadata((err, meta) => {
      if (err) {
        console.error(err);
        return;
      }
      meta.startTime = new Date(startTime);
      this._saveMetadata(meta);
    });
  }

  /**
   * @description Set the metadata end time.
   * @public
   * @param {Date|number|string} endTime Date parsable time.
   */
  setMetaEnd(endTime) {
    this.fetchMetadata((err, meta) => {
      if (err) {
        console.error(err);
        return;
      }
      meta.endTime = new Date(endTime);
      this._saveMetadata(meta);
    });
  }

  /**
   * @description Fetch the metadata for this group from file.
   * @private
   * @param {Function} cb Callback with optional error argument, otherwise
   * second argument is parsed {@link HGStatMetadata}.
   */
  _fetchMetadata(cb) {
    this._fetchUser('meta', cb);
  }

  /**
   * @description Fetch the metadata for this group from file. Modified values
   * will not persist. Use functions to modify.
   * @private
   * @param {Function} cb Callback with optional error argument, otherwise
   * second argument is parsed {@link HGStatMetadata}.
   */
  fetchMetadata(cb) {
    this.fetchUser('meta', cb);
  }

  /**
   * @description Parse the given object into a {@link HGStatMetadata} object.
   * @private
   * @param {object} data The data to parse.
   * @returns {HGStatMetadata} The parsed object.
   */
  _parseMetadata(data) {
    const out = {};
    if (!data) data = {};
    if (data.name != null) out.name = data.name;
    if (data.startTime != null) out.startTime = new Date(data.startTime);
    if (data.endTime != null) out.endTime = new Date(data.endTime);
    out.createDate = data.createDate ? new Date(data.createDate) : new Date();
    out.modifiedDate =
        data.modifiedDate ? new Date(data.modifiedDate) : new Date();
    return out;
  }

  /**
   * @description Save the current metadata to file.
   * @private
   * @param {HGStatMetadata} meta The data to save. Overwrites existing data.
   * @param {boolean} [immediate=false] Force saving to perform immediately
   * instead of delaying until next event loop.
   */
  _saveMetadata(meta, immediate = false) {
    if (this._saveQueue.meta) {
      clearTimeout(this._saveQueue.meta.timeout);
      this._saveQueue.meta.timeout = null;
    }
    if (!immediate) {
      this._saveQueue.meta = {
        data: meta,
        timeout: setTimeout(
            () => this._saveMetadata(this._saveQueue.meta.data, true), 1000),
      };
      return;
    }
    const data = {
      name: meta.name,
      startTime: meta.startTime && meta.startTime.getTime(),
      endTime: meta.endTime && meta.endTime.getTime(),
      createDate: meta.createDate.getTime(),
      modifiedDate: Date.now(),
    };
    delete this._saveQueue.meta;
    common.mkAndWrite(`${this._dir}meta.json`, this._dir, data);
  }

  /**
   * @description Delete all data associated with this group.
   * @public
   */
  reset() {
    const self = this;
    const resetQueue = function() {
      const keys = Object.keys(self._saveQueue);
      for (const k of keys) {
        clearTimeout(self._saveQueue[k].timeout);
        delete self._saveQueue[k];
      }
    };
    resetQueue();
    const toSend = global.sqlCon.format(
        'DELETE FROM HGStats WHERE botId=? AND guildId=? AND groupID=?;',
        [this.bot, this.guild, this.id]);
    global.sqlCon.query(toSend, (err) => {
      if (err) console.error(err);
    });
    rimraf(this._dir, (err) => {
      if (err) console.error(err);
      resetQueue();
    });
  }

  /**
   * @description Check if a stat group with the given ID exists for the given
   * game.
   * @public
   * @static
   * @param {HungryGames~GuildGame} game The game of which the stats to look up.
   * @param {string} id The group ID to check for.
   * @returns {boolean} True if exists, false otherwise.
   */
  static exists(game, id) {
    const dir = `${common.guildSaveDir}${game.id}/hg/stats/`;
    return fs.existsSync(`${dir}${id}/`);
  }

  /**
   * @description Fetch list of IDs for all created groups.
   * @public
   * @static
   * @param {HungryGames~GuildGame} game The game to get list for.
   * @param {Function} cb Callback with optional error argument, otherwise
   * second argument is array of IDs as strings.
   */
  static fetchList(game, cb) {
    fs.readdir(`${common.guildSaveDir}${game.id}/hg/stats/`, cb);
  }

  /**
   * @description Create an ID for a new group.
   * @todo Limit number of IDs to prevent infinite loop finding new ID.
   * @public
   * @static
   * @param {HungryGames~GuildGame} game The game to create an ID for to ensure
   * no collisions.
   * @returns {string} Valid created ID.
   */
  static createID(game) {
    const dir = `${common.guildSaveDir}${game.id}/hg/stats/`;
    let output;
    do {
      const id = crypto.randomBytes(2).toString('hex').toUpperCase();
      output = `0000${id}`.slice(-4);
    } while (fs.existsSync(`${dir}${output}`));
    return output;
  }
}
module.exports = StatGroup;