// 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;