Source: src/hungryGames.js

// Copyright 2018-2020 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.MessageEmbed();
    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.addField(
          obj.title, 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```',
          true);
    });
    tmpHelp.addField(
        'Web Interface', '[Hungry Games Online Control](' + self.common.webURL +
            'hg/)```Manage the Games without using commands!\n' +
            self.common.webURL + 'hg/```',
        true);
    self.helpMessage = tmpHelp;
  }

  /** @inheritdoc */
  this.initialize = function() {
    const cmdOpts = {
      validOnlyInGuild: true,
      defaultDisabled: true,
      permissions: self.Discord.Permissions.FLAGS.MANAGE_ROLES |
          self.Discord.Permissions.FLAGS.MANAGE_GUILD |
          self.Discord.Permissions.FLAGS.MANAGE_CHANNELS,
    };
    const cmdOptsAnywhere = {
      validOnlyInGuild: false,
      defaultDisabled: true,
      permissions: self.Discord.Permissions.FLAGS.MANAGE_ROLES |
          self.Discord.Permissions.FLAGS.MANAGE_GUILD |
          self.Discord.Permissions.FLAGS.MANAGE_CHANNELS,
    };
    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('`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);
        });
      });
      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 && !m.deleted;
      });
      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.MessageAttachment();
      file.setFile(Buffer.from(JSON.stringify(game.serializable, null, 2)));
      file.setName(`HG-${finalId}.json`);
      msg.channel.send(`HG Data for guild ${finalId}`, 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.Permissions.FLAGS.ATTACH_FILES)) {
      reply(msg, 'startNoAttachFiles');
      if (!myPerms) {
        self.error(
            'Failed to fetch perms for myself. ' + (msg.guild.me && true));
      }
      return;
    } else if (!myPerms.has(self.Discord.Permissions.FLAGS.EMBED_LINKS)) {
      reply(msg, 'startNoEmbedLinks');
      return;
    } else if (!myPerms.has(self.Discord.Permissions.FLAGS.SEND_MESSAGES)) {
      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() {
      self.client.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(strings.get(
            'gameStartNextDayInfo', msg.locale,
            `${msg.prefix}${self.postPrefix}`));
      }

      let mentions = self.common.mention(msg);
      if (g.options.mentionEveryoneAtStart) {
        mentions += '@everyone';
      }

      msg.channel.send(mentions, 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(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(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(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(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.Permissions.FLAGS.ATTACH_FILES) &&
         !myPerms.has(self.Discord.Permissions.FLAGS.ADMINISTRATOR))) {
      reply(msg, 'nextDayPermImagesTitle', 'nextDayPermImagesBody');
      if (!myPerms) {
        self.error(
            'Failed to fetch perms for myself. ' + (msg.guild.me && true));
      }
      return;
    } else if (
      !myPerms.has(self.Discord.Permissions.FLAGS.EMBED_LINKS) &&
        !myPerms.has(self.Discord.Permissions.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.array();
    }
    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.array();
    }
    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({format: '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(self.common.mention(msg), 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~MessageEmbed} that lists all
   * included and excluded players in the game.
   * @private
   * @param {HungryGames~GuildGame} game The game to format.
   * @param {Discord~MessageEmbed} [finalMessage] Optional existing
   * embed to modify instead of creating a new one.
   * @param {?string} [locale=null] Language locale to format titles.
   * @returns {Discord~MessageEmbed} The created message embed.
   */
  function makePlayerListEmbed(game, finalMessage, locale = null) {
    if (!finalMessage) {
      finalMessage = new self.Discord.MessageEmbed();
      finalMessage.setTitle(strings.get('listPlayerTitle', locale));
      finalMessage.setColor(defaultColor);
    }
    if (!game || !game.currentGame || !game.currentGame.includedUsers) {
      finalMessage.addField(
          strings.get('listPlayerNoPlayersTitle', locale),
          strings.get('listPlayerNoPlayersBody', locale));
      return finalMessage;
    }
    const numUsers = game.currentGame.includedUsers.length;
    if (numUsers > 200) {
      finalMessage.addField(
          strings.get('listPlayerIncludedNum', locale, numUsers),
          strings.get(
              'listPlayerExcludedNum', locale, game.excludedUsers.length),
          true);
      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.addField(
            el.name || el.id,
            statusList.splice(0, el.players.length).join('\n').slice(0, 1023),
            true);
      });
    } 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.addField(
              strings.get(
                  'listPlayerIncludedNum', locale,
                  `${i * quarterLength + 1}-${(i + 1) * quarterLength}`),
              thisMessage, true);
        }
        finalMessage.addField(
            strings.get(
                'listPlayerIncludedNum', locale,
                `${(numCols - 1) * quarterLength + 1}-${numUsers}`),
            statusList.join('\n'), true);
      } else {
        finalMessage.addField(
            strings.get('listPlayerIncludedNum', locale, numUsers),
            statusList.join('\n') || 'Nobody', false);
      }
    }
    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.addField(
          strings.get(
              'listPlayerExcludedNum', locale, game.excludedUsers.length),
          excludedList, false);
    }
    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.MessageEmbed();
    embed.setTitle(strings.get('optionListTitle', msg.locale));
    embed.setFooter(
        strings.get('pageNumbers', msg.locale, page + 1, bodyFields.length));
    embed.setDescription('```js\n' + bodyFields[page].join('\n\n') + '```');
    embed.addField(
        strings.get('optionListSimpleExampleTitle', msg.locale),
        strings.get(
            'optionListSimpleExampleBody', msg.locale,
            `${msg.prefix}${self.postPrefix}`),
        true);
    embed.addField(
        strings.get('optionListObjectExampleTitle', msg.locale),
        strings.get(
            'optionListObjectExampleBody', msg.locale,
            `${msg.prefix}${self.postPrefix}`),
        true);

    if (optionMessages[msg.id]) {
      msg.edit(embed).then(() => {
        optChangeListener(msg, options, page);
      });
    } else {
      msg.channel.send(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);
    msg_.awaitReactions((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;
    }, {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(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(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.Permissions.FLAGS.SEND_MESSAGES) &&
            perms.has(self.Discord.Permissions.FLAGS.ATTACH_FILES)) {
          msg.channel.send(
              strings.get('legacyBackup', msg.locale),
              new self.Discord.MessageAttachment(
                  Buffer.from(stringified), '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.MessageEmbed();
      embed.setTitle('NPC Info');
      embed.setDescription(specific.name);
      embed.setFooter(specific.id);
      embed.setThumbnail(specific.avatarURL);
      msg.channel.send(self.common.mention(msg), 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.MessageEmbed();
      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.addField(
              strings.get(
                  'listPlayerIncludedNum', msg.locale,
                  `${i * quarterLength + 1}-${(i + 1) * quarterLength}`),
              thisMessage, true);
        }
        finalMessage.addField(
            strings.get(
                'listPlayerIncludedNum', msg.locale,
                `${(numCols - 1) * quarterLength + 1}-${numINPCs}`),
            iList.join('\n'), true);
      } else {
        finalMessage.addField(
            strings.get('listPlayerIncludedNum', msg.locale, numINPCs),
            iList.join('\n') || 'None', false);
      }
      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.addField(
              strings.get(
                  'listPlayerExcludedNum', msg.locale,
                  `${i * quarterLength + 1}-${(i + 1) * quarterLength}`),
              thisMessage, true);
        }
        finalMessage.addField(
            strings.get(
                'listPlayerExcludedNum', msg.locale,
                `${(numCols - 1) * quarterLength + 1}-${numENPCs}`),
            eList.join('\n'), true);
      } else {
        finalMessage.addField(
            strings.get('listPlayerExcludedNum', msg.locale, numENPCs),
            eList.join('\n') || 'None', false);
      }
      msg.channel.send(self.common.mention(msg), 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.startTyping();
      }
    }
    /**
     * 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);
        }
        msg.channel.stopTyping();
        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');
        msg.channel.stopTyping();
        return;
      } else if (!cl) {
        incoming.destroy();
        self.common.reply(
            msg,
            strings.get(
                'invalidFileSize', msg.locale, self.maxBytes / 1000 / 1000),
            strings.get('unknownFileSize', msg.locale));
        msg.channel.stopTyping();
        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');
        msg.channel.stopTyping();
        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`);
          msg.channel.stopTyping();
        }
      });
      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);
            msg.channel.stopTyping();
            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) {
      msg.channel.stopTyping();
      const embed = new self.Discord.MessageEmbed();
      embed.setTitle(strings.get('npcConfirmTitle', msg.locale));
      embed.setAuthor(username);
      embed.setDescription(
          strings.get(
              'npcConfirmDescription', msg.locale, emoji.whiteCheckMark,
              emoji.x));
      embed.attachFiles(
          [new self.Discord.MessageAttachment(buffer, `${username}.png`)]);
      msg.channel.send(embed)
          .then((msg_) => {
            msg_.react(emoji.whiteCheckMark).then(() => msg_.react(emoji.x));
            newReact(maxReactAwaitTime);
            msg_.awaitReactions((reaction, user) => {
              return user.id == msg.author.id &&
                      (reaction.emoji.name == emoji.whiteCheckMark ||
                       reaction.emoji.name == emoji.x);
            }, {max: 1, time: maxReactAwaitTime}).then((reactions) => {
              embed.setDescription('');
              if (reactions.size == 0) {
                msg_.reactions.removeAll().catch(() => {});
                embed.setFooter(strings.get('timedOut', msg.locale));
                msg_.edit(embed);
              } else if (
                reactions.first().emoji.name == emoji.whiteCheckMark) {
                msg_.reactions.removeAll().catch(() => {});
                embed.setFooter(strings.get('confirmed', msg.locale));
                msg_.edit(embed);
                onConfirm(image);
              } else {
                msg_.reactions.removeAll().catch(() => {});
                embed.setFooter(strings.get('cancelled', msg.locale));
                msg_.edit(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(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 MessageEmbed with.
   * @returns {string|Discord~MessageEmbed} String key if error, MessageEmbed 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.MessageEmbed();
    embed.setTitle(strings.get('npcDeleteSuccess', locale));
    embed.setDescription(toDelete.name);
    embed.setFooter(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) {
    msg.author.send(self.helpMessage)
        .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.MessageEmbed();
    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(self.common.mention(msg), 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.addField(
                strings.get('statsLifetime', msg.locale), list.join('\n'),
                true);
            checkDone();
            return;
          } else if (group.id === 'previous') {
            embed.addField(
                strings.get('statsPrevious', msg.locale), list.join('\n'),
                true);
            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.addField(meta.name, list.join('\n'), true);
            } else {
              embed.addField(group.id, list.join('\n'), true);
            }
            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.MessageEmbed();
        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.addField(
              `${i * quarterLength + 1}-${(i + 1) * quarterLength}`,
              thisMessage, true);
        }
        embed.addField(
            `${(numCols - 1) * quarterLength + 1}-${numTotal}`,
            list.join('\n').slice(0, 1024) || '.', true);

        msg.channel.send(self.common.mention(msg), 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.MessageEmbed();
        embed.setTitle(strings.get('numsTitle', msg.locale));
        res.forEach((el, i) => embed.addField(`#${i}`, el, true));
        msg.channel.send(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.MessageEmbed();
    embed.setThumbnail('https://discordemoji.com/assets/emoji/rigged.png');
    embed.setColor([187, 26, 52]);
    msg.channel.send(self.common.mention(msg), 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.MessageEmbed();
    embed.setColor(defaultColor);
    embed.setTitle(strings.get('reactToJoinTitle', locale));
    embed.setDescription(strings.get(
        'reactToJoinBody', locale,
        hg.getGame(channel.guild.id).currentGame.name));
    channel.send(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(`\`${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);
            }
          });
        });
      }
      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();