// Copyright 2019-2020 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const fs = require('fs'); const rimraf = require('rimraf'); // rm -rf /** * Contains a Hunger Games style simulation. */ class HungryGames { /** * @description HungryGames constructor. Currently requires a valid SubModule * as a parent. * @todo Remove reliance on SubModule. * @param {SubModule} parent Parent submodule used to hook logging into. */ constructor(parent) { /** * Parent subModule for logging and bot hooking. * * @private * @type {HG} * @constant */ this._parent = parent; /** * Current {@link HungryGames~Messages} instance. * * @public * @type {HungryGames~Messages} * @constant */ this.messages = new HungryGames.Messages(); /** * Default game options. * * @public * @type {HungryGames~DefaultOptions} * @constant */ this.defaultOptions = new HungryGames.DefaultOptions(); /** * All currently tracked games. Mapped by guild ID. In most cases you should * not reference this directly. Use {@link HungryGames#getGame} to get the * game object for a guild. * * @see {@link HungryGames#getGame} * * @private * @type {object.<HungryGames~GuildGame>} * @default * @constant */ this._games = {}; /** * Stores the guilds we have looked for their data recently and the * timestamp at which we looked. Used to reduce filesystem requests and * blocking. * * @private * @type {object.<number>} * @constant */ this._findTimestamps = {}; /** * The delay after failing to find a guild's data to look for it again. * * @private * @type {number} * @constant * @default 15 Seconds */ this._findDelay = 15000; /** * Maximum amount of milliseconds long running operations are allowed to * take to prevent cpu deadlock. * * @public * @type {number} * @constant * @default */ this.maxDelta = 5; /** * @description The minimum amount of time to keep a * {@link HungryGames~GuildGame} in memory before purging after a save. * @private * @type {number} * @constant * @default 3 minutes */ this._purgeDelta = 3 * 60 * 1000; /** * The file path to save current state for a specific guild relative to * {@link Common~guildSaveDir}. * * @see {@link Common#guildSaveDir} * @see {@link HungryGames#_games} * @see {@link HungryGames#_hgSaveDir} * * @private * @type {string} * @constant * @default */ this._saveFile = 'game.json'; /** * The file directory for finding saved data related to the hungry games * data of individual guilds. * * @see {@link Common#guildSaveDir} * @see {@link HungryGames#_games} * @see {@link HungryGames#_saveFile} * * @private * @type {string} * @constant * @default */ this._hgSaveDir = '/hg/'; /** * @description Object storing all default events for the games. Null until * specified by instantiator. * @private * @type {?HungryGames~EventContainer} * @default */ this._defaultEventStore = null; /** * Array of all battles that can take place normally by default. * * @private * @type {HungryGames~Battle[]} * @default */ this._defaultBattles = []; } /** * @description The file path to save current state for a specific guild * relative to Common#guildSaveDir. * @see {@link Common#guildSaveDir} * @see {@link HungryGames#_games} * @see {@link HungryGames#_saveFile} * @see {@link HungryGames#_hgSaveDir} * * @public * @returns {string} Save file name. */ get saveFile() { return this._saveFile; } /** * @description The file directory for finding saved data related to the * hungry games data of individual guilds. * @see {@link Common#guildSaveDir} * @see {@link HungryGames#_games} * @see {@link HungryGames#_saveFile} * @see {@link HungryGames#_hgSaveDir} * * @public * @returns {string} Save dir path. */ get hgSaveDir() { return this._hgSaveDir; } /** * @description Returns a reference to the current games object for a given * guild. * * @public * @param {string} id The guild id to get the data for. * @returns {?HungryGames~GuildGame} The current object storing all data about * game in a guild. */ getGame(id) { return this._find(id); } /** * @description Similar to {@link HungryGames.getGame} except asyncronous and * fetched game is passed as callback argument. * * @public * @param {string} id The guild id to get the data for. * @param {Function} cb Callback with single argument. Null if unable to be * found, {@link HungryGames~GuildGame} if found. */ fetchGame(id, cb) { this._find(id, cb); } /** * @description Update reference to the current * {@link HungryGames~EventContainer} that stores all custom events. * @public * @param {HungryGames~EventContainer} ec The container reference. */ setDefaultEvents(ec) { this._defaultEventStore = ec; } /** * @description Update the reference to the array storing default battles * events. * * @public * @param {HungryGames~Battle[]} list Array to reference. */ setDefaultBattles(list) { this._defaultBattles = list; } /** * @description Returns an object storing all of the default events for the * games. * * @public * @returns {HungryGames~EventContainer} Object storing default events. */ getDefaultEvents() { return this._defaultEventStore; } /** * @description Create a new GuildGame. * @fires HG#create * @public * @param {Discord~Guild|string} guild Guild object, or ID to create * a game for. * @param {Function} [cb] Callback once game has been fully created. Passes * the created game as the only argument. */ create(guild, cb) { if (!(guild instanceof this._parent.Discord.Guild)) { guild = this._parent.client.guilds.resolve(guild); } const optKeys = this.defaultOptions.keys; const opts = {}; for (const key of optKeys) { if (typeof key !== 'string') continue; if (typeof this.defaultOptions[key].value === 'object') { opts[key] = Object.assign({}, this.defaultOptions[key].value); } else { opts[key] = this.defaultOptions[key].value; } } const self = this; const getAll = function(members) { self.getAllPlayers( members, [], false, [], opts.excludeNewUsers, [], (res) => { self._games[guild.id] = new HungryGames.GuildGame( self._parent.client.user.id, guild.id, opts, `${guild.name}'s Hungry Games`, res); cb(self._games[guild.id]); self._parent._fire('create', guild.id); }); }; if (guild.memberCount > 100) { opts.excludeNewUsers = true; getAll(guild.members.cache); } else { guild.members.fetch().then(getAll).catch((err) => { this._parent.error('Failed to fetch all users for guild: ' + guild.id); console.error(err); cb(null); }); } } /** * @description Create a new Game for a guild, and refresh the player lists. * @fires HG#refresh * @public * @param {Discord~Guild|string} guild Guild object, or ID to refresh * a game for. * @param {Function} [cb] Callback once game has been fully refreshed. Passes * the refreshed game as the only argument, or null if unable to find the * game. */ refresh(guild, cb) { if (!(guild instanceof this._parent.Discord.Guild)) { guild = this._parent.client.guilds.resolve(guild); } this.fetchGame(guild.id, (game) => { if (!game) { cb(null); return; } const name = (game.currentGame && game.currentGame.name) || (`${guild.name}'s Hungry Games`); const teams = game.currentGame && game.currentGame.teams; const self = this; const getAll = function(members) { self.getAllPlayers( members, game.excludedUsers, game.options.includeBots, game.includedUsers, game.options.excludeNewUsers, game.includedNPCs, (res) => { game.currentGame = new HungryGames.Game(name, res, teams); cb(game); self._parent._fire('refresh', guild.id); }); }; if (game.options.excludeNewUsers) { getAll(guild.members.cache); } else { guild.members.fetch().then(getAll).catch((err) => { this._parent.error( 'Failed to fetch all users for guild: ' + guild.id); console.error(err); cb(null); }); } }); } /** * Form an array of Player objects based on guild members, excluded members, * and whether to include bots. * * @public * @param { * Discord~Collection<Discord~GuildMember> * } members All members in guild. * @param {string[]} excluded Array of ids of users that should not be * included in the games. * @param {boolean} bots Should bots be included in the games. * @param {string[]} included Array of ids of users that should be included in * the games. Used if excludeByDefault is true. * @param {boolean} excludeByDefault Should new users be excluded from the * game by default? * @param {NPC[]} [includedNPCs=[]] NPCs to include as players. * @param {basicCB} cb Callback on completion. Only argument is array of * {@link HungryGames~Player} to include in the games. */ getAllPlayers( members, excluded, bots, included, excludeByDefault, includedNPCs, cb) { const iTime = Date.now(); const finalMembers = []; const self = this; const memList = Array.isArray(members) ? members : [...members.values()]; const large = memList.length >= HungryGames.largeServerCount; if (large || !Array.isArray(excluded)) excluded = []; const memberIterate = function(obj) { if (obj.isNPC) return; if (included && excluded && !included.includes(obj.user.id) && !excluded.includes(obj.user.id)) { if (excludeByDefault) { if (!large) excluded.push(obj.user.id); } else { included.push(obj.user.id); } } else if ( included && excluded && included.includes(obj.user.id) && excluded.includes(obj.user.id)) { self._parent.error( 'User in both blacklist and whitelist: ' + obj.user.id + ' Guild: ' + obj.guild.id); if (excludeByDefault) { included.splice(included.findIndex((el) => el == obj.user.id), 1); } else { excluded.splice(excluded.findIndex((el) => el == obj.user.id), 1); } } const toInclude = !( (!bots && obj.user.bot) || (excluded && excluded.includes(obj.user.id) || (excludeByDefault && included && !included.includes(obj.user.id)))); if (toInclude) { finalMembers.push(new HungryGames.Player( obj.id, obj.user.username, obj.user.displayAvatarURL({extension: 'png'}), obj.nickname)); } }; let iTime2 = 0; const done = function() { const now = Date.now(); const start = iTime2 - iTime; const total = now - iTime; if (start > 10 || total > 10) { self._parent.debug( `GetAllPlayers ${finalMembers.length} ${start} ${total}`); } cb(finalMembers); }; const memberStep = function(i) { const start = Date.now(); if (i < memList.length) { for (i; Date.now() - start < self.maxDelta && i < memList.length; i++) { memberIterate(memList[i]); } } else if (includedNPCs && i - memList.length < includedNPCs.length) { for (i; Date.now() - start < self.maxDelta && i - memList.length < includedNPCs.length; i++) { const obj = includedNPCs[i - memList.length]; finalMembers.push( new self._parent.NPC(obj.name, obj.avatarURL, obj.id)); } } else { done(); return; } setTimeout(() => memberStep(i)); }; iTime2 = Date.now(); memberStep(0); } /** * Reset the specified category of data from a game. * * @fires HG#reset * @public * @param {string} id The id of the guild to modify. * @param {string} command The category of data to reset. * @returns {string} The message key referencing what happened. */ resetGame(id, command) { const game = this.getGame(id); if (!game) return 'resetNoData'; if (game.currentGame && game.currentGame.inProgress) { return 'resetInProgress'; } this._parent._fire('reset', id, command); if (command == 'all') { game._stats.fetchGroupList((err, list) => { if (err) { if (err.code !== 'ENOENT') { this._parent.error('Failed to fetch stat group list: ' + id); console.error(err); } return; } list.forEach((el) => game._stats.fetchGroup(el, (err, group) => { if (err) { this._parent.error('Failed to fetch group: ' + id + '/' + el); console.error(err); return; } group.reset(); })); }); delete this._games[id]; rimraf(this._parent.common.guildSaveDir + id + this.hgSaveDir, (err) => { if (!err) return; this._parent.error( 'Failed to delete directory: ' + this._parent.common.guildSaveDir + id + this.hgSaveDir); console.error(err); }); return 'resetAll'; } else if (command == 'stats') { game._stats.fetchGroupList((err, list) => { if (err) { if (err.code !== 'ENOENT') { this._parent.error('Failed to fetch stat group list: ' + id); console.error(err); } return; } list.forEach((el) => game._stats.fetchGroup(el, (err, group) => { if (err) { this._parent.error('Failed to fetch group: ' + id + '/' + el); console.error(err); return; } group.reset(); })); }); return 'resetStats'; } else if (command == 'events') { game.customEventStore = new HungryGames.EventContainer(); game.disabledEventIds = { bloodbath: [], player: [], arena: [], weapon: [], battle: {starts: [], attacks: [], outcomes: []}, }; delete game.legacyEvents; return 'resetEvents'; } else if (command == 'current') { game.currentGame = null; return 'resetCurrent'; } else if (command == 'options') { const optKeys = this.defaultOptions.keys; game.options = {}; for (const key of optKeys) { game.options[key] = this.defaultOptions[key].value; } return 'resetOptions'; } else if (command == 'teams') { game.currentGame.teams = []; game.formTeams(); return 'resetTeams'; } else if (command == 'users') { game.includedUsers = []; game.excludedUsers = []; this.refresh(id, () => {}); return 'resetUsers'; } else if (command == 'npcs') { game.includedNPCs = []; game.excludedNPCs = []; this.refresh(id, () => {}); return 'resetNPCs'; } else if (command == 'actions') { game.actions = new HungryGames.ActionStore(); this._parent._fire('actionUpdate', id, null); return 'resetActions'; } else if (command == 'react') { game.reactMessage = null; return 'resetReact'; } else { return 'resetHelp'; } } /** * @description Create and insert an action for a trigger in the given guild. * @public * @param {string} gId The guild ID of the game to modify. * @param {string} trigger The name of the trigger to insert the action into. * @param {string} action The name of the action to create. * @param {object} [args={}] The optional additional arguments required for * the action to be created. * @param {Function} [cb] Callback once completed. Single argument is error * string if failed, or null if succeeded. */ insertAction(gId, trigger, action, args = {}, cb) { if (typeof cb !== 'function') cb = function() {}; this.fetchGame(gId, (game) => { if (!game) { cb('No game has been created.'); return; } const act = HungryGames.Action.actionList.find((el) => el.name === action); if (!act) { cb('Unknown action.'); return; } if (!game.actions) { cb('Game doesn\'t have any action data.'); return; } if (!Array.isArray(game.actions[trigger])) { cb('Unknown trigger.'); return; } const created = HungryGames.Action[action].create(this._parent.client, gId, args); if (!created) { cb('Bad Args'); return; } const res = game.actions.insert(trigger, created); if (res) { this._parent._fire('actionInsert', gId, trigger); cb(null); return; } else { cb('Bad trigger.'); return; } }); } /** * @description Remove an action for a trigger in the given guild. * @public * @param {string} gId The guild ID of the game to modify. * @param {string} trigger The name of the trigger to remove the action from. * @param {string} id The id of the action to remove. * @param {Function} [cb] Callback once completed. Single argument is error * string if failed, or null if succeeded. */ removeAction(gId, trigger, id, cb) { if (typeof cb !== 'function') cb = function() {}; this.fetchGame(gId, (game) => { if (!game) { cb('No game has been created.'); return; } if (!game.actions) { cb('Game doesn\'t have any action data.'); return; } if (!Array.isArray(game.actions[trigger])) { cb('Unknown trigger.'); return; } const res = game.actions.remove(trigger, id); if (res) { this._parent._fire('actionRemove', gId, trigger); cb(); return; } else { cb('Bad Index.'); return; } }); } /** * @description Update a specific action for a trigger in the given guild. * @public * @param {string} gId The guild ID of the game to modify. * @param {string} trigger The name of the trigger to remove the action from. * @param {string} id The id of the action to remove. * @param {string} key The key of the value to change. * @param {number|string} value The value to set the setting to. * @param {Function} [cb] Callback once completed. Single argument is error * string if failed, or null if succeeded. */ updateAction(gId, trigger, id, key, value, cb) { if (typeof cb !== 'function') cb = function() {}; this.fetchGame(gId, (game) => { if (!game) { cb('No game has been created.'); return; } if (!game.actions) { cb('Game doesn\'t have any action data.'); return; } if (!Array.isArray(game.actions[trigger])) { cb('Unknown trigger.'); return; } const action = game.actions[trigger].find((el) => el.id === id); if (!action) { cb('Unknown action.'); return; } if (typeof value !== typeof action[key]) { cb('Bad value.'); return; } action[key] = value; this._parent._fire('actionUpdate', gId, trigger); cb(null); }); } /** * @description Create a new event. * @public * @param {HungryGames~Event} evt Event object, or Event-like object, to * finalize and save. * @param {Function} [cb] Optional callback that fires once data is saved to * file. First parameter is optional error string argument. Second is * otherwise the final created event. * @returns {?string} The event's ID if the event was created successfully, or * null if failed to parse data. */ createEvent(evt, cb) { if (typeof cb !== 'function') cb = function() {}; const creator = evt.creator; if (evt.type === 'normal') { const err = HungryGames.NormalEvent.validate(evt); if (err) { cb(err); return null; } evt = HungryGames.NormalEvent.from(evt); } else if (evt.type === 'arena') { const err = HungryGames.ArenaEvent.validate(evt); if (err) { cb(err); return null; } evt = HungryGames.ArenaEvent.from(evt); const fail = evt.outcomes.find( (el) => typeof el.id !== 'string' || el.id.length === 0); if (fail) { cb('BAD_OUTCOME_BAD_ID'); return; } } else if (evt.type === 'weapon') { const err = HungryGames.WeaponEvent.validate(evt); if (err) { cb(err); return null; } evt = HungryGames.WeaponEvent.from(evt); const fail = evt.outcomes.find( (el) => typeof el.id !== 'string' || el.id.length === 0); if (fail) { cb('BAD_OUTCOME_BAD_ID'); return; } } else { cb('BAD_TYPE'); return null; } const hash = HungryGames.Event.createIDHash(); const now = Date.now(); if (!evt.id.match(/\d{17,19}\/\d+-[0-9a-f]/)) { evt.id = `${creator}/${now}-${hash}`; } evt.creator = creator; if (evt.outcomes) { evt.outcomes.map((el) => HungryGames.NormalEvent.from(el)); } const newDir = HungryGames.EventContainer.eventDir; const filename = `${newDir}${evt.id}.json`; if (fs.existsSync(filename)) { cb('ALREADY_EXISTS'); return null; } const str = JSON.stringify(evt); this._parent.common.mkAndWrite(filename, null, str, (err) => { if (err) { console.error(err); cb('WRITE_FAILED'); return; } const toSend = global.sqlCon.format( 'INSERT INTO HGEvents (Id, CreatorId, DateCreated, Privacy, ' + 'EventType) VALUES (?, ?, FROM_UNIXTIME(?), "unlisted", ?)', [evt.id, evt.creator, now / 1000, evt.type]); global.sqlCon.query(toSend, (err) => { if (err) { console.error(err); cb('SQL_FAILED'); return; } cb(null, evt); }); }); return evt.id; } /** * @description Completely delete an event and all of its data. * @todo Deleting a sub-event is not safe for multiple requests, it does not * handle the requests properly if a second request is made before the first * is completed. * @public * @param {string} user The user requesting deletion. * @param {string} id The ID of the event to delete. * @param {Function} [cb] Callback once completed. Only parameter is optional * error string. */ deleteEvent(user, id, cb) { if (typeof cb !== 'function') cb = function() {}; if (typeof id !== 'string' || id.length === 0) { cb('BAD_ID'); return; } const match = id.match(/^(\d{17,19}\/\d+-[0-9a-z]+)\/([0-9a-z]+)$/); let sub = null; if (match) { id = match[1]; sub = match[2]; } const toSend = global.sqlCon.format('SELECT * FROM HGEvents WHERE id=?', [id]); global.sqlCon.query(toSend, (err, rows) => { if (err) { console.error(err); cb('SQL_FAILED'); return; } if (!rows || !rows[0] || !rows[0].CreatorId) { cb('BAD_ID'); return; } if (rows[0].CreatorId != user) { cb('BAD_USER'); return; } if (sub) { if (!this._defaultEventStore) { cb('NOT_READY'); return; } this._defaultEventStore.fetch(id, null, (err, evt) => { if (err) { cb(err); return; } const index = evt.outcomes.findIndex((el) => el.id === sub); if (index === -1) { cb('BAD_SUB_ID'); return; } evt.outcomes.splice(index, 1); this.replaceEvent(user, evt, cb); }); } else { const toSend = global.sqlCon.format('DELETE FROM HGEvents WHERE id=?', [id]); global.sqlCon.query(toSend, (err, rows) => { if (err) { console.error(err); cb('SQL_FAILED'); return; } else if (!rows.affectedRows) { console.error('FAILED to delete event row', id); } const filename = `${HungryGames.EventContainer.eventDir}${id}.json`; this._parent.common.unlink(filename, (err) => { if (err) { console.error(err); cb('UNLINK_FAILED'); return; } cb(null); }); }); } }); } /** * @description Replace an event with new data. * @public * @param {string} user The user requesting deletion. * @param {HungryGames~Event} evt The new event data. * @param {Function} [cb] Callback once completed. Only parameter is optional * error string. */ replaceEvent(user, evt, cb) { if (typeof cb !== 'function') cb = function() {}; if (evt.type === 'normal') { const err = HungryGames.NormalEvent.validate(evt); if (err) { cb(err); return; } evt = HungryGames.NormalEvent.from(evt); } else if (evt.type === 'arena') { const err = HungryGames.ArenaEvent.validate(evt); if (err) { cb(err); return; } evt = HungryGames.ArenaEvent.from(evt); } else if (evt.type === 'weapon') { const err = HungryGames.WeaponEvent.validate(evt); if (err) { cb(err); return; } evt = HungryGames.WeaponEvent.from(evt); } else { cb('BAD_TYPE'); return; } const newDir = HungryGames.EventContainer.eventDir; const filename = newDir + evt.id + '.json'; if (!fs.existsSync(filename)) { cb('NONEXISTENT'); return; } const toSend = global.sqlCon.format( 'SELECT * FROM HGEvents WHERE id=? LIMIT 1', [evt.id]); global.sqlCon.query(toSend, (err, rows) => { if (err) { console.error(err); cb('SQL_FAILED'); return; } if (!rows || !rows[0] || !rows[0].CreatorId) { cb('BAD_ID'); return; } if (rows[0].CreatorId != user) { cb('BAD_USER'); return; } const toSend = global.sqlCon.format( 'UPDATE HGEvents SET DateModified=FROM_UNIXTIME(?) WHERE id=?', [Date.now() / 1000, evt.id]); global.sqlCon.query(toSend, (err) => { if (err) console.error(err); }); const str = JSON.stringify(evt); this._parent.common.mkAndWrite(filename, newDir, str, (err) => { if (err) { console.error(err); cb('WRITE_FAILED'); return; } cb(null); }); }); } /** * @description Fetch all event IDs of the events the given user has created. * @public * @param {string} user The user requesting deletion. * @param {basicCB} [cb] Callback once completed. First parameter is optional * error string, second is otherwise an array if database rows. */ fetchUserEvents(user, cb) { const toSend = global.sqlCon.format( 'SELECT * FROM HGEvents WHERE CreatorId=?', [user]); global.sqlCon.query(toSend, (err, files) => { if (err) { cb('SQL_FAILED'); } else { cb(null, files); } }); } /** * @description Returns a guild's game data. Returns cached version if that * exists, or searches the file system for saved data. Data will only be * checked from disk at most once every `HungryGames~findDelay` milliseconds. * Returns `null` if data could not be found, or an error occurred. * * @private * @param {number|string} id The guild id to get the data for. * @param {Function} [cb] Callback to fire once complete. This becomes * asyncronous if given, if not given this function is syncronous. Single * parameter is null if not found, or {@link HungryGames~GuildGame} if found. * @returns {?HungryGames~GuildGame} The game data, or null if no game could * be loaded or loading asyncronously because a callback was given. */ _find(id, cb) { const a = typeof cb === 'function'; if (!a) cb = function() {}; if (!id) { cb(null); return null; } const now = Date.now(); if (this._games[id]) { this._findTimestamps[id] = now; cb(this._games[id]); return this._games[id]; } if (now - this._findTimestamps[id] < this._findDelay) { cb(null); return null; } this._findTimestamps[id] = now; const self = this; const parse = function(game) { if (!game) game = {}; if (!game.bot) game.bot = self._parent.client.user.id; try { game = HungryGames.GuildGame.from(game, self._parent.client); game.id = id; } catch (err) { self._parent.error('Failed to parse game data for guild ' + id); console.error(err); return null; } // Flush default and stale options. if (game.options) { for (const opt of self.defaultOptions.keys) { if (!(self.defaultOptions[opt] instanceof Object)) continue; if (typeof game.options[opt] !== typeof self.defaultOptions[opt].value) { if (self.defaultOptions[opt].value instanceof Object) { game.options[opt] = Object.assign({}, self.defaultOptions[opt].value); } else { game.options[opt] = self.defaultOptions[opt].value; } } else if (self.defaultOptions[opt].value instanceof Object) { const dKeys = Object.keys(self.defaultOptions[opt].value); dKeys.forEach((el) => { if (typeof game.options[opt][el] !== typeof self.defaultOptions[opt].value[el]) { game.options[opt][el] = self.defaultOptions[opt].value[el]; } }); } } for (const opt in game.options) { if (!(game.options[opt] instanceof Object)) continue; if (typeof self.defaultOptions[opt] === 'undefined') { delete game.options[opt]; } else if (game.options[opt].value instanceof Object) { const keys = Object.keys(game.options[opt].value); keys.forEach((el) => { if (typeof game.options[opt][el] !== typeof self.defaultOptions[opt].value[el]) { delete game.options[opt][el]; } }); } } } // If the bot stopped while simulating a day, just start over and try // again. if (game && game.currentGame && game.currentGame.day && game.currentGame.day.state === 1) { game.currentGame.day.state = 0; } return game; }; const filename = this._parent.common.guildSaveDir + id + this.hgSaveDir + this.saveFile; if (a) { this._parent.common.readAndParse(filename, (err, parsed) => { if (err) { if (err.code === 'ENOENT') { cb(null); } else { this._parent.debug('Failed to load game data for guild:' + id); console.error(err); } return; } this._games[id] = parse(parsed); cb(this._games[id]); }); } else { try { const tmp = fs.readFileSync(filename); try { this._games[id] = JSON.parse(tmp); if (this._parent.initialized) { this._parent.debug('Loaded game from file ' + id); } } catch (e2) { this._parent.error('Failed to parse game data for guild ' + id); return null; } } catch (e) { if (e.code !== 'ENOENT') { this._parent.debug('Failed to load game data for guild:' + id); console.error(e); } return null; } return this._games[id] = parse(this._games[id]); } } /** * @description Save all HG related data to file. Purges old data from memory * as well. * @public * @param {string} [opt='sync'] Can be 'async', otherwise defaults to * synchronous. */ save(opt) { Object.entries(this._games).forEach((obj) => { const id = obj[0]; if (!obj[1]) { console.error(id, 'Doesn\'t exist.'); delete this._games[obj[0]]; return; } const data = obj[1].serializable; const dir = `${this._parent.common.guildSaveDir}${id}${this.hgSaveDir}`; const filename = `${dir}${this.saveFile}`; const saveStartTime = Date.now(); let stringified; try { stringified = JSON.stringify(data); } catch (err) { this._parent.error('Failed to stringify synchronously'); console.error(err); return; } if (opt == 'async') { this._parent.common.mkAndWrite(filename, dir, stringified, (err) => { if (err) { this._parent.error(`Failed to save HG data for ${filename}`); console.error(err); } else if ( this._findTimestamps[id] - saveStartTime < -this._purgeDelta) { delete this._games[id]; delete this._findTimestamps[id]; this._parent.debug(`Purged ${id}`); } }); } else { this._parent.common.mkAndWriteSync(filename, dir, stringified); if (this._findTimestamps[id] - Date.now() < -this._purgeDelta) { delete this._games[id]; delete this._findTimestamps[id]; this._parent.debug('Purged ' + id); } } }); } /** * @description End all event listeners, intervals, and timeouts to prepare * for a full stop. * @public */ shutdown() { Object.values(this._games).forEach((el) => el.clearIntervals()); this.messages.shutdown(); } /** * Games with more than this many members is considered large, and will have * some features disabled in order to improve performance. * * @public * @static * @type {number} * @constant * @default 10000 */ static get largeServerCount() { return 10000; } } module.exports = HungryGames; const toLoad = [ // Actions './actions/Action.js', './actions/ActionManager.js', './actions/ActionStore.js', // Base './DefaultOptions.js', './ForcedOutcome.js', './Grammar.js', './FinalEvent.js', './Event.js', './NormalEvent.js', './ArenaEvent.js', './WeaponEvent.js', './Battle.js', './OutcomeProbabilities.js', './Day.js', './Messages.js', './UserIconUrl.js', './Player.js', './Team.js', './Game.js', './EventContainer.js', './Stats.js', './StatGroup.js', './StatManager.js', './GuildGame.js', // Sim './Simulator.js', ]; toLoad.forEach((el) => delete require.cache[require.resolve(el)]); toLoad.forEach((el) => { const obj = require(el); HungryGames[obj.name] = obj; });