Source: src/hg/EventContainer.js

// Copyright 2019-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const fs = require('fs');
const https = require('https');

/**
 * @description Manages interface for event storage.
 *
 * @memberof HungryGames
 * @inner
 */
class EventContainer {
  /**
   * @description Create container.
   * @param {{
   *   bloodbath: string[],
   *   player: string[],
   *   arena: string[],
   *   weapon: string[]
   * }} obj List of IDs to load.
   */
  constructor(obj) {
    /**
     * @description Cached bloodbath events.
     * @private
     * @type {object.<HungryGames~NormalEvent>}
     * @default
     * @constant
     */
    this._bloodbath = {};
    /**
     * @description All bloodbath event IDs that should be loaded.
     * @private
     * @type {string[]}
     * @default
     * @constant
     */
    this._bloodbathIds = [];
    /**
     * @description Cached player events.
     * @private
     * @type {object.<HungryGames~NormalEvent>}
     * @default
     * @constant
     */
    this._player = {};
    /**
     * @description All player event IDs that should be loaded.
     * @private
     * @type {string[]}
     * @default
     * @constant
     */
    this._playerIds = [];
    /**
     * @description Cached arena events.
     * @private
     * @type {object.<HungryGames~ArenaEvent>}
     * @default
     * @constant
     */
    this._arena = {};
    /**
     * @description All arena event IDs that should be loaded.
     * @private
     * @type {string[]}
     * @default
     * @constant
     */
    this._arenaIds = [];
    /**
     * @description Cached weapon events.
     * @private
     * @type {object.<HungryGames~WeaponEvent>}
     * @default
     * @constant
     */
    this._weapon = {};
    /**
     * @description All weapon IDs that should be loaded.
     * @private
     * @type {string[]}
     * @default
     * @constant
     */
    this._weaponIds = [];

    /**
     * @description Number of currently loading requets that have not completed.
     * @see {@link HungryGames~EventContainer.loading}
     * @private
     * @type {number}
     * @default
     */
    this._loading = 0;

    /**
     * @description List of callbacks to fire once loading is completed.
     * @private
     * @type {Function[]}
     * @default
     * @constant
     */
    this._callbacks = [];

    if (obj) this.updateAndFetchAll(obj);
  }

  /**
   * @description Get serializable version of this object for saving to file.
   * @public
   * @returns {object} Serializable object for saving.
   */
  get serializable() {
    const out = {};
    EventContainer.types.forEach((type) => out[type] = this.ids(type));
    return out;
  }

  /**
   * @description Current types of events that this object stores.
   * @public
   * @static
   * @readonly
   * @returns {string[]} All types available.
   */
  static get types() {
    return ['bloodbath', 'player', 'arena', 'weapon'];
  }

  /**
   * @description Directory where all event data is stored.
   * @public
   * @static
   * @readonly
   * @returns {string} Path relative to projcet root.
   */
  static get eventDir() {
    return './save/hg/events/';
  }

  /**
   * @description True if data is currently being updated, and should not be
   * trusted as complete or up to date.
   * @public
   * @readonly
   * @returns {boolean} True if loading, false otherwise.
   */
  get loading() {
    return this._loading > 0;
  }

  /**
   * @description Fires callback once not loading anymore, or immediately if not
   * currently loading.
   * @public
   * @param {Function} cb Callback to fire.
   */
  waitForReady(cb) {
    if (this.loading) {
      this._callbacks.push(cb);
    } else {
      cb();
    }
  }

  /**
   * @description Fetch list of IDs for specific type.
   * @public
   * @param {string} type Event type to fetch IDs for.
   * @returns {string[]} List of IDs.
   */
  ids(type) {
    if (EventContainer.types.includes(type)) return this[`_${type}Ids`];
    return [];
  }

  /**
   * @description Get the object reference storing events of a certain type,
   * mapped by the event IDs.
   * @public
   * @param {string} type The type to fetch.
   * @returns {object.<
   *   HungryGames~Event|
   *   HungryGames~NormalEvent|
   *   HungryGames~ArenaEvent|
   *   HungryGames~Battle|
   *   HungryGames~WeaponEvent
   * >} The object of requested event types.
   */
  get(type) {
    if (EventContainer.types.includes(type)) return this[`_${type}`];
    return {};
  }

  /**
   * @description Remove an event from a type. Purges from cache immediately.
   * @public
   * @param {string} id ID of the event to remove.
   * @param {string} type The category to remove the event from.
   * @returns {boolean} True if success, false otherwise.
   */
  remove(id, type) {
    if (!EventContainer.types.includes(type)) return false;
    const ids = this.ids(type);
    const index = ids.findIndex((el) => el === id);
    if (index < 0) return false;
    ids.splice(index, 1);
    if (this.get(type)[id]) delete this.get(type)[id];
    return true;
  }

  /**
   * @description Get the object reference storing events of a certain type
   * after passed through `Object.values()`.
   * @public
   * @param {string} type The type to fetch.
   * @returns {Array.<
   *   HungryGames~Event|
   *   HungryGames~NormalEvent|
   *   HungryGames~ArenaEvent|
   *   HungryGames~Battle|
   *   HungryGames~WeaponEvent
   * >} The object of requested event types.
   */
  getArray(type) {
    return Object.values(this.get(type));
  }

