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