// Copyright 2018-2022 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const fs = require('fs'); const Jimp = require('jimp'); const http = require('http'); const https = require('https'); const crypto = require('crypto'); const FuzzySearch = require('fuzzy-search'); const MessageMaker = require('./lib/MessageMaker.js'); require('./subModule.js').extend(HG); // Extends the SubModule class. delete require.cache[require.resolve('./locale/Strings.js')]; const Strings = require('./locale/Strings.js'); /** * @classdesc Hunger Games simulator subModule. * @class * @augments SubModule * @listens Discord~Client#guildDelete * @listens Discord~Client#channelDelete * @listens Command#hg */ function HG() { const self = this; /** * Name of the HG Web submodule for lookup. * * @private * @constant * @default * @type {string} */ const webSM = './web/hg.js'; this.myName = 'HG'; this.postPrefix = 'hg '; const hgPath = './hg/HungryGames.js'; delete require.cache[require.resolve(hgPath)]; const HungryGames = require(hgPath); const hg = new HungryGames(self); /** * @description Fetch a reference to the current HungryGames instance. * @public * @returns {HungryGames} Current instance. */ this.getHG = function() { return hg; }; /** * @description Instance of locale string manager. * @private * @type {Strings} * @default * @constant */ const strings = new Strings('hg'); strings.purge(); /** * The maximum number of bytes allowed to be received from a client in an * image upload. * * @public * @type {number} * @constant * @default 8000000 (8MB) */ this.maxBytes = 8000000; /** * The permission tags for all settings related to the Hungry Games. * * @private * @constant * @default * @type {string[]} */ const patreonSettingKeys = [ 'hg:fun_translators', 'hg:bar_color', 'hg:customize_stats', 'hg:personal_weapon', ]; /** * The file path to read battle events. * * @see {@link HungryGames~battles} * * @private * @type {string} * @constant * @default */ const battleFile = './save/hgBattles.json'; /** * Maximum amount of time to wait for reactions to a message. * * @private * @type {number} * @constant * @default 5 Minutes */ const maxReactAwaitTime = 5 * 1000 * 60; // 5 Minutes /** * Regex to match all URLs in a string. * * @private * @type {RegExp} * @constant * @default */ const urlRegex = new RegExp( '(http(s)?:\\/\\/.)?(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]' + '{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)(?![^<]*>)', 'g'); /** * Default options for a game. * * @private * @type {HungryGames~DefaultOptions} * @constant */ const defaultOptions = hg.defaultOptions; const defaultOptSearcher = new FuzzySearch(defaultOptions.keys); let cmdSearcher; /** * Default options for a game. * * @type {object.<{ * value: string|number|boolean, * values: null|string[], * comment: string * }>} * @constant */ this.defaultOptions = defaultOptions; /** * Default color to choose for embedded messages. * * @private * @type {Discord~ColorResolveable} * @constant * @default */ const defaultColor = [200, 125, 0]; /** * Color to put above patrons avatars. RGBA Hex (0xRRGGBBAA). * * @private * @type {number} * @constant * @default */ const patreonColor = 0xF96854FF; /** * Helper object of emoji characters mapped to names. * * @private * @type {object.<string>} * @constant */ const emoji = { x: '❌', whiteCheckMark: '✅', 0: '\u0030\u20E3', 1: '\u0031\u20E3', 2: '\u0032\u20E3', 3: '\u0033\u20E3', 4: '\u0034\u20E3', 5: '\u0035\u20E3', 6: '\u0036\u20E3', 7: '\u0037\u20E3', 8: '\u0038\u20E3', 9: '\u0039\u20E3', 10: '\u{1F51F}', arrowUp: '⬆', arrowDown: '⬇', arrowLeft: '⬅', arrowRight: '➡', arrowDoubleLeft: '⏪', arrowDoubleRight: '⏩', arrowsCounterClockwise: '🔄', crossedSwords: '⚔', shield: '🛡', heart: '❤', redHeart: '❤️', yellowHeart: '💛', blueHeart: '💙', brokenHeart: '💔', skull: '💀', question: '⚔', redCircle: '🔴', trophy: '🏆', }; /** * All attacks and outcomes for battles. * * @see {@link HungryGames~battleFile} * * @private * @type { * { * starts: string[], * attacks: HungryGames~Battle[], * outcomes: string[] * } * } */ let battles = {}; /** * @description The file where the default event IDs are listed. * @private * @type {string} * @default * @constant */ const eventFileList = './save/hgDefaultEvents.json'; /** * @description Container for all default events. * @private * @type {HungryGames~EventContainer} * @default * @constant */ const defaultEvents = new HungryGames.EventContainer(); /** * Messages I have sent showing current options. * * @private * @type {object.<Discord~Message>} * @default */ const optionMessages = {}; /** * The last time the currently scheduled reaction event listeners are expected * to end. Used for checking of submoduleis unloadable. * * @private * @type {number} */ let listenersEndTime = 0; /** * All registered event handlers. * * @private * @type {object.<Array.<Function>>} */ const eventHandlers = {}; /** * @description Parse all default events from file. * * @private */ function updateEvents() { fs.readFile(eventFileList, (err, data) => { if (err) { self.error('Failed to read default event list.'); console.error(err); return; } try { const parsed = JSON.parse(data); if (!parsed) return; loadDefaultsFromIds(parsed); } catch (err) { self.error(eventFileList + ' Parse failed.'); console.log(err); } }); } updateEvents(); fs.watchFile(eventFileList, {persistent: false}, (curr, prev) => { if (curr.mtime == prev.mtime) return; if (self.initialized) { self.debug('Re-reading default events from file'); } else { console.log('HG: Re-reading default events from file'); } updateEvents(); }); /** * @description Load all default events from file, described by the loaded * list from file. * @private * @param {{ * bloodbath: string[], * player: string[], * arena: string[], * weapon: string[] * }} obj List of IDs to load. */ function loadDefaultsFromIds(obj) { defaultEvents.updateAndFetchAll( obj, () => hg.setDefaultEvents(defaultEvents)); } /** * @description Parse all battles from file. * * @private */ function updateBattles() { fs.readFile(battleFile, (err, data) => { if (err) return; try { const parsed = JSON.parse(data); if (parsed) { battles = self.common.deepFreeze(parsed); hg.setDefaultBattles(battles); } } catch (err) { console.log(err); } }); } updateBattles(); fs.watchFile(battleFile, {persistent: false}, (curr, prev) => { if (curr.mtime == prev.mtime) return; if (self.initialized) { self.debug('Re-reading battles from file'); } else { console.log('HG: Re-reading battles from file'); } updateBattles(); }); /** * @description The object that stores all data to be formatted into the help * message. * * @private * @constant */ const helpObject = JSON.parse(fs.readFileSync('./docs/hgHelp.json')); /** @inheritdoc */ this.helpMessage = 'Module loading...'; /** * @description Set all help messages once we know what prefix to use. * * @private */ function setupHelp() { const prefix = self.bot.getPrefix() + self.postPrefix; self.helpMessage = '`' + prefix + 'help` for Hungry Games help.'; // Format help message into rich embed. const tmpHelp = new self.Discord.EmbedBuilder(); tmpHelp.setTitle(helpObject.title); tmpHelp.setURL( self.common.webURL + '#' + encodeURIComponent(helpObject.title.replace(/\s/g, '_'))); helpObject.sections.forEach((obj) => { const titleID = encodeURIComponent((self.postPrefix + obj.title).replace(/\s/g, '_')); const titleURL = `${self.common.webHelp}#${titleID} `; tmpHelp.addFields([{ name: obj.title, value: titleURL + '```js\n' + obj.rows .map((row) => { if (typeof row === 'string') { return prefix + row.replace(/\{prefix\}/g, prefix); } else if (typeof row === 'object') { return prefix + row.command.replace(/\{prefix\}/g, prefix) + ' // ' + row.description.replace(/\{prefix\}/g, prefix); } }) .join('\n') + '\n```', }]); }); tmpHelp.addFields([{ name: 'Web Interface', value: '[Hungry Games Online Control](' + self.common.webURL + 'hg/)```Manage the Games without using commands!\n' + self.common.webURL + 'hg/```', }]); self.helpMessage = tmpHelp; } /** @inheritdoc */ this.initialize = function() { const cmdOpts = { validOnlyInGuild: true, defaultDisabled: true, permissions: self.Discord.PermissionsBitField.Flags.ManageRoles | self.Discord.PermissionsBitField.Flags.ManageGuild | self.Discord.PermissionsBitField.Flags.ManageChannels, }; const cmdOptsAnywhere = { validOnlyInGuild: false, defaultDisabled: true, permissions: self.Discord.PermissionsBitField.Flags.ManageRoles | self.Discord.PermissionsBitField.Flags.ManageGuild | self.Discord.PermissionsBitField.Flags.ManageChannels, }; const subCmds = [ new self.command.SingleCommand('help', help), new self.command.SingleCommand('makemewin', commandMakeMeWin), new self.command.SingleCommand('makemelose', commandMakeMeLose), new self.command.SingleCommand( ['create', 'c', 'new'], mkCmd(createGame), cmdOpts), new self.command.SingleCommand( ['reset', 'clear'], mkCmd(resetGame), cmdOpts), new self.command.SingleCommand(['debug'], mkCmd(showGameInfo), cmdOpts), new self.command.SingleCommand( ['exclude', 'remove', 'exc', 'ex'], mkCmd(excludeUser), cmdOpts), new self.command.SingleCommand( ['include', 'add', 'inc', 'in'], mkCmd(includeUser), cmdOpts), new self.command.SingleCommand( ['options', 'setting', 'settings', 'set', 'option', 'opt', 'opts'], mkCmd(toggleOpt), cmdOpts), new self.command.SingleCommand( ['events', 'event'], mkCmd(useWebsiteForCustom), cmdOpts), new self.command.SingleCommand( ['claimlegacy'], mkCmd(commandClaimLegacyEvents), cmdOpts), new self.command.SingleCommand( ['npc', 'ai', 'npcs', 'ais', 'bots', 'bot'], mkCmd(listNPCs), cmdOpts, [ new self.command.SingleCommand( ['add', 'create'], mkCmd(createNPC), cmdOpts), new self.command.SingleCommand( ['rename', 'name', 'edit'], mkCmd(renameNPC), cmdOpts), new self.command.SingleCommand( ['remove', 'delete'], mkCmd(removeNPC), cmdOpts), new self.command.SingleCommand( ['include', 'inc', 'in'], mkCmd(includeNPC), cmdOpts), new self.command.SingleCommand( ['exclude', 'exc', 'ex'], mkCmd(excludeNPC), cmdOpts), ]), new self.command.SingleCommand( ['players', 'player', 'list'], mkCmd(listPlayers), cmdOpts), new self.command.SingleCommand( ['start', 's', 'begin'], mkCmd(startGame), cmdOpts), new self.command.SingleCommand(['pause', 'p'], mkCmd(pauseGame), cmdOpts), new self.command.SingleCommand( ['autoplay', 'autostart', 'auto', 'play', 'go'], mkCmd(startAutoplay), cmdOpts), new self.command.SingleCommand( ['next', 'nextday', 'resume', 'continue', 'unpause'], mkCmd(nextDay), cmdOpts), new self.command.SingleCommand( ['step', 'single', 'one', 'nextevent'], mkCmd(commandStep), cmdOpts), new self.command.SingleCommand( ['end', 'abort', 'stop'], mkCmd(endGame), cmdOpts), new self.command.SingleCommand( ['save'], (msg) => { if (self.common.trustedIds.includes(msg.author.id)) { self.save('async'); msg.channel.send({content: '`Saving all data.`'}); } }, cmdOpts), new self.command.SingleCommand( ['team', 'teams', 't'], mkCmd(editTeam), cmdOpts), new self.command.SingleCommand( ['stats', 'stat', 'info', 'me'], mkCmd(commandStats), {validOnlyInGuild: true}), new self.command.SingleCommand( [ 'lb', 'leaderboard', 'leaderboards', 'leader', 'leaders', 'top', 'rank', 'ranks', ], mkCmd(commandLeaderboard), {validOnlyInGuild: true}), new self.command.SingleCommand( ['group', 'groups', 'season', 'seasons', 'g', 'gr'], mkCmd(commandGroups), cmdOpts, [ new self.command.SingleCommand( ['create', 'new', 'make'], mkCmd(commandNewGroup), cmdOpts), new self.command.SingleCommand( ['delete', 'remove'], mkCmd(commandDeleteGroup), cmdOpts), new self.command.SingleCommand( ['select', 'choose', 'use'], mkCmd(commandSelectGroup), cmdOpts), new self.command.SingleCommand( ['rename', 'name', 'title'], mkCmd(commandRenameGroup), cmdOpts), ]), new self.command.SingleCommand( ['nums'], mkCmd(commandNums), cmdOptsAnywhere), new self.command.SingleCommand( ['rig', 'rigged'], mkCmd(commandRig), cmdOptsAnywhere), new self.command.SingleCommand( ['kill', 'smite'], mkCmd(commandKill), cmdOpts), new self.command.SingleCommand( ['heal', 'revive', 'thrive', 'resurrect', 'restore'], mkCmd(commandHeal), cmdOpts), new self.command.SingleCommand( ['wound', 'hurt', 'damage', 'stab', 'punch', 'slap', 'injure'], mkCmd(commandWound), cmdOpts), new self.command.SingleCommand( [ 'give', 'reward', 'award', 'sponsor', 'rewards', 'awards', 'gift', 'gifts', 'sponsors', ], mkCmd(commandGiveWeapon), cmdOpts), new self.command.SingleCommand( ['take', 'destroy', 'reduce'], mkCmd(commandTakeWeapon), cmdOpts), new self.command.SingleCommand( ['rename', 'name'], mkCmd(commandRename), cmdOpts), new self.command.SingleCommand( ['react', 'reaction', 'emote', 'emoji'], mkCmd(commandReactJoin), cmdOpts), ]; const hgCmd = new self.command.SingleCommand( [ 'hg', 'hunger', 'hungry', 'hungergames', 'hungrygames', 'hungergame', 'hungrygame', ], function(msg) { if (cmdSearcher && msg.text && msg.text.length > 1) { const toSearch = msg.text.trim().split(' ')[0]; const searched = cmdSearcher.search(toSearch); if (searched && searched.length > 0) { if (searched.length > 1) { reply( msg, 'unknownCommandSuggestionList', 'fillOne', searched .map((el) => `${msg.prefix}${self.postPrefix}${el}`) .join('\n')); } else { reply( msg, 'unknownCommandSuggestOne', 'fillOne', `${msg.prefix}${self.postPrefix}${searched[0]}`); } return; } } reply( msg, 'unknownCommand', 'unknownCommandHelp', `${msg.prefix}${self.postPrefix}`); }, null, subCmds); self.command.on(hgCmd); setupHelp(); self.client.on('guildDelete', onGuildDelete); self.client.on('channelDelete', onChannelDelete); self.client.guilds.cache.forEach((g) => { hg.fetchGame(g.id, (game) => { if (!game) return; if (game.currentGame && game.currentGame.day.state > 1 && game.currentGame.inProgress && !game.currentGame.ended && !game.currentGame.isPaused) { try { self.nextDay(game.author, g.id, game.outputChannel); } catch (err) { console.error(err); } } else { delete hg._games[g.id]; delete hg._findTimestamps[g.id]; } }); }); cmdSearcher = new FuzzySearch( Object.values(hgCmd.subCmds) .map((el) => el.aliases) .reduce((a, c) => a.concat(c))); if (self.client.shard) { /** * @description Fetch a string with the HG stats for this shard. * @private * @returns {string} Formatted stats string. */ self.client.getHGStats = getStatsString; } }; /** @inheritdoc */ this.shutdown = function() { self.command.deleteEvent('hg'); self.client.removeListener('guildDelete', onGuildDelete); self.client.removeListener('channelDelete', onChannelDelete); self._fire('shutdown'); Object.keys(eventHandlers).forEach((el) => delete eventHandlers[el]); fs.unwatchFile(eventFileList); fs.unwatchFile(battleFile); hg.shutdown(); if (self.client.shard) { self.client.getHGStats = null; } }; /** @inheritdoc */ this.unloadable = function() { const web = self.bot.getSubmodule(webSM); return self.getNumSimulating() === 0 && listenersEndTime < Date.now() && (!web || !web.getNumClients || web.getNumClients() == 0); }; /** @inheritdoc */ this.reloadable = function() { return self.getNumSimulating() === 0 && listenersEndTime < Date.now(); }; /** * @description Handle being removed from a guild. * * @private * @param {Discord~Guild} guild The guild that we just left. * @listens Discord~Client#guildDelete */ function onGuildDelete(guild) { hg.fetchGame(guild.id, (game) => { if (!game || !game.currentGame || !game.currentGame.inProgress) return; self.endGame(null, guild.id, true); }); } /** * @description Handle a channel being deleted. Cleans up games that may be in * progress in these channels. * * @private * @param {Discord~DMChannel|Discord~GuildChannel} channel The channel that * was deleted. * @listens Discord~Client#channelDelete */ function onChannelDelete(channel) { if (!channel.guild) return; if (!hg._games[channel.guild.id]) return; self.pauseGame(channel.guild.id); } /** * Make a subcommand handler with the given callback function. This is a * wrapper around existing functions. * * @private * @param {HungryGames~hgCommandHandler} cb Command handler when subcommand is * triggered. * @returns {Command~commandHandler} Subcommand initial handler that will fire * when command is fired. Calls the passed callback handler with the mapped * parameters. */ function mkCmd(cb) { return function(msg) { if (self.common.isRelease && (msg.guild && msg.guild.memberCount > 75000)) { reply(msg, 'largeServerDisabled', 'largeServerDisabledSub'); return; } const id = msg.guild && msg.guild.id; const cached = id && hg._games[id]; hg.fetchGame(id, (game) => { if (game) { if (!cached && game.legacyEvents) { setTimeout(() => { if (!hg._games[id]) return; if (!game.legacyEvents) return; reply( msg, 'legacyEventNoticeTitle', 'legacyEventNoticeBody', `${msg.prefix}${self.postPrefix}`); }, 1000); } if (game.loading) { reply(msg, 'loadingTitle', 'loadingBody'); return; } game.channel = msg.channel.id; game.author = msg.author.id; let text = msg.text.trim().toLocaleLowerCase(); if (text.length > 0) { if (game.includedNPCs) { game.includedNPCs.sort( (a, b) => b.username.length - a.username.length); game.includedNPCs.forEach((el) => { if (text.indexOf(el.username.toLocaleLowerCase()) > -1) { // text = text.replace(el.username.toLocaleLowerCase(), ''); msg.softMentions.users.set(el.id, el); } else if (text.indexOf(el.id.toLocaleLowerCase()) > -1) { text = text.replace(el.id.toLocaleLowerCase(), ''); msg.softMentions.users.set(el.id, el); } }); } if (game.excludedNPCs) { game.excludedNPCs.sort( (a, b) => b.username.length - a.username.length); game.excludedNPCs.forEach((el) => { if (text.indexOf(el.username.toLocaleLowerCase()) > -1) { // text = text.replace(el.username.toLocaleLowerCase(), ''); msg.softMentions.users.set(el.id, el); } else if (text.indexOf(el.id.toLocaleLowerCase()) > -1) { text = text.replace(el.id.toLocaleLowerCase(), ''); msg.softMentions.users.set(el.id, el); } }); } } } cb(msg, id /* , game*/); }); }; } /** * @description Reply to msg with locale strings. * @private * * @param {Discord~Message} msg Message to reply to. * @param {?string} titleKey String key for the title, or null for default. * @param {string} bodyKey String key for the body message. * @param {string} [rep] Placeholder replacements for the body only. * @returns {Promise<Discord~Message>} Message send promise from * {@link Discord}. */ function reply(msg, titleKey, bodyKey, ...rep) { return strings.reply(self.common, msg, titleKey, bodyKey, ...rep); } /** * @description Get the locale string in the given guild. * @public * * @see {@link Strings.get} * * @param {string} str String ID to get. * @param {string} gId ID of guild to get locale from. * @param {string} [rep] Replacements for string. * @returns {?string} Found string with replacements, or null. */ this.getString = function(str, gId, ...rep) { return strings.get( str, self.bot.getLocale && self.bot.getLocale(gId), ...rep); }; /** * Tell a user their chances of winning have not increased. * * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#hg makemelose */ function commandMakeMeWin(msg) { reply(msg, 'makeMeWin'); } /** * Tell a user their chances of losing have not increased. * * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#hg makemelose */ function commandMakeMeLose(msg) { reply(msg, null, 'makeMeLose', nothing()); } /** * Handler for a Hungry Games command. * * @callback HungryGames~hgCommandHandler * @param {Discord~Message} msg The message sent in Discord that triggered * this command. * @param {string} id The id of the guild this command was run on for * convenience. */ /** * @description A player object representing a non-player. It makes sense I * promise. This represents a Player in the game, that is not attached to a * real account. Serializable. * @inner * @augments HungryGames~Player */ class NPC extends HungryGames.Player { /** * @description Create a non-player character. * @param {string} username The username to show for this npc. * @param {string} avatarURL The url (or fake url) of the image to use as * the player's avatar. * @param {string} [id] Id to assign, if a valid id is not provided, a * random id will be generated. */ constructor(username, avatarURL, id) { if (typeof id !== 'string' || !NPC.checkID(id)) { id = NPC.createID(); } super(id, username, avatarURL); /** * Always true. * * @public * @default * @constant * @type {boolean} */ this.isNPC = true; /** * Equivalent to `this.name` for compatibility. * * @public * @type {string} */ this.username = this.name; } } /** * Create an NPC from an Object. Similar to copy-constructor. * * @public * @param {object} data NPC like Object. * @returns {HungryGames~NPC} Copied NPC. */ NPC.from = function(data) { const npc = new NPC(data.username, data.avatarURL, data.id); Object.assign(npc, HungryGames.Player.from(data)); return npc; }; /** * Generate a userID for an NPC. * * @public * @returns {string} Generated ID. */ NPC.createID = function() { let id; do { id = `NPC${crypto.randomBytes(8).toString('hex').toUpperCase()}`; } while (fs.existsSync(`${self.common.userSaveDir}avatars/${id}`)); return id; }; /** * Check if the given ID is a valid NPC ID. * * @public * @param {string} id The ID to validate. * @returns {boolean} True if ID is a valid ID for an NPC. */ NPC.checkID = function(id) { return typeof id === 'string' && (id.match(/^NPC[A-F0-9]+$/) && true || false); }; /** * Save an image for an NPC. Does NOT limit download sizes. * * @public * @param {string|Jimp|Buffer} avatar Any image, URL or file path to fetch the * avatar from. Anything supported by Jimp. * @param {string} id The NPC id to save the avatar to. * @returns {?Promise} Promise if successful will have the public URL where * the avatar is available. Null if error. */ NPC.saveAvatar = function(avatar, id) { if (!NPC.checkID(id)) return null; return self.readImage(avatar).then((image) => { if (!image) throw new Error('Failed to fetch NPC avatar.'); const dir = self.common.userSaveDir + 'avatars/' + id + '/'; const imgName = Date.now() + '.png'; const filename = dir + imgName; const url = self.common.avatarURL + (self.common.isRelease ? 'avatars/' : 'dev/avatars/') + id + '/' + imgName; const fetchSize = HungryGames.UserIconUrl.fetchSize; image.resize(fetchSize, fetchSize); image.getBuffer(Jimp.MIME_PNG, (err, buffer) => { if (err) { self.error(`Failed to convert image into buffer: ${avatar}`); console.error(err); return; } self.common.mkAndWrite(filename, dir, buffer, (err) => { if (!err) return; self.error(`Failed to cache NPC avatar: ${filename}`); console.error(err); }, self.common.encryptAvatars); }); return url; }); }; /** * @inheritdoc * @public */ this.NPC = NPC; /** * @description Returns an object storing all of the default events for the * games. * * @public * @returns {HungryGames~EventContainer} Object storing default events. */ this.getDefaultEvents = function() { return defaultEvents; }; /** * @description Returns the object storing all default * {@link HungryGames~Battle}s parsed from file. * * @public * @returns {HungryGames~Battle[]} Array of all default battle events. */ this.getDefaultBattles = function() { return battles; }; /** * @description Returns the object storing all default * {@link HungryGames~Weapon}s parsed from file. * * @public * @returns {HungryGames~Weapon[]} Array of all default weapons. */ this.getDefaultWeapons = function() { return defaultEvents.getArray('weapon'); }; // Create // /** * Create a Hungry Games for a guild. * * @private * @type {HungryGames~hgCommandHandler} * @param {?Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {boolean} [silent=false] Should we suppress replies to message. * @param {Function} [cb] Callback that fires once loading is complete. Only * parameter is created {@link HungryGames~GuildGame} or null if failed. */ function createGame(msg, id, silent, cb) { if (!msg) { silent = true; msg = { guild: self.client.guilds.resolve(id), }; } const g = hg.getGame(id); /** * @description Fires once game creation is done, and we are ready to * continue. * @private * @param {?HungryGames~GuildGame} game Created GuildGame if successful. */ const done = function(game) { if (!game) { self.warn('Failed to create/refresh game'); cb(null); return; } game.formTeams(); fetchPatreonSettings(game.currentGame.includedUsers, null, null, () => { if (typeof cb === 'function') cb(game); }); }; if (g && g.currentGame && g.currentGame.inProgress) { if (!silent) { reply( msg, 'createInProgressTitle', 'createInProgressBody', `${msg.prefix}${self.postPrefix}`); } if (typeof cb === 'function') cb(null); } else if (g) { if (!silent) reply(msg, 'createRefreshing'); g.includedUsers = g.includedUsers.filter((u) => { const m = msg.guild.members.resolve(u); if (m && m.partial) m.fetch(); return !!m; }); if (msg.guild.memberCount >= HungryGames.largeServerCount) { g.excludedUsers = []; } else { g.excludedUsers = g.excludedUsers.filter((u) => { const m = msg.guild.members.resolve(u); if (m && m.partial) m.fetch(); return m && !m.deleted; }); } hg.refresh(msg.guild, done); } else { hg.create(msg.guild, (game) => { if (!silent) reply(msg, 'createNew'); done(game); }); } } /** * Create a Hungry Games for a guild. * * @public * @param {string} id The id of the guild to create the game in. * @param {Function} [cb] Callback that fires once loading is complete. Only * parameter is created {@link HungryGames~GuildGame} or null if failed. */ this.createGame = function(id, cb) { createGame(null, id, true, cb); }; /** * Given an array of players, lookup the settings for each and update their * data. This is asynchronous. * * @private * * @param {HungryGames~Player[]} players The players to lookup and update. * @param {?string|number} cId The channel ID to fetch the settings for. * @param {?string|number} gId The guild ID to fetch the settings for. * @param {Function} [cb] Calls this callback on completion. No parameters. */ function fetchPatreonSettings(players, cId, gId, cb) { if (!self.bot.patreon || players.length == 0) { if (cb) cb(); return; } let permResponses = 0; let settingRequests = 0; let settingResponses = 0; /** * After retrieving whether the player is an actual patron (ignores * overrides), then fetch permissions from them (uses overrides). * * @private * * @param {?string} err Error string or null. * @param {?{status: string[], message: string}} info Permission * information. * @param {number} p Player object to update. */ function onCheckPatron(err, info, p) { if (!err) { if (info.status) { p.settings['isPatron'] = true; } } self.bot.patreon.getAllPerms( p.id, cId, gId, (err, info) => onPermResponse(err, info, p)); } /** * After retrieving a player's permissions, fetch their settings for each. * * @private * @param {?string} err Error string or null. * @param {?{status: string[], message: string}} info Permission * information. * @param {number} p Player object to update. */ function onPermResponse(err, info, p) { permResponses++; if (err) { if (permResponses === players.length && settingRequests === settingResponses && cb) { cb(); } return; } const values = info.status; for (let i = 0; i < values.length; i++) { if (!patreonSettingKeys.includes(values[i])) continue; settingRequests++; self.bot.patreon.getSettingValue( p.id, cId, gId, values[i], ((p, v) => (err, info) => onSettingResponse(err, info, p, v))( p, values[i])); } if (permResponses === players.length && settingRequests === settingResponses && cb) { cb(); } } /** * After retrieving a player's settings, update their data with the relevant * values. * * @private * @param {?string} err Error string or null. * @param {?{status: *, message: string}} info Permission information. * @param {number} p Player object to update. * @param {string} setting The setting name to update. */ function onSettingResponse(err, info, p, setting) { settingResponses++; if (err) { self.error(err); } else { if (setting == 'hg:bar_color') { let color; if (info.status.match(/^0x[0-9A-Fa-f]{8}$/)) { color = info.status * 1; } else if (info.status.match(/^0x[0-9A-Fa-f]{6}$/)) { // Color requires alpha value, but given is just rgb. Shift rgb, // then set alpha. color = ((info.status * 1) << 8) | 0xFF; } else { if (p.settings.isPatron) { color = patreonColor; } else { color = 0x0; } } p.settings[setting] = color >>> 0; } else { p.settings[setting] = info.status; } } if (permResponses === players.length && settingRequests === settingResponses && cb) { cb(); } } for (let i = 0; i < players.length; i++) { self.bot.patreon.checkPerm( players[i].id, null, ((p) => (err, info) => onCheckPatron(err, info, p))(players[i])); } } /** * Reset data that the user specifies. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function resetGame(msg, id) { const command = msg.text.trim().split(' ')[0]; reply(msg, 'resetTitle', hg.resetGame(id, command)); } /** * Send all of the game data about the current server to the chat. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function showGameInfo(msg, id) { let finalId = id; if (self.common.trustedIds.includes(msg.author.id)) { if (msg.text.trim().split(' ')[0]) { finalId = msg.text.trim().split(' ')[0]; } } const game = hg.getGame(finalId); if (game) { const file = new self.Discord.AttachmentBuilder(); file.setFile(Buffer.from(JSON.stringify(game.serializable, null, 2))); file.setName(`HG-${finalId}.json`); msg.channel.send( {content: `HG Data for guild ${finalId}`, files: [file]}); } else { reply(msg, 'noGame', 'fillOne', finalId); } } // Time Control // /** * Start the games in the channel this was called from. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function startGame(msg, id) { const game = hg.getGame(id); if (game && game.currentGame && game.currentGame.inProgress) { const prefix = `${msg.prefix}${self.postPrefix}`; reply(msg, 'startInProgressTitle', 'startInProgressBody', prefix); return; } const myPerms = msg.channel.permissionsFor(self.client.user.id); if (!myPerms || !myPerms.has(self.Discord.PermissionsBitField.Flags.AttachFiles)) { reply(msg, 'startNoAttachFiles'); if (!myPerms) { self.error( 'Failed to fetch perms for myself. ' + (msg.guild.members.me && true)); } return; } else if (!myPerms.has( self.Discord.PermissionsBitField.Flags.EmbedLinks)) { reply(msg, 'startNoEmbedLinks'); return; } else if (!myPerms.has( self.Discord.PermissionsBitField.Flags.SendMessages)) { return; } if (game && game.reactMessage) { self.endReactJoinMessage(id, (err) => { if (err) { self.error(`${err}: ${id}`); reply(msg, 'reactFailedTitle', err); } startGame(msg, id); }); return; } if (game) game.loading = true; /** * Once the game has finished loading all necessary data, start it if * autoplay is enabled. * * @private */ function loadingComplete() { setTimeout(() => { self._fire('gameStarted', id); const game = hg.getGame(id); HungryGames.ActionManager.gameStart(hg, game); if (game.autoPlay) nextDay(msg, id); }); if (game) game.loading = false; } createGame(msg, id, true, (g) => { if (!g) { if (game) { game.loading = false; if (game.currentGame) game.currentGame.inProgress = false; } self.warn('Failed to create game to start game'); reply(msg, 'createFailedUnknown'); return; } g.currentGame.inProgress = true; const finalMessage = makePlayerListEmbed(g, null, msg.locale); finalMessage.setTitle(hg.messages.get('gameStart', msg.locale)); if (!g.autoPlay) { finalMessage.setFooter({ text: strings.get( 'gameStartNextDayInfo', msg.locale, `${msg.prefix}${self.postPrefix}`), }); } let mentions = self.common.mention(msg); if (g.options.mentionEveryoneAtStart) { mentions += '@everyone'; } msg.channel.send({content: mentions, embeds: [finalMessage]}) .catch((err) => { reply(msg, 'startedTitle', 'startMessageRejected'); self.error( 'Failed to send start game message: ' + msg.channel.id + ' (Num: ' + g.currentGame.includedUsers.length + ')'); console.error(err); }); loadingComplete(); }); if (game && game.currentGame) game.currentGame.inProgress = true; } /** * Start the games in the given channel and guild by the given user. * * @public * @param {string} uId The id of the user who trigged the games to start. * @param {string} gId The id of the guild to run the games in. * @param {string} cId The id of the channel to run the games in. */ this.startGame = function(uId, gId, cId) { startGame(makeMessage(uId, gId, cId), gId); }; /** * Start autoplay in the given channel and guild by the given user. * * @public * @param {string} uId The id of the user who trigged autoplay to start. * @param {string} gId The id of the guild to run autoplay in. * @param {string} cId The id of the channel to run autoplay in. */ this.startAutoplay = function(uId, gId, cId) { startAutoplay(makeMessage(uId, gId, cId), gId); }; /** * End the games in the given guild as the given user. * * @public * @param {string|Discord~Message} uId The id of the user who trigged the * games to end, or a Discord message sent by the user who triggered this. * @param {string} gId The id of the guild to end the games in. */ this.endGame = function(uId, gId) { if (uId != null && typeof uId === 'object') { endGame(uId, gId); } else { endGame(makeMessage(uId, gId, null), gId, true); } }; /** * Pause autoplay in the given guild as the given user. * * @public * @param {string} uId The id of the user who trigged autoplay to end. * @param {string} gId The id of the guild to end autoplay. */ this.pauseAutoplay = function(uId, gId) { pauseAutoplay(makeMessage(uId, gId, null), gId); }; /** * Forms a Discord~Message similar object from given IDs. * * @private * @param {string} uId The id of the user who wrote this message. * @param {string} gId The id of the guild this message is in. * @param {?string} cId The id of the channel this message was 'sent' in. * @param {?string} msg The message content. * @returns {MessageMaker} The created message-like object. */ function makeMessage(uId, gId, cId, msg) { if (!cId && hg.getGame(gId)) cId = hg.getGame(gId).channel; return new MessageMaker(self, uId, gId, cId, msg); } /** * Stop autoplaying. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function pauseAutoplay(msg, id) { if (!hg.getGame(id) || !hg.getGame(id).autoPlay) { if (msg && msg.channel) { reply( msg, 'pauseAutoNoAutoTitle', 'pauseAutoNoAutoBody', `${msg.prefix}${self.postPrefix}`); } return; } hg.getGame(id).autoPlay = false; if (msg && msg.channel) { msg.channel .send({content: strings.get('pauseAuto', msg.locale, msg.author.id)}) .catch(() => {}); } } /** * Start autoplaying. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to start autoplay on. */ function startAutoplay(msg, id, game) { if (!game) game = hg.getGame(id); if (!game || !game.currentGame) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } startAutoplay(msg, id, game); }); return; } if (game.autoPlay && game.currentGame.inProgress) { if (game.currentGame.isPaused) { reply( msg, 'startAutoAlreadyEnabled', 'resumeAutoInstructions', `${msg.prefix}${self.postPrefix}`); } else { pauseAutoplay(msg, id); } } else { game.autoPlay = true; if (game.currentGame.inProgress && game.currentGame.day.state === 0) { if (self.command.validate(`${msg.prefix}hg next`, msg)) { reply(msg, 'noPermNext'); return; } nextDay(msg, id); msg.channel .send({ content: strings.get('startAutoDay', msg.locale, msg.author.id), }) .catch(() => {}); } else if (!game.currentGame.inProgress) { if (self.command.validate(`${msg.prefix}hg start`, msg)) { reply(msg, 'noPermStart'); return; } msg.channel .send({ content: strings.get('startAutoGame', msg.locale, msg.author.id), }) .catch(() => {}); startGame(msg, id); } else if (game.currentGame.isPaused) { reply( msg, 'enableAutoTitle', 'resumeAutoInstructions', `${msg.prefix}${self.postPrefix}`); } else { msg.channel .send( {content: strings.get('enableAuto', msg.locale, msg.author.id)}) .catch(() => {}); } } } /** * Pause the game in by clearing the current interval. * * @public * @param {string} id The id of the guild to pause in. * @returns {string} User information of the outcome of this command. */ this.pauseGame = function(id) { let locale = null; if (self.bot.getLocale) locale = self.bot.getLocale(id); if (!hg.getGame(id) || !hg.getGame(id).currentGame || !hg.getGame(id).currentGame.inProgress) { return strings.get('pauseGameNoGame', locale); } if (hg.getGame(id).currentGame.isPaused) { return strings.get('pauseGameAlreadyPaused', locale); } hg.getGame(id).clearIntervals(); hg.getGame(id).currentGame.isPaused = true; return strings.get('success', locale); }; /** * Stop the game in the middle of the day until resumed. Just clears the * interval for the game. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function pauseGame(msg, id) { reply(msg, 'pauseGameTitle', 'fillOne', self.pauseGame(id)); } /** * Start the next day of the game in the given channel and guild by the given * user. * * @public * @param {string} uId The id of the user who trigged autoplay to start. * @param {string} gId The id of the guild to run autoplay in. * @param {string} cId The id of the channel to run autoplay in. */ this.nextDay = function(uId, gId, cId) { nextDay(makeMessage(uId, gId, cId), gId); }; /** * Simulate a single day then show events to users. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {boolean} [autoStep=true] Value to pass for autoStep. */ function nextDay(msg, id, autoStep = true) { if (!msg.channel) { self.error('Failed to start next day because channel is unknown: ' + id); return; } const game = hg.getGame(id); if (!game || !game.currentGame || !game.currentGame.inProgress) { const prefix = msg.prefix = self.postPrefix; reply(msg, 'needStartGameTitle', 'needStartGameBody', prefix) .catch((err) => { self.error('Failed to tell user to start game: ' + err.message); if (err.message != 'No Perms') console.error(err); }); if (game) game.clearIntervals(); return; } if (game.currentGame.day.state !== 0) { if (game._autoStep) { reply(msg, 'nextDayAlreadySimulating'); } else if (game.currentGame.day.state == 1) { reply(msg, 'nextDayAlreadySimBroken').catch((err) => { self.error( 'Failed to tell user day is already in progress: ' + err.message); if (err.message != 'No Perms') console.error(err); }); } else if (autoStep) { game.createInterval(dayStateModified); } else { game.setStateUpdateCallback(dayStateModified); game.step(); } return; } const myPerms = msg.channel.permissionsFor(self.client.user.id); if (!myPerms || (!myPerms.has(self.Discord.PermissionsBitField.Flags.AttachFiles) && !myPerms.has(self.Discord.PermissionsBitField.Flags.Administrator))) { reply(msg, 'nextDayPermImagesTitle', 'nextDayPermImagesBody'); if (!myPerms) { self.error( 'Failed to fetch perms for myself. ' + (msg.guild.members.me && true)); } return; } else if ( !myPerms.has(self.Discord.PermissionsBitField.Flags.EmbedLinks) && !myPerms.has(self.Discord.PermissionsBitField.Flags.Administrator)) { reply(msg, 'nextDayPermEmbedTitle', 'nextDayPermEmbedBody'); return; } const sim = new HungryGames.Simulator(game, hg, msg); const iTime = Date.now(); sim.go((err) => { if (err) self.warn(`Simulator failed with reason: ${err}`); game.outputChannel = msg.channel.id; // Signal ready to display events. self._fire('dayStateChange', id); HungryGames.ActionManager.dayStart(hg, game); if (!game._dayEventInterval && !game._autoPlayTimeout) { game._autoPlayTimeout = setTimeout(() => { game.setStateUpdateCallback(dayStateModified); if (!game._dayEventInterval && autoStep) game.createInterval(); }, game.options.disableOutput ? 0 : game.options.delayEvents); } }); const now = Date.now(); if (now - iTime > 10) { self.warn(`Simulator.go ${now - iTime}`); } /** * @description Callback for every time the game state is modified. * @fires HG#dayStateChange * @private * @type {HungryGames~GuildGame~StateUpdateCB} * @param {boolean} dayComplete Has the day ended. * @param {boolean} doSim If next day should be simulated and started. */ function dayStateModified(dayComplete, doSim) { self._fire('dayStateChange', id); if (doSim) { nextDay(msg, id, !game.currentGame.isPaused); } else if (dayComplete) { endDayCheck(msg, id); } else { HungryGames.ActionManager.stepped(hg, game); } } } /** * Trigger the end of a day. * * @private * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function endDayCheck(msg, id) { let numAlive = 0; let numTeams = 0; const game = hg.getGame(id); const current = game.currentGame; current.includedUsers.forEach((el) => el.living && numAlive++); if (game.options.teamSize > 0) { current.teams.forEach((team) => team.numAlive > 0 && numTeams++); } if (current.numAlive != numAlive) { self.warn( 'Realtime alive count is incorrect! ' + current.numAlive + ' vs ' + numAlive); current.numAlive = numAlive; } const collab = game.options.teammatesCollaborate == 'always' || (game.options.teammatesCollaborate == 'untilend' && numTeams > 1); if ((collab && numTeams === 1) || numAlive <= 1) { current.inProgress = false; current.ended = true; game.autoPlay = false; HungryGames.ActionManager.gameEnd(hg, game); } else { HungryGames.ActionManager.dayEnd(hg, game); } } /** * Show only the next event in a day. * * @public * @param {string} uId The id of the user who trigged this step. * @param {string} gId The id of the guild to step the game in. * @param {string} cId The id of the channel the request was sent from. */ this.gameStep = function(uId, gId, cId) { commandStep(makeMessage(uId, gId, cId), gId); }; /** * Show only the next event in a day. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function commandStep(msg, id) { if (!msg.channel) { self.error('Failed to start next day because channel is unknown: ' + id); return; } const game = hg.getGame(id); if (game && game.currentGame && !game.currentGame.isPaused) { pauseGame(msg, id); } else if ( !game || !game.currentGame || !game.currentGame.inProgress || !game.currentGame.day.state < 2 || !game._stateUpdateCallback) { nextDay(msg, id, false); } else { game.currentGame.isPaused = true; game.step(); } } /** * End a game early. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {boolean} [silent=false] Prevent sending messages. */ function endGame(msg, id, silent = false) { const game = hg.getGame(id); if (!game || !game.currentGame.inProgress) { if (!silent && msg) reply(msg, 'endGameNoGame'); } else if ( game.loading || (game.currentGame && game.currentGame.day.state == 1)) { if (!silent && msg) { reply(msg, 'endGameLoading'); } } else { game.end(); HungryGames.ActionManager.gameAbort(hg, game); if (!silent && msg) reply(msg, 'endGameSuccess'); } } // User Management // /** * Remove a user from users to be in next game. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] Game object to exclude user from. */ function excludeUser(msg, id, game) { if (!game) game = hg.getGame(id); if (!game || !game.currentGame) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } excludeUser(msg, id, game); }); return; } let firstWord = msg.text.trim().split(' ')[0]; if (firstWord) firstWord = firstWord.toLowerCase(); const specialWords = { everyone: ['everyone', '@everyone', 'all'], online: ['online', 'here'], offline: ['offline'], idle: ['idle', 'away', 'snooze', 'snoozed'], dnd: ['dnd', 'busy'], bots: ['bot', 'bots'], npcs: ['npc', 'npcs', 'ai', 'ais'], }; let resPrefix = ''; let resPostfix = 'excludePast'; const done = function(response) { const locale = self.bot.getLocale && self.bot.getLocale(id); const title = strings.get( 'excludeTemplate', locale, strings.get(resPrefix, locale), strings.get(resPostfix, locale)); const body = response.substr(0, 1024); self.common.reply(msg, title, body); }; if (game.currentGame.inProgress) resPostfix = 'excludeFuture'; if (specialWords.everyone.includes(firstWord)) { resPrefix = 'usersAll'; self.excludeUsers('everyone', id, done); } else if (specialWords.online.includes(firstWord)) { resPrefix = 'usersOnline'; self.excludeUsers('online', id, done); } else if (specialWords.offline.includes(firstWord)) { resPrefix = 'usersOffline'; self.excludeUsers('offline', id, done); } else if (specialWords.idle.includes(firstWord)) { resPrefix = 'usersIdle'; self.excludeUsers('idle', id, done); } else if (specialWords.dnd.includes(firstWord)) { resPrefix = 'usersDND'; self.excludeUsers('dnd', id, done); } else if (specialWords.npcs.includes(firstWord)) { resPrefix = 'usersNPCs'; self.excludeUsers(game.includedNPCs.slice(0), id, done); } else if (specialWords.bots.includes(firstWord)) { resPrefix = 'usersBots'; resPostfix = 'excludeBlocked'; done(self.setOption(id, 'includeBots', false)); } else if ( msg.mentions.users.size + msg.softMentions.users.size + msg.mentions.roles.size + msg.softMentions.roles.size == 0) { reply(msg, 'excludeNoMention'); } else { self.excludeUsers(parseMentions(msg), id, (res) => { self.common.reply(msg, res); }); } } /** * Removes users from a games of a given guild. * * @fires HG#refresh * @public * @param {string|string[]|Discord~User[]|HungryGames~NPC[]} users The users * to exclude, or * 'everyone' to exclude everyone. * @param {string} id The guild id to remove the users from. * @param {Function} cb Callback for when long running operations complete. * Single argument with a string with the outcomes of each user. May have * multiple lines for a single user. */ this.excludeUsers = function(users, id, cb) { const game = hg.getGame(id); const locale = self.bot.getLocale && self.bot.getLocale(id); if (!game) { cb(strings.get('noGame', locale)); return; } if (game.loading) { cb(strings.get('stillLoading', locale)); return; } if (!game.excludedNPCs) game.excludedNPCs = []; if (!game.includedNPCs) game.includedNPCs = []; const iTime = Date.now(); const tmp = []; let npcs = []; const large = self.client.guilds.resolve(id).memberCount >= HungryGames.largeServerCount; switch (users) { case 'everyone': users = game.includedUsers; npcs = game.includedNPCs; break; case 'online': case 'offline': case 'idle': case 'dnd': game.includedUsers.forEach((u) => { const user = self.client.users.resolve(u); if (user && user.presence.status === users) tmp.push(user); }); users = tmp; break; default: if (typeof users === 'string') { cb(strings.get('usersInvalid', locale)); return; } break; } if (!Array.isArray(users)) { users = [...users.values()]; } const num = users.length + npcs.length; const numUsers = users.length; if (num > 10000) { self.warn(`Excluding ${num} users.`); } const iTime2 = Date.now(); const onlyError = num > 2; const response = []; let fetchWait = 0; const chunk = function(i = -1) { if (i < 0) i = num - 1; // Touch the game so it doesn't get purged from memory. const game = hg.getGame(id); game.loading = true; const start = Date.now(); for (i; i >= 0 && Date.now() - start < hg.maxDelta; i--) { if (i < numUsers) { if (typeof users[i] === 'string' && !users[i].startsWith('NPC') && !self.client.users.resolve(users[i])) { fetchWait++; self.client.users.fetch(users[i]).then(fetched).catch((err) => { response.push(err.message); fetched(); }); } else { response.push( excludeIterate(game, users[i], onlyError, large, locale)); } } else { response.push( excludeIterate( game, npcs[i - numUsers], onlyError, large, locale)); } } if (i >= 0) { setTimeout(() => chunk(i)); } else if (fetchWait === 0) { done(); } }; const done = function() { game.loading = false; const now = Date.now(); const begin = iTime2 - iTime; const loop = now - iTime2; if (begin > 10 || loop > 10) { self.debug(`Excluding ${num} ${begin} ${loop}`); } const finalRes = (response.length > 0 && response.filter((el) => el !== '\n').join('').trim()) || strings.get('excludeLargeSuccess', locale, num); cb(finalRes); self._fire('refresh', id); }; const fetched = function(user) { fetchWait--; if (user) response.push(excludeIterate(game, user, onlyError, large)); if (fetchWait === 0) done(); }; setTimeout(chunk); }; /** * @description Exclude a single user from the game as a single iteration step * of the exclude command. * @private * @param {HungryGames~GuildGame} game The game to manipulate. * @param {string|HungryGames~Player|HungryGames~NPC} obj Player for this * iteration. * @param {boolean} [onlyError=false] Only add error messages to response. * @param {boolean} [large=false] Is this a large game where excluded users * are not tracked. * @param {?string} [locale=null] String locale for respons formatting. * @returns {string} Response text for the user performing the operation. */ function excludeIterate( game, obj, onlyError = false, large = false, locale = null) { if (!obj || obj === 'undefined') return ''; const response = []; if (typeof obj === 'string') { if (obj.startsWith('NPC')) { obj = game.includedNPCs.find((el) => el.id == obj); if (!obj && game.excludedNPCs.find((el) => el.id == obj)) { response.push( strings.get('excludeAlreadyExcluded', locale, obj.name)); return `${response.join('\n')}\n`; } } else { obj = self.client.users.resolve(obj); } if (!obj) { response.push(strings.get('excludeInvalidId', locale, obj)); return `${response.join('\n')}\n`; } } else if (obj.id.startsWith('NPC') && !(obj instanceof NPC)) { const objId = obj.id; obj = game.includedNPCs.find((el) => el.id == obj.id); if (!obj) { response.push(strings.get('excludeUnableToFind', locale, objId)); self.error(`Unable to find NPC matching NPC-like data: ${game.id}`); return `${response.join('\n')}\n`; } } if ((!large && game.excludedUsers.includes(obj.id)) || (large && !game.includedUsers.includes(obj.id))) { if (!onlyError) { response.push( strings.get('excludeAlreadyExcluded', locale, obj.username)); } } else { if (obj.isNPC) { game.excludedNPCs.push(obj); if (!onlyError) { response.push( strings.get('excludeBlacklist', locale, obj.username) + '*'); } const includeIndex = game.includedNPCs.findIndex((el) => el.id == obj.id); if (includeIndex >= 0) { /* if (!onlyError) { response += obj.username + ' removed from whitelist.\n'; } */ game.includedNPCs.splice(includeIndex, 1); } } else { if (!large) game.excludedUsers.push(obj.id); if (!onlyError) { response.push(strings.get('excludeBlacklist', locale, obj.username)); } if (!game.includedUsers) game.includedUsers = []; const includeIndex = game.includedUsers.indexOf(obj.id); if (includeIndex >= 0) { /* if (!onlyError) { response += obj.username + ' removed from whitelist.\n'; } */ game.includedUsers.splice(includeIndex, 1); } } if (!game.currentGame.inProgress) { const index = game.currentGame.includedUsers.findIndex((el) => el.id == obj.id); if (index >= 0) { game.currentGame.includedUsers.splice(index, 1); /* if (!onlyError) { response += obj.username + ' removed from included players.\n'; } */ game.formTeams(game.id); } else if (!game.options.includeBots && obj.bot) { // Bots are already excluded. } else { response.push( strings.get('excludeFailedUnknown', locale, obj.username)); self.error(`Failed to remove player from included list. (${obj.id})`); } } } return `${response.join('\n')}\n`; } /** * Add a user back into the next game. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function includeUser(msg, id, game) { if (!game) game = hg.getGame(id); if (!game || !game.currentGame) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } includeUser(msg, id, game); }); return; } let firstWord = msg.text.trim().split(' ')[0]; if (firstWord) firstWord = firstWord.toLowerCase(); const specialWords = { everyone: ['everyone', '@everyone', 'all'], online: ['online', 'here', '@here'], offline: ['offline'], idle: ['idle', 'away', 'snooze', 'snoozed'], dnd: ['dnd', 'busy'], bots: ['bot', 'bots'], npcs: ['npc', 'npcs', 'ai', 'ais'], }; let resPrefix = ''; let resPostfix = 'includePast'; const done = function(response) { const locale = self.bot.getLocale && self.bot.getLocale(id); const title = strings.get( 'excludeTemplate', locale, strings.get(resPrefix, locale), strings.get(resPostfix, locale)); const body = response.substr(0, 1024); self.common.reply(msg, title, body); }; if (game.currentGame.inProgress) resPostfix = 'includeFuture'; if (specialWords.everyone.includes(firstWord)) { resPrefix = 'usersAll'; self.includeUsers('everyone', id, done); } else if (specialWords.online.includes(firstWord)) { resPrefix = 'usersOnline'; self.includeUsers('online', id, done); } else if (specialWords.offline.includes(firstWord)) { resPrefix = 'usersOffline'; self.includeUsers('offline', id, done); } else if (specialWords.idle.includes(firstWord)) { resPrefix = 'usersIdle'; self.includeUsers('idle', id, done); } else if (specialWords.dnd.includes(firstWord)) { resPrefix = 'usersDND'; self.includeUsers('dnd', id, done); } else if (specialWords.npcs.includes(firstWord)) { resPrefix = 'usersNPCs'; self.includeUsers(game.excludedNPCs.slice(0), id, done); } else if (specialWords.bots.includes(firstWord)) { resPrefix = 'usersBots'; resPostfix = 'includeUnblocked'; done(self.setOption(id, 'includeBots', true)); } else if ( msg.mentions.users.size + msg.softMentions.users.size + msg.mentions.roles.size + msg.softMentions.roles.size == 0) { reply(msg, 'includeNoMention'); } else { self.includeUsers(parseMentions(msg), id, (response) => { self.common.reply(msg, response); }); } } /** * Adds a user back into the next game. * * @fires HG#refresh * @public * @param {string|string[]|Discord~User[]|HungryGames~NPC[]} users The users * to include, 'everyone' to include all users, 'online' to include online * users, 'offline', 'idle', or 'dnd' for respective users. * @param {string} id The guild id to add the users to. * @param {Function} cb Callback for when long running operations complete. * Single argument with a string with the outcomes of each user. May have * multiple lines for a single user. */ this.includeUsers = function(users, id, cb) { const game = hg.getGame(id); const locale = self.bot.getLocale && self.bot.getLocale(id); if (!game) { cb(strings.get('noGame', locale)); return; } if (game.loading) { cb(strings.get('stillLoading', locale)); return; } if (!game.excludedNPCs) game.excludedNPCs = []; if (!game.includedNPCs) game.includedNPCs = []; const iTime = Date.now(); const tmp = []; let npcs = []; const large = self.client.guilds.resolve(id).memberCount >= HungryGames.largeServerCount; if (large && typeof users === 'string') { cb('Too many members'); return; } switch (users) { case 'everyone': users = game.excludedUsers; npcs = game.excludedNPCs; break; case 'online': case 'offline': case 'idle': case 'dnd': game.excludedUsers.forEach((u) => { const user = self.client.users.resolve(u); if (user && user.presence.status === users) tmp.push(user); }); users = tmp; break; default: if (typeof users === 'string') { cb(strings.get('usersInvalid', locale)); return; } break; } if (!Array.isArray(users)) { users = [...users.values()]; } const num = users.length + npcs.length; const numUsers = users.length; if (num > 10000) { self.warn(`Including ${num} users.`); } const iTime2 = Date.now(); const onlyError = num > 2; const response = []; let fetchWait = 0; const chunk = function(i = -1) { if (i < 0) i = num - 1; // Touch the game so it doesn't get purged from memory. const game = hg.getGame(id); game.loading = true; const start = Date.now(); for (i; i >= 0 && Date.now() - start < hg.maxDelta; i--) { if (i < numUsers) { if (typeof users[i] === 'string' && !users[i].startsWith('NPC') && !self.client.users.resolve(users[i])) { fetchWait++; self.client.users.fetch(users[i]).then(fetched).catch((err) => { response.push(err.message); fetched(); }); } else { response.push(includeIterate(game, users[i], onlyError)); } } else { response.push(includeIterate(game, npcs[i - numUsers], onlyError)); } } if (i >= 0) { setTimeout(() => { chunk(i); }); } else if (fetchWait === 0) { done(); } }; const done = function() { game.loading = false; const now = Date.now(); const begin = iTime2 - iTime; const loop = now - iTime2; if (begin > 10 || loop > 10) { self.debug(`Including ${num} ${begin} ${loop}`); } const finalRes = (response.length > 0 && response.filter((el) => el !== '\n').join('').trim()) || strings.get('includeLargeSuccess', locale, num); cb(finalRes); self._fire('refresh', id); }; const fetched = function(user) { fetchWait--; if (user) response.push(includeIterate(game, user, onlyError)); if (fetchWait === 0) done(); }; setTimeout(chunk); }; /** * @description Include a single user from the game as a single iteration step * of the include command. * @private * @param {HungryGames~GuildGame} game The game to manipulate. * @param {string|HungryGames~Player|HungryGames~NPC} obj Player for this * iteration. * @param {boolean} [onlyError=false] Only add error messages to response. * @param {?string} [locale=null] String locale for respons formatting. * @returns {string} Response text for the user performing the operation. */ function includeIterate(game, obj, onlyError = false, locale = null) { if (!obj || obj === 'undefined') return ''; const response = []; if (typeof obj === 'string') { if (obj.startsWith('NPC')) { obj = game.excludedNPCs.find((el) => el.id == obj); if (!obj && game.includedNPCs.find((el) => el.id == obj)) { response.push( strings.get('includeAlreadyIncluded', locale, obj.username)); return `${response.join('\n')}\n`; } } else { obj = self.client.users.resolve(obj); } if (!obj) { response.push(strings.get('excludeInvalidId', locale, obj)); return `${response.join('\n')}\n`; } } else if (obj.id.startsWith('NPC') && !(obj instanceof NPC)) { const objId = obj.id; obj = game.excludedNPCs.find((el) => el.id == obj.id); if (!obj) { response.push(strings.get('includeUnableToFind', locale, objId)); self.error(`Unable to find NPC matching NPC-like data: ${game.id}`); return `${response.join('\n')}\n`; } } if (!game.options.includeBots && obj.bot) { response.push(strings.get('includeBotsDisabled', locale, obj.username)); return `${response.join('\n')}\n`; } if (obj.isNPC) { const excludeIndex = game.excludedNPCs.findIndex((el) => el.id == obj.id); if (excludeIndex >= 0) { /* if (!onlyError) { response += obj.username + ' removed from blacklist.\n'; } */ game.excludedNPCs.splice(excludeIndex, 1); } if (!game.includedNPCs.find((el) => el.id == obj.id)) { game.includedNPCs.push(obj); if (!onlyError) { response.push( strings.get('includeWhitelist', locale, obj.username) + '*'); } } } else { const excludeIndex = game.excludedUsers.indexOf(obj.id); if (excludeIndex >= 0) { /* if (!onlyError) { response += obj.username + ' removed from blacklist.\n'; } */ game.excludedUsers.splice(excludeIndex, 1); } if (!game.includedUsers.includes(obj.id)) { game.includedUsers.push(obj.id); if (!onlyError) { response.push(strings.get('includeWhitelist', locale, obj.username)); } } } if (game.currentGame.inProgress) { if (!onlyError) { response.push(strings.get('includeSkipped', locale, obj.username)); } } else if (!game.currentGame.includedUsers.find((u) => u.id === obj.id)) { if (obj.isNPC) { game.currentGame.includedUsers.push( new NPC(obj.name, obj.avatarURL, obj.id)); } else { const avatar = (obj.displayAvatarURL && obj.displayAvatarURL({extension: 'png'})) || obj.avatarURL; game.currentGame.includedUsers.push( new HungryGames.Player(obj.id, obj.username, avatar, obj.nickname)); } /* if (!onlyError) { response += obj.username + ' added to included players.\n'; } */ game.formTeams(); } else { if (!onlyError) { response.push( strings.get('includeAlreadyIncluded', locale, obj.username)); } } return `${response.join('\n')}\n`; } /** * Show a formatted message of all users and teams in current server. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function listPlayers(msg, id) { const game = hg.getGame(id); if (!game) { reply(msg, 'gameNotCreated'); return; } const finalMessage = makePlayerListEmbed(game, null, msg.locale); finalMessage.setDescription( strings.get( 'playerRefreshInfo', msg.locale, `${msg.prefix}${self.postPrefix}`)); msg.channel .send({content: self.common.mention(msg), embeds: [finalMessage]}) .catch((err) => { reply(msg, 'messageRejected'); self.error( 'Failed to send list of players message: ' + msg.channel.id); console.error(err); }); } /** * @description Create a {@link Discord~EmbedBuilder} that lists all * included and excluded players in the game. * @private * @param {HungryGames~GuildGame} game The game to format. * @param {Discord~EmbedBuilder} [finalMessage] Optional existing * embed to modify instead of creating a new one. * @param {?string} [locale=null] Language locale to format titles. * @returns {Discord~EmbedBuilder} The created message embed. */ function makePlayerListEmbed(game, finalMessage, locale = null) { if (!finalMessage) { finalMessage = new self.Discord.EmbedBuilder(); finalMessage.setTitle(strings.get('listPlayerTitle', locale)); finalMessage.setColor(defaultColor); } if (!game || !game.currentGame || !game.currentGame.includedUsers) { finalMessage.addFields([{ name: strings.get('listPlayerNoPlayersTitle', locale), value: strings.get('listPlayerNoPlayersBody', locale), }]); return finalMessage; } const numUsers = game.currentGame.includedUsers.length; if (numUsers > 200) { finalMessage.addFields([{ name: strings.get('listPlayerIncludedNum', locale, numUsers), value: strings.get( 'listPlayerExcludedNum', locale, game.excludedUsers.length), }]); return finalMessage; } if (game.options.teamSize > 0) self.sortTeams(game); const splitEmbeds = game.currentGame.teams.length < 25 && game.options.teamSize > 0; let prevTeam = null; const statusList = game.currentGame.includedUsers.map((obj) => { let myTeam = null; if (game.options.teamSize > 0) { myTeam = game.currentGame.teams.find( (team) => team.players.find((player) => player == obj.id)); /* if (!myTeam) { self.error( 'Failed to find team for player: ' + obj.id + ' in ' + game.id); console.error(game.currentGame.teams); } */ } let shortName; if (obj.nickname && game.options.useNicknames) { shortName = obj.nickname.substring(0, 16); if (shortName != obj.nickname) { shortName = `${shortName.substring(0, 13)}...`; } } else { shortName = obj.name.substring(0, 16); if (shortName != obj.name) { shortName = `${shortName.substring(0, 13)}...`; } } if (splitEmbeds) return shortName; let prefix = ''; if (myTeam && myTeam !== prevTeam) { prevTeam = myTeam; prefix = `__${myTeam.name}__\n`; } return `${prefix}\`${shortName}\``; }); if (game.options.teamSize == 0) { statusList.sort((a, b) => { a = a.toLocaleLowerCase(); b = b.toLocaleLowerCase(); if (a < b) return -1; if (a > b) return 1; return 0; }); } if (splitEmbeds) { game.currentGame.teams.reverse().forEach((el) => { finalMessage.addFields([{ name: el.name || el.id, value: statusList.splice(0, el.players.length).join('\n').slice(0, 1023), }]); }); } else { const numCols = self.calcColNum(statusList.length > 10 ? 3 : 2, statusList); if (statusList.length >= 5) { const quarterLength = Math.ceil(statusList.length / numCols); for (let i = 0; i < numCols - 1; i++) { const thisMessage = statusList.splice(0, quarterLength).join('\n').substring(0, 1024); finalMessage.addFields([{ name: strings.get( 'listPlayerIncludedNum', locale, `${i * quarterLength + 1}-${(i + 1) * quarterLength}`), value: thisMessage, }]); } finalMessage.addFields([{ name: strings.get( 'listPlayerIncludedNum', locale, `${(numCols - 1) * quarterLength + 1}-${numUsers}`), value: statusList.join('\n'), }]); } else { finalMessage.addFields([{ name: strings.get('listPlayerIncludedNum', locale, numUsers), value: statusList.join('\n') || 'Nobody', }]); } } if (game.excludedUsers.length > 0) { let excludedList = '\u200B'; if (game.excludedUsers.length < 20) { const guild = self.client.guilds.resolve(game.id); excludedList = game.excludedUsers.map((obj) => getName(guild, obj)).join(', '); const trimmedList = excludedList.substr(0, 512); if (excludedList != trimmedList) { excludedList = `${trimmedList.substr(0, 509)}...`; } else { excludedList = trimmedList; } } finalMessage.addFields([{ name: strings.get( 'listPlayerExcludedNum', locale, game.excludedUsers.length), value: excludedList, }]); } return finalMessage; } /** * Get the username of a user id if available, or their id if they couldn't be * found. * * @private * @param {Discord~Guild} guild The guild to look for the user in. * @param {string} user The id of the user to find the name of. * @returns {string} The user's name or id if name was unable to be found. */ function getName(guild, user) { let name = ''; if (typeof user === 'object' && user.username) { name = user.username; } else if (guild.members.resolve(user)) { name = guild.members.resolve(user).user.username; } else { name = user; } return name; } /** * Change an option to a value that the user specifies. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function toggleOpt(msg, id) { msg.text = msg.text.trim(); const option = msg.text.split(' ')[0]; const value = msg.text.split(' ')[1]; const output = self.setOption(id, option, value, msg.text); if (!output) { if (!hg.getGame(id).options) { reply( msg, 'optionNoOptions', 'optionCreateGame,', `${msg.prefix}${self.postPrefix}`); } else { showOpts(msg, hg.getGame(id).options); } } else { self.common.reply(msg, output); } } /** * Change an option to a value for the given guild. * * @public * @param {string} id The guild id to change the option in. * @param {?string} option The option key to change. * @param {?string|boolean|number} value The value to change the option to. * @param {string} [text=''] The original message sent without the command * prefix in the case we are changing the value of an object and require all * user inputted data. * @returns {string} A message saying what happened, or null if we should show * the user the list of options instead. */ this.setOption = function(id, option, value, text = '') { const locale = self.bot.getLocale && self.bot.getLocale(id); if (!hg.getGame(id) || !hg.getGame(id).currentGame) { return strings.get('gameNotCreated', locale); } if (typeof option === 'undefined' || option.length == 0) { return null; } else if ( option[0] === '_' || typeof defaultOptions[option] === 'undefined') { const searchedOption = defaultOptSearcher.search(option); if (typeof defaultOptions[searchedOption] === 'undefined') { return strings.get( 'optionInvalidChoice', locale, option, `${self.bot.getPrefix(id)}${self.postPrefix}`); } option = searchedOption; } return changeObjectValue( hg.getGame(id).options, defaultOptions, option, value, text.split(' '), id); }; /** * Recurse through an object to change a certain child value based off a given * array of words. * * @fires HG#toggleOption * @private * @param {HungryGames~GuildGame.options} obj The object with the values to * change. * @param {HungryGames~defaultOptions} defaultObj The default template object * to base changes off of. * @param {string} option The first value to check. * @param {number|boolean|string} value The value to change to, or the next * option key to check if we have not found an end to a branch yet. * @param {Array.<string|boolean|number>} values All keys leading to the final * value, as well as the final value. * @param {string} id The id of the guild this was triggered for. * @param {{min: number, max: number}} [range] Allowable range for values that * are numbers. * @param {string[]} [keys=[]] List of previous option keys. * @returns {string} Message saying what happened. Can be an error message. */ function changeObjectValue( obj, defaultObj, option, value, values, id, range, keys) { const locale = self.bot.getLocale && self.bot.getLocale(id); if (!keys || !Array.isArray(keys)) keys = []; keys.push(option); let type = typeof defaultObj[option]; if (type !== 'undefined' && typeof defaultObj[option].value !== 'undefined') { type = typeof defaultObj[option].value; range = range || defaultObj[option].range; } if (hg.getGame(id).currentGame && hg.getGame(id).currentGame.inProgress) { if (option == 'teamSize' || option == 'includeBots') { return strings.get('optionTeamDuringGame', locale); } } if (type === 'number') { value = Number(value); if (typeof value !== 'number' || isNaN(value)) { return strings.get('optionInvalidNumber', locale, option, obj[option]); } else { if (range) { if (value < range.min) value = range.min; if (value > range.max) value = range.max; } const old = obj[option]; obj[option] = value; self._fire('toggleOption', id, ...keys, value); if (option == 'teamSize' && value != 0) { return strings.get( 'optionChangeTeam', locale, option, obj[option], old, `${self.bot.getPrefix()}${self.postPrefix}`); } else { return strings.get('optionChange', locale, option, obj[option], old); } } } else if (type === 'boolean') { if (typeof value === 'string') value = value.toLowerCase(); if (value === 'true' || value === 'false') value = value === 'true'; if (typeof value !== 'boolean') { return strings.get('optionInvalidBoolean', locale, option, obj[option]); } else { if (option == 'excludeNewUsers' && self.client.guilds.resolve(id).memberCount >= HungryGames.largeServerCount) { obj[option] = true; return strings.get('optionServerToLargeExclude', locale); } const old = obj[option]; obj[option] = value; if (option == 'includeBots') { createGame(null, id, true); } self._fire('toggleOption', id, ...keys, value); return strings.get('optionChange', locale, option, obj[option], old); } } else if (type === 'string') { value = (value || '').toLowerCase(); if (defaultObj[option].values.lastIndexOf(value) < 0) { return strings.get( 'optionInvalidString', locale, option, JSON.stringify(defaultObj[option].values), obj[option]); } else { const old = obj[option]; obj[option] = value; self._fire('toggleOption', id, ...keys, value); return strings.get('optionChange', locale, option, obj[option], old); } } else if (type === 'object') { if (typeof defaultObj[option].value[value] === 'undefined') { return strings.get( 'optionInvalidObject', locale, value, JSON.stringify(obj[option], null, 1)); } else { return changeObjectValue( obj[option], defaultObj[option].value || defaultObj[option], values[1], values[2], values.slice(3), id, range, keys); } } else { return strings.get( 'optionInvalidType', locale, option, type, JSON.stringify(defaultObj), value, JSON.stringify(values)); } } /** * Format the options for the games and show them to the user. * * @private * @param {Discord~Message} msg The message that lead to this being called. * @param {object} options The options to format. */ function showOpts(msg, options) { const entries = Object.entries(options); const bodyList = entries.map((obj) => { const key = obj[0]; const val = obj[1]; return key + ': ' + JSON.stringify(val) + ' (default: ' + JSON.stringify(defaultOptions[key].value) + ')\n' + '/* ' + defaultOptions[key].comment + ' */'; }); let totalLength = 0; const bodyFields = [[]]; let fieldIndex = 0; for (let i = 0; i < bodyList.length; i++) { if (bodyList[i].length + totalLength > 1500) { fieldIndex++; totalLength = 0; bodyFields.push([]); } totalLength += bodyList[i].length; bodyFields[fieldIndex].push(bodyList[i]); } let page = 0; if (msg.optId) page = msg.optId; if (page < 0) page = 0; if (page >= bodyFields.length) page = bodyFields.length - 1; const embed = new self.Discord.EmbedBuilder(); embed.setTitle(strings.get('optionListTitle', msg.locale)); embed.setFooter({ text: strings.get('pageNumbers', msg.locale, page + 1, bodyFields.length), }); embed.setDescription('```js\n' + bodyFields[page].join('\n\n') + '```'); embed.addFields([{ name: strings.get('optionListSimpleExampleTitle', msg.locale), value: strings.get( 'optionListSimpleExampleBody', msg.locale, `${msg.prefix}${self.postPrefix}`), }]); embed.addFields([{ name: strings.get('optionListObjectExampleTitle', msg.locale), value: strings.get( 'optionListObjectExampleBody', msg.locale, `${msg.prefix}${self.postPrefix}`), }]); if (optionMessages[msg.id]) { msg.edit({embeds: [embed]}).then(() => { optChangeListener(msg, options, page); }); } else { msg.channel.send({embeds: [embed]}).then((msg_) => { msg_.origAuth = msg.author.id; msg_.prefix = self.bot.getPrefix(msg.guild); optChangeListener(msg_, options, page); }); } } /** * The callback for when the user chooses to change page of the options. * * @private * @param {Discord~Message} msg_ The message we sent showing the options. * @param {object} options The options to show in the message. * @param {number} index The page index to show. */ function optChangeListener(msg_, options, index) { msg_.optId = index; optionMessages[msg_.id] = msg_; msg_.react(emoji.arrowLeft).then(() => msg_.react(emoji.arrowRight)); newReact(maxReactAwaitTime); const filter = (reaction, user) => { if (user.id != self.client.user.id) { reaction.users.remove(user).catch(() => {}); } return (reaction.emoji.name == emoji.arrowRight || reaction.emoji.name == emoji.arrowLeft) && user.id != self.client.user.id; }; msg_.awaitReactions({filter, max: 1, time: maxReactAwaitTime}) .then((reactions) => { if (reactions.size == 0) { msg_.reactions.removeAll().catch(() => {}); delete optionMessages[msg_.id]; return; } const name = reactions.first().emoji.name; if (name == emoji.arrowRight) { msg_.optId++; } else if (name == emoji.arrowLeft) { msg_.optId--; } showOpts(msg_, options); }); } // Team Management // /** * Entry for all team commands. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {boolean} [silent=false] Should we disable replying to the given * message? * @returns {?string} Error message or null if no error. */ function editTeam(msg, id, silent) { const split = msg.text.trim().split(' '); if (!hg.getGame(id) || !hg.getGame(id).currentGame) { const message = strings.get('teamEditNoGame', msg.locale); if (!silent) { msg.channel .send({content: self.common.mention(msg) + ' `' + message + '`'}) .catch(console.error); } return message; } if (hg.getGame(id).currentGame.inProgress) { switch (split[0]) { case 'rename': break; default: { const message = strings.get('teamEditInProgress', msg.locale); if (!silent) { msg.channel.send( {content: self.common.mention(msg) + ' `' + message + '`'}); } return message; } } } if (hg.getGame(id).options.teamSize == 0) { const message = strings.get('teamEditNoTeams', msg.locale); if (!silent) { self.common.reply( msg, message, `${msg.prefix}${self.postPrefix}opt teamSize 2`); } return message; } switch (split[0]) { case 'swap': swapTeamUsers(msg, id); break; case 'move': moveTeamUser(msg, id); break; case 'rename': renameTeam(msg, id, silent); break; case 'reset': if (!silent) reply(msg, 'resetTeams'); hg.getGame(id).currentGame.teams = []; hg.getGame(id).formTeams(id); break; case 'randomize': case 'shuffle': randomizeTeams(msg, id, silent); break; default: listPlayers(msg, id); break; } } /** * @description Allows editing teams. Entry for all team actions. * * @public * @param {string} uId The id of the user is running the action. * @param {string} gId The id of the guild to run this in. * @param {string} cmd The command to run on the teams. * @param {string} one The id of the user to swap, or the new name of the team * if we're renaming a team. * @param {string} two The id of the user to swap, or the team id if we're * moving a player to a team. * @returns {?string} Error message or null if no error. */ this.editTeam = function(uId, gId, cmd, one, two) { const locale = self.bot.getLocale && self.bot.getLocale(gId); if (!hg.getGame(gId) || !hg.getGame(gId).currentGame) { return strings.get('gameNotCreated', locale); } if (hg.getGame(gId).currentGame.inProgress) { switch (cmd) { case 'swap': case 'move': return; } } switch (cmd) { case 'swap': { let p1 = -1; const team1 = hg.getGame(gId).currentGame.teams.find((t) => { return t.players.find((p, i) => { if (p == one) { p1 = i; return true; } return false; }); }); let p2 = -1; const team2 = hg.getGame(gId).currentGame.teams.find((t) => { return t.players.find((p, i) => { if (p == two) { p2 = i; return true; } return false; }); }); if (!team1 || !team2) break; const tmp = team1.players.splice(p1, 1)[0]; team1.players.push(team2.players.splice(p2, 1)[0]); team2.players.push(tmp); break; } case 'move': { let pId = -1; let tId = -1; const teamS = hg.getGame(gId).currentGame.teams.find((t, i) => { if (t.players.find((p, j) => { if (p == one) { pId = j; return true; } return false; })) { tId = i; return true; } return false; }); let teamD = hg.getGame(gId).currentGame.teams.find((t) => { return t.id == two; }); if (!teamS) break; if (!teamD) { const current = hg.getGame(gId).currentGame; const newTeam = new HungryGames.Team( current.teams.length, strings.get('teamDefaultName', locale, current.teams.length + 1), []); teamD = current.teams[current.teams.push(newTeam) - 1]; } teamD.players.push(teamS.players.splice(pId, 1)[0]); if (teamS.players.length === 0) { hg.getGame(gId).currentGame.teams.splice(tId, 1); } break; } default: return editTeam( makeMessage( uId, gId, null, cmd + ' ' + (one || '') + ' ' + (two || '')), gId, true); } }; /** * Swap two users from one team to the other. * * @private * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function swapTeamUsers(msg, id, game) { const mentions = msg.mentions.users.concat(msg.softMentions.users); if (mentions.size != 2) { reply(msg, 'teamSwapNeedTwo'); return; } if (!game) game = hg.getGame(id); if (!game || !game.currentGame) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } swapTeamUsers(msg, id, game); }); return; } const user1 = mentions.first().id; const user2 = mentions.first(2)[1].id; let teamId1 = 0; let playerId1 = 0; let teamId2 = 0; let playerId2 = 0; teamId1 = game.currentGame.teams.findIndex((team) => { const index = team.players.findIndex((player) => player == user1); if (index > -1) playerId1 = index; return index > -1; }); teamId2 = game.currentGame.teams.findIndex((team) => { const index = team.players.findIndex((player) => player == user2); if (index > -1) playerId2 = index; return index > -1; }); if (teamId1 < 0 || teamId2 < 0) { reply(msg, 'teamSwapNoTeam'); return; } const intVal = game.currentGame.teams[teamId1].players[playerId1]; game.currentGame.teams[teamId1].players[playerId1] = game.currentGame.teams[teamId2].players[playerId2]; game.currentGame.teams[teamId2].players[playerId2] = intVal; reply(msg, 'teamSwapSuccess'); } /** * Move a single user to another team. * * @private * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function moveTeamUser(msg, id, game) { const mentions = msg.mentions.users.concat(msg.softMentions.users); if (mentions.size < 1) { reply(msg, 'teamMoveNoMention'); return; } if (!game) game = hg.getGame(id); if (!game || !game.currentGame) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } moveTeamUser(msg, id, game); }); return; } let user1 = mentions.first().id; let teamId1 = 0; let playerId1 = 0; let user2 = 0; if (mentions.size >= 2) { user2 = mentions.first(2)[1].id; if (msg.text.indexOf(user2) < msg.text.indexOf(user1)) { const intVal = user1; user1 = user2; user2 = intVal; } } let teamId2 = 0; teamId1 = game.currentGame.teams.findIndex((team) => { const index = team.players.findIndex((player) => player == user1); if (index > -1) playerId1 = index; return index > -1; }); if (user2 > 0) { teamId2 = game.currentGame.teams.findIndex( (team) => team.players.find((player) => player == user2)); } else { const split = msg.text.trim().split(' '); teamId2 = split.find((el) => el.match(/^\d+$/)) - 1; teamId2 = game.currentGame.teams.findIndex((team) => team.id == teamId2); } if (teamId1 < 0 || teamId2 < 0 || isNaN(teamId2)) { let extra = null; if (user2 > 0 && teamId2 < 0) { extra = strings.get( 'teamMoveNoTeam', msg.locale, self.client.users.resolve(user2).username); } else if (user1 > 0 && teamId1 < 0) { extra = strings.get( 'teamMoveNoTeam', msg.locale, self.client.users.resolve(user1).username); } reply(msg, 'teamMoveBadFormat', extra && 'fillOne', extra); return; } if (teamId2 >= game.currentGame.teams.length) { const newTeam = new HungryGames.Team( game.currentGame.teams.length, strings.get( 'teamDefaultName', msg.locale, game.currentGame.teams.length + 1), []); game.currentGame.teams.push(newTeam); teamId2 = game.currentGame.teams.length - 1; } const user1Final = self.client.users.resolve(user1); reply( msg, 'success', 'teamMoveSuccess', user1Final && user1Final.username || user1, game.currentGame.teams[teamId1].name, game.currentGame.teams[teamId2].name); game.currentGame.teams[teamId2].players.push( game.currentGame.teams[teamId1].players.splice(playerId1, 1)[0]); if (game.currentGame.teams[teamId1].players.length == 0) { game.currentGame.teams.splice(teamId1, 1); } } /** * Rename a team. * * @private * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {boolean} [silent=false] Disable replying to message. */ function renameTeam(msg, id, silent) { const split = msg.text.trim().split(' ').slice(1); let message = split.slice(1).join(' '); const search = Number(split[0]); const mentions = msg.mentions.users.concat(msg.softMentions.users); if (isNaN(search) && (mentions.size == 0)) { if (!silent) reply(msg, 'teamRenameNoId'); return; } let teamId = search - 1; if (!hg.getGame(id) || !hg.getGame(id).currentGame) { if (!silent) reply(msg, 'gameNotCreated'); return; } if (isNaN(search)) { teamId = hg.getGame(id).currentGame.teams.findIndex( (team) => team.players.find((player) => player == mentions.first().id)); } else { teamId = hg.getGame(id).currentGame.teams.findIndex( (team) => team.id == teamId); } if (teamId < 0) { if (!silent) { reply( msg, 'teamRenameInvalidIdTitle', 'teamRenameInvalidIdBody', hg.getGame(id).currentGame.teams.length); } return; } message = message.slice(0, 101); if (!silent) { reply( msg, 'success', 'teamRenameSuccess', hg.getGame(id).currentGame.teams[teamId].name, message); } hg.getGame(id).currentGame.teams[teamId].name = message; } /** * Swap random users between teams. * * @private * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {boolean} [silent=false] If true, this will not attempt to send * messages to the channel where the msg was sent.. */ function randomizeTeams(msg, id, silent) { if (!hg.getGame(id) || !hg.getGame(id).currentGame) { if (!silent) reply(msg, 'gameNotCreated'); return; } if (hg.getGame(id).currentGame.inProgress) { if (!silent) reply(msg, 'teamEditInProgress'); return; } const current = hg.getGame(id).currentGame; if (current.teams.length == 0) { if (!silent) reply(msg, 'teamRandomizeNoTeams'); return; } for (let i = 0; i < current.includedUsers.length; i++) { const teamId1 = Math.floor(Math.random() * current.teams.length); const playerId1 = Math.floor(Math.random() * current.teams[teamId1].players.length); const teamId2 = Math.floor(Math.random() * current.teams.length); const playerId2 = Math.floor(Math.random() * current.teams[teamId2].players.length); const intVal = current.teams[teamId1].players[playerId1]; current.teams[teamId1].players[playerId1] = current.teams[teamId2].players[playerId2]; current.teams[teamId2].players[playerId2] = intVal; } if (!silent) reply(msg, 'teamRandomizeSuccess'); } /** * Enable or disable an event without deleting it completely. * * @fires HG#eventToggled * * @public * @param {number|string} id The guild id that the event shall be toggled in. * @param {string} type The type of event. 'bloodbath', 'player', 'weapon', or * 'arena'. * @param {string} evtId The event ID of which to toggle in the category. * @param {boolean} [value] Set enabled to a value instead of toggling. * @returns {?string} Error message or null if no error. */ this.toggleEvent = function(id, type, evtId, value) { if (!['bloodbath', 'arena', 'player', 'weapon'].includes(type)) { return 'Invalid Type'; } if (!hg.getGame(id)) return 'Invalid ID or no game'; const allDisabled = hg.getGame(id).disabledEventIds[type]; const dIndex = allDisabled.findIndex((el) => el === evtId); if (typeof value !== 'boolean') value = dIndex > -1; if ((dIndex > -1) !== value) { return `Already ${value?'Enabled':'Disabled'}`; } else if (value) { allDisabled.splice(dIndex, 1); self._fire('eventToggled', id, type, evtId, value); return null; } allDisabled.push(evtId); self._fire('eventToggled', id, type, evtId, value); return null; }; /** * Tell users to use the website to manage custom events. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. */ function useWebsiteForCustom(msg) { reply( msg, 'legacyEventCommandResponseTitle', 'legacyEventNoticeBody', `${msg.prefix}${self.postPrefix}`); } /** * Update all legacy custom events to the newer ID based system. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function commandClaimLegacyEvents(msg, id) { const game = hg.getGame(id); if (!game || !game.legacyEvents) { reply(msg, 'legacyNoLegacyTitle', 'legacyNoLegacyBody'); return; } self.claimLegacy(game, msg.author.id, (err, res, stringified) => { if (err) { reply(msg, 'legacyNoClaimed', err); } else { reply(msg, 'legacyClaimed', res); const perms = msg.channel.permissionsFor(self.client.user); if (perms.has(self.Discord.PermissionsBitField.Flags.SendMessages) && perms.has(self.Discord.PermissionsBitField.Flags.AttachFiles)) { msg.channel.send({ content: strings.get('legacyBackup', msg.locale), files: [new self.Discord.AttachmentBuilder( Buffer.from(stringified), {name: 'HGLegacyEventBackup.json'})], }); } } }); } /** * @description Claim legacy events to the given owner's account. * @public * @param {HungryGames~GuildGame} game The game storing legacy events. * @param {string} owner The ID of ther user to attach the events to. * @param {Function} cb Callback once completed. First argument is optional * error string, second is otherwise success information string, third will * always be the stringified legacy events. */ this.claimLegacy = function(game, owner, cb) { const custom = game.legacyEvents; if (!custom) { cb('No legacy events to claim.'); return; } const dir = self.common.guildSaveDir + game.id; const stringified = JSON.stringify(custom, null, 2); let total = 0; let done = 0; let deleted = false; let errored = false; const checkDone = function() { done++; if (done < total) return; const additional = (deleted ? 'legacyWeaponReset' : 'legacyWeaponNoReset') + (errored ? 'legacyFailuresUnknown' : 'legacyNoFailures'); cb(null, additional, stringified); const filename = `${dir}/HGLegacyEventBackup.json`; self.common.mkAndWrite(filename, dir, stringified, (err) => { if (err) { self.error('Failed to save HG Legacy event backup file.'); console.error(err); return; } if (!errored) delete game.legacyEvents; }); }; const iterate = function(type, type2) { return function(evt, i) { total++; if ((evt.victim && evt.victim.weapon) || (evt.attacker && evt.attacker.weapon)) { deleted = true; delete evt.victim.weapon; delete evt.attacker.weapon; } if (evt.outcomes) { evt.outcomes.forEach((el) => { el.creator = owner; el.type = 'normal'; if ((el.victim && el.victim.weapon) || (el.attacker && el.attacker.weapon)) { deleted = true; delete el.victim.weapon; delete el.attacker.weapon; } }); } evt.type = type2; evt.creator = owner; hg.createEvent(evt, (err, out) => { if (err) { self.error( 'Failed to update legacy event: ' + type + ' ' + type2 + ' ' + i + ' ' + game.id); console.error(err); errored = true; checkDone(); return; } game.customEventStore.fetch(out.id, type, (err) => { if (err) { self.error( 'Failed to fetch claimed event: ' + out.id + ' ' + type + ' ' + type2 + ' ' + i + ' ' + game.id); console.error(err); errored = true; checkDone(); return; } checkDone(); }); }); }; }; custom.bloodbath.forEach(iterate('bloodbath', 'normal')); custom.player.forEach(iterate('player', 'normal')); custom.arena.forEach(iterate('arena', 'arena')); const wepIterate = iterate('weapon', 'weapon'); Object.entries(custom.weapon).forEach((el, i) => { const evt = Object.assign({}, el[1]); evt.name = el[0]; wepIterate(evt, i); }); if (total === 0) cb('legacyNoneFound'); }; /** * List all currently created NPCs. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function listNPCs(msg, id) { let specific = msg.softMentions.users.find((el) => el.id.startsWith('NPC')); /** * Function to pass into Array.map to format NPCs into strings for this * list. * * @private * @param {NPC} obj NPC object to format as a string. * @returns {string} Name as a string. */ function mapFunc(obj) { let shortName; shortName = obj.name.substring(0, 16); if (shortName != obj.name) { shortName = `${shortName.substring(0, 13)}...`; } return `\`${shortName}\``; } if (!hg.getGame(id)) { reply(msg, 'gameNotCreated'); return; } const iNPCs = hg.getGame(id).includedNPCs || []; const eNPCs = hg.getGame(id).excludedNPCs || []; if (specific) { specific = iNPCs.concat(eNPCs).find((el) => el.id == specific.id); const embed = new self.Discord.EmbedBuilder(); embed.setTitle('NPC Info'); embed.setDescription(specific.name); embed.setFooter({text: specific.id}); embed.setThumbnail(specific.avatarURL); msg.channel.send({content: self.common.mention(msg), embeds: [embed]}) .catch((err) => { self.error('Failed to send NPC info message: ' + msg.channel.id); console.error(err); }); } else if (msg.text && !['show', 'list'].includes(msg.text.trim())) { reply( msg, 'npcUnknownTitle', 'npcUnknownBody', msg.text, `${msg.prefix}${self.postPrefix}`); } else { const finalMessage = new self.Discord.EmbedBuilder(); finalMessage.setTitle(strings.get('npcListTitle', msg.locale)); finalMessage.setColor(defaultColor); let iList = []; let eList = []; if (iNPCs.length > 0) iList = iNPCs.map(mapFunc).sort(); if (eNPCs.length > 0) eList = eNPCs.map(mapFunc).sort(); const numINPCs = iList.length; const numENPCs = eList.length; if (iList.length >= 5) { const numCols = self.calcColNum(iList.length > 10 ? 3 : 2, iList); const quarterLength = Math.ceil(iList.length / numCols); for (let i = 0; i < numCols - 1; i++) { const thisMessage = iList.splice(0, quarterLength).join('\n').substring(0, 1024); finalMessage.addFields([{ name: strings.get( 'listPlayerIncludedNum', msg.locale, `${i * quarterLength + 1}-${(i + 1) * quarterLength}`), value: thisMessage, }]); } finalMessage.addFields([{ name: strings.get( 'listPlayerIncludedNum', msg.locale, `${(numCols - 1) * quarterLength + 1}-${numINPCs}`), value: iList.join('\n'), }]); } else { finalMessage.addFields([{ name: strings.get('listPlayerIncludedNum', msg.locale, numINPCs), value: iList.join('\n') || 'None', }]); } if (eList.length >= 5) { const numCols = self.calcColNum(eList.length > 10 ? 3 : 2, eList); const quarterLength = Math.ceil(eList.length / numCols); for (let i = 0; i < numCols - 1; i++) { const thisMessage = eList.splice(0, quarterLength).join('\n').substring(0, 1024); finalMessage.addFields([{ name: strings.get( 'listPlayerExcludedNum', msg.locale, `${i * quarterLength + 1}-${(i + 1) * quarterLength}`), value: thisMessage, }]); } finalMessage.addFields([{ name: strings.get( 'listPlayerExcludedNum', msg.locale, `${(numCols - 1) * quarterLength + 1}-${numENPCs}`), value: eList.join('\n'), }]); } else { finalMessage.addFields([{ name: strings.get('listPlayerExcludedNum', msg.locale, numENPCs), value: eList.join('\n') || 'None', }]); } msg.channel .send({content: self.common.mention(msg), embeds: [finalMessage]}) .catch((err) => { reply(msg, 'messageRejected', 'npcTooMany'); self.error( 'Failed to send list of NPCs message: ' + msg.channel.id); console.error(err); }); } } /** * Create a new NPC. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function createNPC(msg, id) { let username; fetchAvatar(); /** * @description Fetch the avatar the user has requested. Prioritizes * attachments, then URLs, otherwise returns. * * @private */ function fetchAvatar() { let url; if (msg.attachments.size == 1) { const a = msg.attachments.first(); url = a.proxyURL || a.url; } else if (msg.attachments.size == 0) { url = msg.text.match(urlRegex); if (url) url = url[0]; } if (typeof url !== 'string' || url.length == 0) { reply(msg, 'npcNoImage'); } else { username = formatUsername(msg.text, url); if (username.length < 2) { reply(msg, 'npcNoUsername', 'fillOne', username); return; } let request = https.request; if (url.startsWith('http://')) request = http.request; const opt = {headers: {'User-Agent': self.common.ua}}; let req; try { req = request(url, opt, onIncoming); } catch (err) { self.warn('Failed to request npc avatar: ' + url); // console.error(err); self.common.reply(msg, err.message); return; } req.on('error', (err) => { self.error('Failed to fetch image: ' + url); console.error(err); }); req.end(); msg.channel.sendTyping(); } } /** * Fired on the 'response' http revent. * * @private * * @param {http.IncomingMessage} incoming Response object. */ function onIncoming(incoming) { if (incoming.statusCode != 200 ) { incoming.destroy(); if (incoming.statusCode == 415) { reply(msg, 'npcBadURLMime', 'statusCode', incoming.statusCode); } else { reply(msg, 'npcBadURL', 'statusCode', incoming.statusCode); } return; } const cl = incoming.headers['content-length']; const type = incoming.headers['content-type']; const supported = ['image/jpeg', 'image/png', 'image/bmp', 'image/tiff', 'image/gif']; self.debug('MIME: ' + type + ', CL: ' + cl); if (!supported.includes(type)) { incoming.destroy(); reply(msg, 'invalidFileType', 'fillOne', type || 'unknown filetype'); return; } else if (!cl) { incoming.destroy(); self.common.reply( msg, strings.get( 'invalidFileSize', msg.locale, self.maxBytes / 1000 / 1000), strings.get('unknownFileSize', msg.locale)); return; } else if (cl > self.maxBytes) { incoming.destroy(); self.common.reply( msg, strings.get( 'invalidFileSize', msg.locale, self.maxBytes / 1000 / 1000), Math.round(cl / 1000 / 100) / 10 + 'MB'); return; } const data = []; let reqBytes = 0; incoming.on('data', (chunk) => { data.push(chunk); reqBytes += chunk.length; if (reqBytes > self.maxBytes) { incoming.destroy(); self.common.reply( msg, strings.get( 'invalidFileSize', msg.locale, self.maxBytes / 1000 / 1000), `>${Math.round(reqBytes / 1000 / 100) / 10}MB`); } }); incoming.on('end', () => onGetAvatar(Buffer.concat(data))); } /** * Once image has been received, convert to Jimp. * * @private * * @param {Buffer} buffer The image as a Buffer. */ function onGetAvatar(buffer) { Jimp.read(buffer) .then((image) => { if (!image) throw new Error('Invalid Data'); let size = 128; if (hg.getGame(id) && hg.getGame(id).options && hg.getGame(id).options.eventAvatarSizes) { size = hg.getGame(id).options.eventAvatarSizes.avatar; } const copy = new Jimp(image); copy.resize(size, size); copy.getBuffer(Jimp.MIME_PNG, (err, out) => { if (err) throw err; sendConfirmation(image, out); }); }) .catch((err) => { reply(msg, 'invalidImage', 'fillOne', err.message); self.error('Failed to convert buffer to image.'); console.error(err); }); } /** * Show a confirmation message to the user with the username and avatar. * * @private * * @param {Jimp} image The Jimp image for internal use. * @param {Buffer} buffer The Buffer the image buffer for showing. */ function sendConfirmation(image, buffer) { const embed = new self.Discord.EmbedBuilder(); embed.setTitle(strings.get('npcConfirmTitle', msg.locale)); embed.setAuthor({name: username}); embed.setDescription( strings.get( 'npcConfirmDescription', msg.locale, emoji.whiteCheckMark, emoji.x)); msg.channel .send({ embeds: [embed], files: [new self.Discord.AttachmentBuilder( buffer, {name: `${username}.png`})], }) .then((msg_) => { msg_.react(emoji.whiteCheckMark).then(() => msg_.react(emoji.x)); newReact(maxReactAwaitTime); const filter = (reaction, user) => user.id == msg.author.id && (reaction.emoji.name == emoji.whiteCheckMark || reaction.emoji.name == emoji.x); msg_.awaitReactions({filter, max: 1, time: maxReactAwaitTime}) .then((reactions) => { embed.setDescription(null); if (reactions.size == 0) { msg_.reactions.removeAll().catch(() => {}); embed.setFooter( {text: strings.get('timedOut', msg.locale)}); msg_.edit({embeds: [embed]}); } else if ( reactions.first().emoji.name == emoji.whiteCheckMark) { msg_.reactions.removeAll().catch(() => {}); embed.setFooter( {text: strings.get('confirmed', msg.locale)}); msg_.edit({embeds: [embed]}); onConfirm(image); } else { msg_.reactions.removeAll().catch(() => {}); embed.setFooter( {text: strings.get('cancelled', msg.locale)}); msg_.edit({embeds: [embed]}); } }); }) .catch((err) => { self.error('Failed to send NPC confirmation: ' + msg.channel.id); console.error(err); }); } /** * Once user has confirmed adding NPC. * * @private * * @param {Jimp} image The image to save to file for this NPC. */ function onConfirm(image) { const id = NPC.createID(); const p = NPC.saveAvatar(image, id); if (!p) { reply(msg, 'npcCreateWentWrongTitle', 'npcCreateWentWrongBody'); return; } else { p.then((url) => { const error = self.createNPC(msg.guild.id, username, url, id); if (error) { reply(msg, 'npcCreateFailed', error); } else { self.common.reply( msg, strings.get('npcCreated', msg.locale, username), id); } }).catch((err) => { self.error('Failed to create NPC.'); console.log(err); }); } } } /** * @description Create an npc in a guild. * * @public * @param {string|number} gId The guild id to add the npc to. * @param {string} username The name of the npc. * @param {string} avatar The url path to the avatar. Must be valid url to * this server. (ex: * https://www.spikeybot.com/avatars/NPCBBBADEF031F83638/avatar1.png). * @param {string} id The npc id of this npc. Must match the id in the avatar * url. * @returns {?string} Error message key or null if no error. */ this.createNPC = function(gId, username, avatar, id) { if (typeof avatar !== 'string') return 'invalidAvatarURL'; const splitURL = avatar.match(/\/avatars\/(NPC[A-F0-9]+)\/\w+\.png/); if (!splitURL) return 'invalidAvatarURL'; const urlID = splitURL[1]; if (!NPC.checkID(id)) { return 'invalidNPCId'; } else if (urlID !== id) { return 'avatarIdMismatch'; } const npc = new NPC(formatUsername(username), avatar, id); const pushNPC = function(game) { if (!game.includedNPCs) hg.getGame(gId).includedNPCs = []; game.includedNPCs.push(npc); if (!game.currentGame || !game.currentGame.inProgress) { self.createGame(gId); } self._fire('memberAdd', gId, npc.id); }; hg.fetchGame(gId, (game) => { if (!game) { self.createGame(gId, pushNPC); } else { pushNPC(game); } }); return null; }; /** * Clean up username, and format to rules similar to Discord. * * @private * * @param {string} u The username. * @param {string|RegExp} [remove] A substring or RegExp to remove. * @returns {string} Formatted username. */ function formatUsername(u, remove) { if (!remove) remove = /a^/; // Match nothing by default. return u.replace(remove, '') .replace(/^\s+|\s+$|@|#|:|```/g, '') .replace(/\s{2,}/g, ' ') .substring(0, 32); } /** * @inheritdoc * @public */ this.formatUsername = formatUsername; /** * Rename an NPC. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function renameNPC(msg, id) { const mentions = msg.softMentions.users.filter((el) => el.id.startsWith('NPC')); if (mentions.size == 0) { if (msg.text && msg.text.length > 1) { reply( msg, 'npcUnknownTitle', 'npcUnknownBody', msg.text, `${msg.prefix}${self.postPrefix}`); } else { reply(msg, 'npcRenameSpecify'); } return; } const toRename = mentions.first(); const oldName = toRename.username; const trimmed = (msg.text.indexOf(toRename.id) > -1 ? msg.text.replace(toRename.id, '') : msg.text.replace(oldName, '')).trim(); const newName = formatUsername(trimmed); const success = self.renameNPC(id, toRename.id, newName); if (success) { reply(msg, 'npcRenameFailed', success); } else { reply( msg, 'npcRenameSuccessTitle', 'npcRenameSuccessBody', oldName, newName); } } /** * @description Rename an npc in a guild. * * @public * @param {string|number} gId The guild ID context. * @param {string} npcId The ID of the NPC to rename. * @param {string} username The new name of the npc. * @returns {?string} Error message or null if no error. */ this.renameNPC = function(gId, npcId, username) { const npc = hg.getGame(gId).includedNPCs.find((el) => el.id == npcId) || hg.getGame(gId).excludedNPCs.find((el) => el.id == npcId); if (!npc) return 'npcUnknownTitle'; username = formatUsername(username); if (username.length < 2) return 'npcNoUsername'; npc.username = username; npc.name = username; return null; }; /** * Delete an NPC. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function removeNPC(msg, id) { const mentions = msg.softMentions.users.filter((el) => el.id.startsWith('NPC')); if (mentions.size == 0) { if (msg.text && msg.text.length > 1) { reply( msg, 'npcUnknownTitle', 'npcUnknownBody', msg.text, `${msg.prefix}${self.postPrefix}`); } else { reply(msg, 'npcDeleteSpecify'); } return; } const toDelete = mentions.first(); const success = self.removeNPC(id, toDelete.id, msg.locale); if (typeof success === 'string') { reply(msg, 'npcDeleteFailed', success); } else { msg.channel.send({embeds: [success]}) .catch(() => reply(msg, 'npcDeleteSuccess', 'fillOne', toDelete.id)); } } /** * Delete an NPC from a guild. * * @public * * @param {string} gId Guild id of which to remove npc. * @param {string} npc ID of npc to delete. * @param {string} [locale] Language locale to create EmbedBuilder with. * @returns {string|Discord~EmbedBuilder} String key if error, EmbedBuilder to * send if success. */ this.removeNPC = function(gId, npc, locale) { const incIndex = hg.getGame(gId).includedNPCs.findIndex((el) => el.id == npc); const excIndex = hg.getGame(gId).excludedNPCs.findIndex((el) => el.id == npc); let toDelete; if (incIndex > -1) { toDelete = hg.getGame(gId).includedNPCs.splice(incIndex, 1)[0]; self._fire('memberRemove', gId, npc); } else if (excIndex > -1) { toDelete = hg.getGame(gId).excludedNPCs.splice(excIndex, 1)[0]; self._fire('memberRemove', gId, npc); } else { self.error('NPC HALF DISCOVERED :O ' + npc); return 'npcHalfDiscovered'; } if (!hg.getGame(gId).currentGame.inProgress) self.createGame(gId); const embed = new self.Discord.EmbedBuilder(); embed.setTitle(strings.get('npcDeleteSuccess', locale)); embed.setDescription(toDelete.name); embed.setFooter({text: toDelete.id}); embed.setThumbnail(toDelete.avatarURL); return embed; }; /** * @description Include an NPC in the game. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function includeNPC(msg, id) { includeUser(msg, id); } /** * @description Exclude an NPC from the game. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. */ function excludeNPC(msg, id) { excludeUser(msg, id); } /** * @description Send help message to DM and reply to server. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. */ function help(msg) { const message = typeof self.helpMessage === 'string' ? {content: self.helpMessage} : {embeds: [self.helpMessage]}; msg.author.send(message) .then(() => { if (msg.guild != null) { reply(msg, 'helpMessageSuccess', 'fillOne', ':wink:') .catch(() => {}); } }) .catch(() => reply(msg, 'helpMessageFailed').catch(() => {})); } /** * @description Responds with stats about a player in the games. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id Guild ID this command was called from. */ function commandStats(msg, id) { const game = hg.getGame(id); if (!game) { reply(msg, 'noStats', 'statsAfterGame'); return; } const numTotal = game.statGroup ? 3 : 2; const user = msg.softMentions.users.first() || msg.author; let numDone = 0; const embed = new self.Discord.EmbedBuilder(); embed.setTitle( strings.get('statsUserTitle', msg.locale, user.tag || user.username)); embed.setColor([255, 0, 255]); const checkDone = function() { numDone++; if (numDone === numTotal) { msg.channel.send({content: self.common.mention(msg), embeds: [embed]}); } }; const groupDone = function(err, group) { if (!group) { checkDone(); return; } group.fetchUser(user.id, (err, data) => { if (err) { self.error( 'Failed to fetch HG User stats: ' + id + '@' + user.id + '/' + group.id); console.error(err); } else { const list = data.keys.map( (el) => `${self.common.camelToSpaces(el)}: ${data.get(el)}`); if (group.id === 'global') { embed.addFields([{ name: strings.get('statsLifetime', msg.locale), value: list.join('\n'), }]); checkDone(); return; } else if (group.id === 'previous') { embed.addFields([{ name: strings.get('statsPrevious', msg.locale), value: list.join('\n'), }]); checkDone(); return; } group.fetchMetadata((err, meta) => { if (err) { self.error( 'Failed to fetch metadata for group ' + id + '/' + group.id); console.error(err); } if (meta && meta.name) { embed.addFields([{name: meta.name, value: list.join('\n')}]); } else { embed.addFields([{name: group.id, value: list.join('\n')}]); } checkDone(); }); } }); }; if (game.statGroup) game._stats.fetchGroup(game.statGroup, groupDone); game._stats.fetchGroup('global', groupDone); game._stats.fetchGroup('previous', groupDone); } /** * @description Responds with list of all stat group names and IDs. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id Guild ID this command was called from. */ function commandGroups(msg, id) { const game = hg.getGame(id); if (!game) { reply(msg, 'noGroupData', 'groupCreateFirst'); return; } let total = 0; let done = 0; const list = []; const checkDone = function() { done++; if (done >= total) { reply( msg, 'groupTitle', 'fillOne', list.join('\n') || strings.get('groupNotFound', msg.locale)); } }; const groupDone = function(err, group) { if (err) { checkDone(); return; } group.fetchMetadata((err, meta) => { const flag = game.statGroup === group.id ? '*' : ' '; if (err) { list.push(`${group.id}${flag}`); checkDone(); self.error( 'Failed to fetch metadata for stat group: ' + id + '/' + group.id); } else { list.push(`${group.id}${flag}: ${meta.name}`); checkDone(); } }); }; const groupID = msg.text.match(/\b([a-fA-F0-9]{4})\b/); if (groupID) { total = 1; game._stats.fetchGroup(groupID[1].toUpperCase(), groupDone); } else { game._stats.fetchGroupList((err, list) => { if (err) { if (err.code === 'ENOENT') { list = []; } else { self.error('Failed to get list of stat groups.'); console.error(err); reply(msg, 'groupListFailedTitle', 'groupListFailedBody'); return; } } list = list.filter((el) => !['global', 'previous'].includes(el)); total = list.length; list.forEach((el) => game._stats.fetchGroup(el, groupDone)); if (list.length === 0) reply(msg, 'groupNone'); }); } } /** * @description Creates a new stat group. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id Guild ID this command was called from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function commandNewGroup(msg, id, game) { if (!game) game = hg.getGame(id); if (!game) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } commandNewGroup(msg, id, game); }); return; } const name = msg.text.trim().slice(0, 24); game._stats.createGroup({name: name}, (group) => { let res = group.id; if (name) res = `${res}: ${name}`; game.statGroup = group.id; reply(msg, 'groupCreatedAndSelected', 'fillOne', res); }); } /** * @description Selects an existing stat group. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id Guild ID this command was called from. */ function commandSelectGroup(msg, id) { const game = hg.getGame(id); if (!game) { reply(msg, 'noGroupData', 'groupCreateFirst'); return; } let groupID = msg.text.match(/\b([a-fA-F0-9]{4})\b/); if (!groupID) { reply(msg, 'groupDisabled'); game.statGroup = null; return; } groupID = groupID[1].toUpperCase(); game._stats.fetchGroup(groupID, (err, group) => { if (err) { reply( msg, 'groupNotFound', 'groupListCommand', `${msg.prefix}${self.postPrefix}`); return; } game.statGroup = groupID; let name; if (group.name) { name = `${group.name} (${group.id})`; } else { name = `${group.id}`; } reply(msg, 'groupSelected', 'fillOne', name); }); } /** * @description Renames an existing stat group. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id Guild ID this command was called from. */ function commandRenameGroup(msg, id) { const game = hg.getGame(id); if (!game) { reply(msg, 'noGroupData', 'groupCreateFirst'); return; } const regex = /\b([a-fA-F0-9]{4})\b/; let groupID = msg.text.match(regex); if (!groupID) { reply( msg, 'groupSpecifyId', 'groupListCommand', `${msg.prefix}${self.postPrefix}`); return; } groupID = groupID[1].toUpperCase(); const newName = msg.text.replace(regex, '').trim().slice(0, 24); game._stats.fetchGroup(groupID, (err, group) => { if (err) { reply( msg, 'groupNotFound', 'groupListCommand', `${msg.prefix}${self.postPrefix}`); return; } group.setMetaName(newName); let name; if (newName) { name = `${group.id}: (${newName})`; } else { name = `${group.id}`; } reply(msg, 'groupRenamed', 'fillOne', name); }); } /** * @description Deletes an existing stat group. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id Guild ID this command was called from. */ function commandDeleteGroup(msg, id) { const game = hg.getGame(id); if (!game) { reply(msg, 'noGroupData', 'groupCreateFirst'); return; } let groupID = msg.text.match(/\b([a-fA-F0-9]{4})\b/); if (!groupID) { reply( msg, 'groupSpecifyId', 'groupListCommand', `${msg.prefix}${self.postPrefix}`); return; } groupID = groupID[1].toUpperCase(); game._stats.fetchGroup(groupID, (err, group) => { if (err) { reply( msg, 'groupNotFound', 'groupListCommand', `${msg.prefix}${self.postPrefix}`); return; } let additional = null; if (game.statGroup === group.id) { additional = strings.get('groupDisabled', msg.locale); game.statGroup = null; } group.reset(); self.common.reply( msg, strings.get('groupDeleted', msg.locale, group.id), additional); }); } /** * @description Ranks players by stat. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id Guild ID this command was called from. */ function commandLeaderboard(msg, id) { const game = hg.getGame(id); if (!game) { reply(msg, 'statsNoData', 'completeGameFirst'); return; } const regex = /\b([a-fA-F0-9]{4})\b/; let groupID = msg.text.match(regex); if (!groupID) { const prevList = ['last', 'previous', 'recent']; if (prevList.find((el) => msg.text.indexOf(el) > -1)) { groupID = 'previous'; } else { groupID = 'global'; } } else { groupID = groupID[1].toUpperCase(); } const text = msg.text.toLocaleLowerCase(); const col = HungryGames.Stats.keys.find( (el) => text.indexOf(el.toLocaleLowerCase()) > -1 || text.indexOf( self.common.camelToSpaces(el).toLocaleLowerCase()) > -1) || 'wins'; game._stats.fetchGroup(groupID, (err, group) => { if (err) { if (groupID === 'previous' || groupID === 'global') { reply(msg, 'statsNoData', 'completeGameFirst'); } else { reply( msg, 'groupNotFound', 'groupListCommand', `${msg.prefix}${self.postPrefix}`); } return; } const opts = {}; opts.sort = col; const num = msg.text.replace(regex, '').match(/\d+/); if (num && num[0] * 1 > 0) opts.limit = num[0] * 1; group.fetchUsers(opts, (err, rows) => { if (err) { self.error('Failed to fetch leaderboard: ' + id + '/' + groupID); console.error(err); reply(msg, 'lbFailed'); return; } if (!rows || rows.length === 0) { reply(msg, 'groupNoData', 'completeGameFirst'); return; } const list = rows.map((el, i) => { let name; if (el.id.startsWith('NPC')) { const npc = game.includedNPCs.find((n) => n.id === el.id) || game.excludedNPCs.find((n) => n.id === el.id); name = npc ? npc.name : el.id; } else { const iU = game.currentGame.includedUsers.find((u) => u.id === el.id); if (iU) { name = (game.options.useNicknames && iU.nickname) || iU.name; } else { const m = msg.guild.members.resolve(el.id); name = m ? (game.options.useNicknames && m.nickname) || m.user.username : el.id; } } return `${i+1}) ${name}: ${el.get(col)}`; }); const embed = new self.Discord.EmbedBuilder(); embed.setTitle(strings.get('rankedBy', msg.locale, col)); const groupName = groupID === 'global' ? strings.get('lifetime', msg.locale) : groupID; embed.setDescription(groupName); embed.setColor([255, 0, 255]); const numCols = self.calcColNum(1, list); const numTotal = list.length; const quarterLength = Math.ceil(numTotal / numCols); for (let i = 0; i < numCols - 1; i++) { const thisMessage = list.splice(0, quarterLength).join('\n').slice(0, 1024); embed.addFields([{ name: `${i * quarterLength + 1}-${(i + 1) * quarterLength}`, value: thisMessage, }]); } embed.addFields([{ name: `${(numCols - 1) * quarterLength + 1}-${numTotal}`, value: list.join('\n').slice(0, 1024) || '.', }]); msg.channel.send({content: self.common.mention(msg), embeds: [embed]}) .catch((err) => { self.error( 'Failed to send leaderboard in channel: ' + msg.channel.id); console.error(err); reply(msg, 'lbSendFailed', 'fillOne', err.code); }); }); }); } /** * @description Replies to the user with stats about all the currently loaded * games in this shard. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. */ function commandNums(msg) { if (self.client.shard) { self.client.shard.broadcastEval('this.getHGStats(true)') .then((res) => { const embed = new self.Discord.EmbedBuilder(); embed.setTitle(strings.get('numsTitle', msg.locale)); res.forEach( (el, i) => embed.addFields([{name: `#${i}`, value: el}])); msg.channel.send({embeds: [embed]}); }) .catch((err) => { reply(msg, 'numsFailure'); self.error(err); }); } else { self.common.reply(msg, getStatsString(false, msg.locale)); } } /** * @description Get this shard's stats and format it into a human readable * string. * @private * @param {boolean} [short=false] Provide a short version. * @param {?string} [locale=null] Language to use for strings. * @returns {string} The formatted string. */ function getStatsString(short = false, locale = null) { const listenerBlockDuration = listenersEndTime - Date.now(); let message; if (short) { message = `${self.getNumSimulating()}/${Object.keys(hg._games).length}`; } else { message = strings.get( 'numsNumSimulating', locale, self.getNumSimulating(), Object.keys(hg._games).length); } if (!short && listenerBlockDuration > 0) { message += '\n' + strings.get( 'numsLastListener', locale, Math.round(listenerBlockDuration / 100 / 60) / 10); } const web = !self.common.isSlave && self.bot.getSubmodule(webSM); if (web) { const numClients = web.getNumClients(); if (short) { message += ` (${numClients} web)`; } else { message += '\n' + numClients + ' web client' + (numClients == 1 ? '' : 's') + ' connected.'; } } return message; } /** * @description Replies to the user with an image saying "rigged". That is * all. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. */ function commandRig(msg) { const embed = new self.Discord.EmbedBuilder(); embed.setThumbnail('https://discordemoji.com/assets/emoji/rigged.png'); embed.setColor([187, 26, 52]); msg.channel.send({content: self.common.mention(msg), embeds: [embed]}); } /** * @description Fetch an array of user IDs that are in the current game and * have been referenced in any way due to the given message from the user. * @private * @param {Discord~Message} msg The message that lead to this being called. * @param {HungryGames~GuildGame} game The game this is for. * @returns {string[]} Array of user IDs that are in the current game that * were mentioned. */ function parseGamePlayers(msg, game) { const mentions = parseMentions(msg); let firstWord = msg.text.trim().split(' ')[0]; if (firstWord) firstWord = firstWord.toLowerCase(); const specialWords = strings.getRaw('groupWords', msg.locale); // const specialWords = { // everyone: ['everyone', '@everyone', 'all'], // online: ['online', 'here', '@here'], // offline: ['offline'], // idle: ['idle', 'away', 'snooze', 'snoozed'], // dnd: ['dnd', 'busy'], // bots: ['bot', 'bots'], // npcs: ['npc', 'npcs', 'ai', 'ais'], // }; let players = []; const incU = game.currentGame.includedUsers; if (specialWords.everyone.includes(firstWord)) { players = game.currentGame.includedUsers.map((el) => el.id); } else if (specialWords.online.includes(firstWord)) { players = incU.filter((el) => { const member = msg.guild.members.resolve(el.id); if (!member) return false; return member.user.presence.status === 'online'; }).map((el) => el.id); } else if (specialWords.offline.includes(firstWord)) { players = incU.filter((el) => { const member = msg.guild.members.resolve(el.id); if (!member) return false; return member.user.presence.status === 'offline'; }).map((el) => el.id); } else if (specialWords.idle.includes(firstWord)) { players = incU.filter((el) => { const member = msg.guild.members.resolve(el.id); if (!member) return false; return member.user.presence.status === 'idle'; }).map((el) => el.id); } else if (specialWords.dnd.includes(firstWord)) { players = incU.filter((el) => { const member = msg.guild.members.resolve(el.id); if (!member) return false; return member.user.presence.status === 'dnd'; }).map((el) => el.id); } else if (specialWords.npcs.includes(firstWord)) { players = incU.filter((el) => el.isNPC).map((el) => el.id); } else if (specialWords.bots.includes(firstWord)) { players = incU.filter((el) => { const member = msg.guild.members.resolve(el.id); if (!member) return false; return member.user.bot; }).map((el) => el.id); } return players.concat( mentions .filter((u) => { if (!u || players.includes(u.id)) return false; return game.currentGame.includedUsers.find((p) => p.id == u.id); }) .map((el) => el.id)); } /** * @description Allows the game creator to kill a player in the game. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function commandKill(msg, id, game) { if (!game) game = hg.getGame(id); if (!game) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } commandKill(msg, id, game); }); return; } const players = parseGamePlayers(msg, game); if (!players || players.length == 0) { reply(msg, 'effectPlayerKillNoPlayer'); return; } HungryGames.GuildGame.forcePlayerState( hg.getGame(id), players, 'dead', hg.messages, hg._defaultEventStore.getArray('player'), msg.locale, (res) => reply(msg, res)); } /** * @description Allows the game creator to heal or revive a player in the * game. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function commandHeal(msg, id, game) { if (!game) game = hg.getGame(id); if (!game) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } commandHeal(msg, id, game); }); return; } const players = parseGamePlayers(msg, game); if (!players || players.length == 0) { reply(msg, 'effectPlayerHealNoPlayer'); return; } HungryGames.GuildGame.forcePlayerState( hg.getGame(id), players, 'thriving', hg.messages, hg._defaultEventStore.getArray('player'), msg.locale, (res) => reply(msg, res)); } /** * @description Allows the game creator to wound a player in the game. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function commandWound(msg, id, game) { if (!game) game = hg.getGame(id); if (!game) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } commandWound(msg, id, game); }); return; } const players = parseGamePlayers(msg, game); if (!players || players.length == 0) { reply(msg, 'effectPlayerWoundNoPlayer'); return; } HungryGames.GuildGame.forcePlayerState( hg.getGame(id), players, 'wounded', hg.messages, hg._defaultEventStore.getArray('player'), msg.locale, (res) => reply(msg, res)); } /** * @description Rename the guild's game to the given custom name. * * @public * @param {string|number} id The guild id of which to change the game's name. * @param {string} name The custom name to change to. Must be 100 characters * or fewer. * @returns {boolean} True if successful, false if failed. Failure is probably * due to a game not existing or the name being longer than 100 characters. */ this.renameGame = function(id, name) { if (!hg.getGame(id) || !hg.getGame(id).currentGame) return false; if (name.length > 100) return false; hg.getGame(id).currentGame.customName = name; hg.getGame(id).currentGame.name = name || (self.client.guilds.resolve(id).name + '\'s Hungry Games'); return true; }; /** * @description Rename a guild's game to a custom name. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function commandRename(msg, id, game) { if (!game) game = hg.getGame(id); if (!game || !game.currentGame) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } commandRename(msg, id, game); }); return; } if (self.renameGame(id, msg.text.trim())) { reply( msg, 'renameGameSuccess', 'fillOne', msg.text.trim() || self.client.guilds.resolve(id).name); } else { reply(msg, 'renameGameFail'); } } /** * @description Give a certain amount of a weapon to a player. * * @see {@link HG~commandModifyWeapon} * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @listens Command#hg give */ function commandGiveWeapon(msg, id) { commandModifyWeapon(msg, id, false); } /** * @description Take a certain amount of a weapon from a player. * * @see {@link HG~commandModifyWeapon} * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @listens Command#hg take */ function commandTakeWeapon(msg, id) { commandModifyWeapon(msg, id, true); } /** * @description Actually does the parsing for {@link HG~commandGiveWeapon} and * {@link HG~commandTakeWeapon}. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {boolean} [flip=false] Should the parsed number value be multiplied * by -1. */ function commandModifyWeapon(msg, id, flip = false) { const game = hg.getGame(id); if (!game || !game.currentGame || !game.currentGame.includedUsers || !game.currentGame.inProgress) { reply(msg, 'needStartGameTitle'); return; } let users = msg.softMentions.users; if (users.size === 0) { reply(msg, 'modifyPlayerNoPlayer'); return; } users = users.filter( (el) => game.currentGame.includedUsers.find((u) => u.id == el.id)); if (users.size === 0) { reply(msg, 'modifyPlayerNoPlayerInGame'); return; } let num = 0; let final = null; const list = []; const text = msg.text.toLocaleLowerCase().replace(/\d{17,19}/g, ''); const weapons = game.customEventStore.getArray('weapon'); defaultEvents.getArray('weapon').forEach((w) => { list.push(w.name); if (text.indexOf(w.name.toLocaleLowerCase()) > -1) { num++; final = w.id; } }); weapons.forEach((w) => { if (!list.includes(w.name) && text.indexOf(w.name.toLocaleLowerCase()) > -1) { num++; final = w.id; } }); if (num == 0) { reply(msg, 'modifyPlayerNoWeapon'); return; } else if (num > 1) { reply(msg, 'modifyPlayerMultipleWeapon'); return; } let count = text.match(/\b(-?\d+)\b/); if (!count) { count = flip ? -1 : 1; } else { count = (flip ? -1 : 1) * count[1]; } game.modifyPlayerWeapon( users.first().id, final, hg, count, false, (res, ...args) => reply(msg, 'modifyPlayerTitle', res, ...args)); } /** * @description Start or stop allowing users to enter in to a game by clicking * on a reaction to a message. * * @private * @type {HungryGames~hgCommandHandler} * @param {Discord~Message} msg The message that lead to this being called. * @param {string} id The id of the guild this was triggered from. * @param {HungryGames~GuildGame} [game] The game object to modify. */ function commandReactJoin(msg, id, game) { if (!game) game = hg.getGame(id); if (!game || !game.currentGame) { createGame(msg, id, false, (game) => { if (!game) { reply(msg, 'createFailedUnknown'); return; } commandReactJoin(msg, id, game); }); return; } if (game.reactMessage) { self.endReactJoinMessage(id, (err, info) => { if (err) { self.error(err); reply(msg, 'reactFailedTitle', err); } else { reply(msg, 'reactSuccessTitle', info); } }); } else { self.createReactJoinMessage(msg.channel); } } /** * @description Send a message with a reaction for users to click on. Records * message id and channel id in game data. * * @public * @param {Discord~TextChannel|string} channel The channel in the guild to * send the message, or the ID of the channel. */ this.createReactJoinMessage = function(channel) { if (typeof channel === 'string') { channel = self.client.channels.resolve(channel); } if (!channel || !channel.guild || !channel.guild.id || !hg.getGame(channel.guild.id)) { return; } const locale = self.bot.getLocale && self.bot.getLocale(channel.guild.id); const embed = new self.Discord.EmbedBuilder(); embed.setColor(defaultColor); embed.setTitle(strings.get('reactToJoinTitle', locale)); embed.setDescription(strings.get( 'reactToJoinBody', locale, hg.getGame(channel.guild.id).currentGame.name)); channel.send({embeds: [embed]}).then((msg) => { hg.getGame(channel.guild.id).reactMessage = { id: msg.id, channel: msg.channel.id, }; msg.react(emoji.crossedSwords).catch(() => {}); }); }; /** * @description End the reaction join and update the included users to only * include those who reacted to the message. * * @public * @param {string} id The guild id of which to end the react join. * @param {Function} [cb] Callback once this is complete. First parameter is a * string key if error, null otherwise, the second is a string with info if * success or null otherwise. */ this.endReactJoinMessage = function(id, cb) { if (typeof cb !== 'function') cb = function() {}; if (!hg.getGame(id) || !hg.getGame(id).reactMessage || !hg.getGame(id).reactMessage.id || !hg.getGame(id).reactMessage.channel) { hg.getGame(id).reactMessage = null; cb('reactFailedNotStarted'); return; } let numTotal = 0; let numDone = 0; let msg; const channel = self.client.guilds.resolve(id).channels.resolve( hg.getGame(id).reactMessage.channel); if (!channel) { hg.getGame(id).reactMessage = null; cb('reactFailedNoChannel'); return; } channel.messages.fetch(hg.getGame(id).reactMessage.id) .then((m) => { msg = m; if (!msg.reactions || msg.reactions.size == 0) { usersFetched(); } else { msg.reactions.cache.forEach((el) => { numTotal++; el.users.fetch().then(usersFetched).catch((err) => { self.error(`Failed to fetch user reactions: ${msg.channel.id}`); console.error(err); usersFetched(); }); }); } }) .catch((err) => { console.error(err); hg.getGame(id).reactMessage = null; cb('reactFailedNoMessage'); }); let list = new self.Discord.Collection(); /** * @description Adds fetched user reactions to buffer until all are * received, then ends react join. * * @private * @param {Discord.Collection.<User>|Discord.User[]} reactionUsers Array of * users for a single reaction. */ function usersFetched(reactionUsers) { numDone++; if (reactionUsers && (reactionUsers.length > 0 || reactionUsers.size > 0)) { list = list.concat( reactionUsers.filter((el) => el.id != self.client.user.id)); } if (numTotal > numDone) return; self.excludeUsers('everyone', id, () => { hg.getGame(id).reactMessage = null; const locale = self.bot.getLocale && self.bot.getLocale(id); const ended = strings.get('ended', locale); msg.edit({content: `\`${ended}\``}).catch(() => {}); if (list.size == 0) { cb(null, 'reactNoUsers'); } else { self.includeUsers(list, id, (res) => cb(null, res)); } }); } }; /** * @description Sort the includedUsers and teams for the given game. * @public * @param {HungryGames~GuildGame} game The game to sort. */ this.sortTeams = function(game) { game.currentGame.teams.sort((a, b) => b.id - a.id); game.currentGame.includedUsers.sort((a, b) => { const aTeam = game.currentGame.teams.find((team) => { return team.players.findIndex((player) => { return player == a.id; }) > -1; }); const bTeam = game.currentGame.teams.find((team) => { return team.players.findIndex((player) => { return player == b.id; }) > -1; }); if (!aTeam || !bTeam || aTeam.id == bTeam.id) { const aN = ((game.options.useNicknames && a.nickname) || a.name) .toLocaleLowerCase(); const bN = ((game.options.useNicknames && b.nickname) || b.name) .toLocaleLowerCase(); if (aN < bN) return -1; if (aN > bN) return 1; return 0; } else { return aTeam.id - bTeam.id; } }); }; /** * @description Returns the number of games that are currently being shown to * users. * * @public * @returns {number} Number of games simulating. */ this.getNumSimulating = function() { const loadedEntries = Object.entries(hg._games); const inProgress = loadedEntries.filter((game) => { return game[1].currentGame && game[1].currentGame.inProgress && game[1].currentGame.day.state > 1 && !game[1].currentGame.isPaused; }); return inProgress.length; }; /** * @description Get a random word that means "nothing". * * @private * @returns {string} A word meaning "nothing". */ function nothing() { return strings.get('nothing'); } /** * Calculates the number of columns for the given player list. Assumes maximum * character count of 1024 per section. The number of columns also becomes * limited to 5, because we will run into the embed total character limit of * 6000 if we add any more. * [Discord API Docs]( * https://discordapp.com/developers/docs/resources/channel#embed-limits). * * @public * * @param {number} numCols Minimum number of columns. * @param {string[]} statusList List of text to check. * @returns {number} Number of columns the data shall be formatted as. */ this.calcColNum = function(numCols, statusList) { if (numCols === statusList.length) return numCols; // if (numCols > 25) return 25; if (numCols > 5) return 5; const quarterLength = Math.ceil(statusList.length / numCols); for (let i = 0; i < numCols; i++) { if (statusList.slice(quarterLength * i, quarterLength * (i + 1)) .join('\n') .length > 1024) { return self.calcColNum(numCols + 1, statusList); } } return numCols; }; /** * Update {@link HungryGames~listenersEndTime} because a new listener was * registered with the given duration. * * @private * @param {number} duration The length of time the listener will be active. */ function newReact(duration) { if (Date.now() + duration > listenersEndTime) { listenersEndTime = Date.now() + duration; } } /** * @description Parse all mentioned users from all softMentions and Discord * mentions, including roles. * @private * @param {Discord~Message} msg The message containing mention data. * @returns {Discord~Collection<Discord~User>} Collection of all users * mentioned. */ function parseMentions(msg) { const mentionedRoleUsers = new self.Discord.Collection( ...msg.mentions.roles.map((r) => r.members.map((m) => [m.id, m.user]))); const softRoleUsers = new self.Discord.Collection( ...msg.softMentions.roles.map( (r) => r.members.map((m) => [m.id, m.user]))); return msg.mentions.users.concat(msg.softMentions.users) .concat(mentionedRoleUsers.concat(softRoleUsers)); } /** * Attempt to fetch an image from a URL. Checks if the file has been cached to * the filesystem first. * * @public * * @param {string|Jimp|Buffer} url The url to fetch the image from, or * anything Jimp supports. * @returns {Promise} Promise from JIMP with image data. */ this.readImage = function(url) { let fromCache = false; let filename; let dir; if (typeof url === 'string') { const splitURL = url.match(/\/(avatars)\/(\w+)\/([^?&/]+)/); if (splitURL && splitURL[1] == 'avatars') { dir = `${self.common.userSaveDir}avatars/${splitURL[2]}/`; filename = `${dir}${splitURL[3]}`; } if (filename && fs.existsSync(filename)) { fromCache = true; return toJimp(filename); } } return toJimp(url).then((image) => { if (fromCache) return image; if (filename && image) { image.getBuffer(Jimp.MIME_PNG, (err, buffer) => { if (err) { self.error( `Failed to convert image into buffer: ${filename || url}`); console.error(err); return; } self.common.mkAndWrite(filename, dir, buffer, (err) => { if (err) { self.error(`Failed to cache avatar: ${filename}`); console.error(err); } }, self.common.encryptAvatars); }); } return image; }); /** * Send the request to Jimp to handle. * * @private * * @param {string} path Or path that Jimp can handle. * @returns {Promise} Promise from Jimp with image data. */ function toJimp(path) { if (typeof path === 'string' && path.startsWith('http')) { path = { url: path, headers: {'User-Agent': self.common.ua}, }; } return Jimp.read(path).catch((err) => { if (fromCache) { self.error(`Failed to read from cache: ${path}`); console.error(err); fromCache = false; return toJimp(url); } }); } }; // Util // /** * Save all game data to file. * * @override * @param {string} [opt='sync'] Can be 'async', otherwise defaults to * synchronous. * @param {boolean} [wait=false] If requested before subModule is * initialized, keep trying until it is initialized. */ this.save = function(opt, wait) { if (!self.initialized) { if (wait) { setTimeout(function() { self.save(opt, wait); }); } return; } hg.save(opt); }; /** * @description Register an event listener. Handlers are called in order they * are registered. Earlier events can modify event data. * * @public * @param {string} evt The name of the event to listen for. * @param {Function} handler The function to call when the event is fired. */ this.on = function(evt, handler) { if (!eventHandlers[evt]) eventHandlers[evt] = []; eventHandlers[evt].push(handler); }; /** * Remove an event listener;. * * @public * @param {string} evt The name of the event that was being listened for. * @param {Function} handler The currently registered handler. */ this.removeListener = function(evt, handler) { if (!eventHandlers[evt]) return; const i = eventHandlers[evt].findIndex((el) => el === handler); if (i > -1) eventHandlers[evt].splice(i, 1); }; /** * Fire an event on all listeners. * * @private * @param {string} evt The event to fire. * @param {...*} args Arguments for the event. */ this._fire = function(evt, ...args) { if (!eventHandlers[evt]) return; eventHandlers[evt].forEach((el) => { try { el(self, ...args); } catch (err) { self.error('Caught error during event firing: ' + evt); console.error(err); } }); }; } module.exports = new HG();