  /**
   * @description Update list of IDs, and cache all.
   * @public
   * @param {{
   *   bloodbath: string[],
   *   player: string[],
   *   arena: string[],
   *   weapon: string[]
   * }} obj List of IDs to load.
   * @param {Function} cb Fires once all events have been cached.
   */
  updateAndFetchAll(obj, cb) {
    const keys = Object.keys(obj);
    keys.forEach((type) => {
      if (!EventContainer.types.includes(type)) {
        if (type !== 'battles') console.error('Unknown type of event: ' + type);
        return;
      }
      const out = this.ids(type);
      // Remove non-existent IDs.
      for (let i = out.length - 1; i >= 0; i--) {
        if (!obj[type].find((el) => el === out[i])) out.splice(i, 1);
      }
      // Add new IDs.
      for (const id of obj[type]) {
        if (!out.find((el) => el === id)) out.push(id);
      }
    });
    this.fetchAll(cb);
  }

  /**
   * @description Fetch all events from file into the cache. Always fetches from
   * file, even if event exists in cache already.
   * @public
   * @param {Function} cb Callback once completed. No arguments.
   */
  fetchAll(cb) {
    let numLeft = 0;
    EventContainer.types.forEach((type) => {
      this.ids(type).forEach((id) => {
        numLeft++;
        this.fetch(id, type, () => {
          const ids = this.ids(type);
          const obj = this.get(type);
          // Purge removed events.
          Object.keys(obj).forEach((el) => {
            if (!ids.includes(el)) delete obj[el];
          });
          numLeft--;
          if (numLeft > 0) return;
          if (typeof cb === 'function') cb();
        });
      });
    });
    if (numLeft === 0) {
      if (typeof cb === 'function') cb();
    }
  }

  /**
   * @description Fetch an event into the cache. Always updates from file, even
   * if already cached.
   * @public
   * @param {string} id The event ID to fetch.
   * @param {?string} type The category to add this event to. If null, event
   * will not be stored in category, nor cached.
   * @param {basicCB} cb Callback once completed. First argument is optional
   * error string, second is otherwise the event object.
   */
  fetch(id, type, cb) {
    const self = this;
    const done = function(err, obj) {
      self._loading--;
      if (!self.loading) {
        self._callbacks.splice(0).forEach((el) => {
          try {
            el();
          } catch (err) {
            console.error(err);
          }
        });
      }
      cb(err, obj);
    };

    if (!id.match || !id.match(/^\d{17,19}\/\d+-[0-9a-z]+$/)) {
      done('BAD_ID');
      return;
    }

    const eventDir = EventContainer.eventDir;
    this._loading++;
    fs.readFile(`${eventDir}${id}.json`, (err, data) => {
      if (err) {
        if (err.code !== 'ENOENT') {
          console.error(`Failed to load: ${eventDir}${id}.json`);
          console.error(err);
          done('BAD_ID');
        } else {
          this._fetchFromUrl(id, type, done);
        }
      } else {
        this._parseFetched(data, id, type, done);
      }
    });
  }

  /**
   * @description Parse fetched data.
   * @private
   * @param {string|Buffer} data Data to parse.
   * @param {string} id The ID of the event we're parsing.
   * @param {string} type The event type we're parsing.
   * @param {Function} done Callback.
   */
  _parseFetched(data, id, type, done) {
    const eventDir = EventContainer.eventDir;
    try {
      const parsed = JSON.parse(data);
      if (!parsed) {
        console.error(`Failed to parse: ${eventDir}${id}.json (NO DATA)`);
        done('PARSE_ERROR');
      } else {
        if (type && EventContainer.types.includes(type)) {
          if (!this.ids(type).includes(id)) this.ids(type).push(id);
          this.get(type)[id] = parsed;
        }
        done(null, parsed);
      }
    } catch (err) {
      console.error(`Failed to parse: ${eventDir}${id}.json`);
      console.error(err);
      done('PARSE_ERROR');
    }
  }

  /**
   * @description Fetch an event into the cache. Always updates from file, even
   * if already cached. This fetches exclusively from the master server URL.
   * This is called from {@link fetch}.
   * @private
   * @param {string} id The event ID to fetch.
   * @param {?string} type The category to add this event to. If null, event
   * will not be stored in category, nor cached.
   * @param {basicCB} cb Callback once completed. First argument is optional
   * error string, second is otherwise the event object.
   */
  _fetchFromUrl(id, type, cb) {
    const isDev = process.argv.includes('--dev');
    const host = {
      protocol: 'https:',
      host: isDev ? 'www.spikeybot.com' : 'kamino.spikeybot.com',
      path: isDev ? `/dev/hg/events/${id}.json` : `/hg/events/${id}.json`,
      method: 'GET',
      headers: {
        'User-Agent': require('../common.js').ua,
        'Content-Type': 'application/json',
      },
    };
    const req = https.request(host, (res) => {
      let content = '';
      res.on('data', (chunk) => content += chunk);
      res.on('end', () => {
        if (res.statusCode == 200) {
          this._parseFetched(content, id, type, cb);
        } else {
          console.error(
              'HG Event', res.statusCode, host.host, host.path, content);
          cb('FETCH_FAILED');
        }
      });
    });
    req.end();
  }

  /**
   * @description Create based of serializable data that was saved to file.
   * @public
   * @static
   * @param {object} obj Parsed save data.
   * @returns {HungryGames~EventContainer} Created object.
   */
  static from(obj) {
    return new EventContainer(obj);
  }
}
module.exports = EventContainer;