Source: web/hg.js

// Copyright 2019-2022 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (web@campbellcrowley.com)
const http = require('http');
const socketIo = require('socket.io');
const auth = require('../../auth.js');
const crypto = require('crypto');
const HungryGames = require('../hg/HungryGames.js');
const MessageMaker = require('../lib/MessageMaker.js');

require('../subModule.js').extend(HGWeb);  // Extends the SubModule class.

/**
 * @classdesc Creates a web interface for managing the Hungry Games. Expects
 * ../hungryGames.js is loaded or will be loaded.
 * @class
 */
function HGWeb() {
  const self = this;
  this.myName = 'HGWeb';

  let hg_ = null;

  let ioClient;
  /**
   * Buffer storing all current image uploads and their associated meta-data.
   *
   * @private
   * @type {object}
   */
  const imageBuffer = {};

  let app;
  let io;
  if (!this.common.isSlave) {
    app = http.createServer(handler);

    app.on('error', (err) => {
      if (io) io.close();
      if (app) app.close();
      if (err.code === 'EADDRINUSE') {
        this.warn(
            'HGWeb failed to bind to port because it is in use. (' + err.port +
            ')');
        if (!this.common.isMaster) {
          this.log(
              'Restarting into client mode due to server already bound ' +
              'to port.');
          startClient();
        }
      } else {
        this.error('HGWeb failed to bind to port for unknown reason.', err);
      }
    });
  }

  /**
   * Start a socketio client connection to the primary running server.
   *
   * @private
   */
  function startClient() {
    const client = require('socket.io-client');
    if (self.common.isSlave) {
      const host = self.common.masterHost;
      const port = host.host === 'localhost' ?
          (self.common.isRelease ? 8011 : 8013) :
          host.port;
      ioClient = client(`${host.protocol}//${host.host}:${port}`, {
        path: `${host.path}child/hg/`,
      });
    } else {
      ioClient = client(
          self.common.isRelease ? 'http://localhost:8011' :
                                  'http://localhost:8013',
          {path: '/socket.io/hg/', reconnectionDelay: 0});
    }
    clientSocketConnection(ioClient);
  }

  /**
   * Update the reference to HungryGames.
   *
   * @private
   * @returns {HG} Reference to the currently loaded HungryGames subModule.
   */
  function hg() {
    const prev = hg_;
    hg_ = self.bot.getSubmodule('./hungryGames.js');
    if (!hg_) return;
    if (prev !== hg_) {
      unlinkHG();
      hg_.on('dayStateChange', dayStateChange);
      hg_.on('toggleOption', handleOptionChange);
      hg_.on('create', broadcastGame);
      hg_.on('refresh', broadcastGame);
      hg_.on('memberAdd', handleMemberAdd);
      hg_.on('memberRemove', handleMemberRemove);
      hg_.on('actionInsert', handleActionUpdate);
      hg_.on('actionRemove', handleActionUpdate);
      hg_.on('actionUpdate', handleActionUpdate);
      hg_.on('eventToggled', handleEventToggled);
      hg_.on('eventAdded', handleEventAdded);
      hg_.on('eventRemoved', handleEventRemoved);
      hg_.on('gameStarted', broadcastGame);
      hg_.on('reset', broadcastGame);
      hg_.on('shutdown', unlinkHG);

      hg_.client.on('guildMemberAdd', handleGuildMemberAdd);
      hg_.client.on('guildMemberRemove', handleGuildMemberRemove);
    }
    return hg_;
  }

  /**
   * Unregister all event handlers from `hg_`.
   *
   * @private
   */
  function unlinkHG() {
    if (!hg_) return;
    hg_.removeListener('dayStateChange', dayStateChange);
    hg_.removeListener('toggleOption', handleOptionChange);
    hg_.removeListener('create', broadcastGame);
    hg_.removeListener('refresh', broadcastGame);
    hg_.removeListener('memberAdd', handleMemberAdd);
    hg_.removeListener('memberRemove', handleMemberRemove);
    hg_.removeListener('actionInsert', handleActionUpdate);
    hg_.removeListener('actionRemove', handleActionUpdate);
    hg_.removeListener('actionUpdate', handleActionUpdate);
    hg_.removeListener('eventToggled', handleEventToggled);
    hg_.removeListener('eventAdded', handleEventAdded);
    hg_.removeListener('eventRemoved', handleEventRemoved);
    hg_.removeListener('gameStarted', broadcastGame);
    hg_.removeListener('reset', broadcastGame);
    hg_.removeListener('shutdown', unlinkHG);

    hg_.client.removeListener('guildMemberAdd', handleGuildMemberAdd);
    hg_.client.removeListener('guildMemberRemove', handleGuildMemberRemove);
  }

  /** @inheritdoc */
  this.initialize = function() {
    if (self.common.isSlave) {
      startClient();
    } else {
      io = socketIo(app, {path: '/socket.io/'});
      app.listen(self.common.isRelease ? 8011 : 8013, '127.0.0.1');
      io.on('connection', socketConnection);
    }
  };

  /**
   * Causes a full shutdown of all servers.
   *
   * @public
   */
  this.shutdown = function() {
    if (io) io.close();
    if (ioClient) {
      ioClient.close();
      ioClient = null;
    }
    if (app) app.close();
    unlinkHG();
  };

  /**
   * Handler for all http requests. Should never be called.
   *
   * @private
   * @param {http.IncomingMessage} req The client's request.
   * @param {http.ServerResponse} res Our response to the client.
   */
  function handler(req, res) {
    res.writeHead(418);
    res.end('TEAPOT');
  }

  /**
   * Map of all currently connected sockets.
   *
   * @private
   * @type {object.<Socket>}
   */
  const sockets = {};

  /**
   * Returns the number of connected clients that are not siblings.
   *
   * @public
   * @returns {number} Number of sockets.
   */
  this.getNumClients = function() {
    return Object.keys(sockets).length - Object.keys(siblingSockets).length;
  };

  /**
   * Map of all sockets connected that are siblings.
   *
   * @private
   * @type {object.<Socket>}
   */
  const siblingSockets = {};

  /**
   * Handler for a new socket connecting.
   *
   * @private
   * @param {socketIo~Socket} socket The socket.io socket that connected.
   */
  function socketConnection(socket) {
    // x-forwarded-for is trusted because the last process this jumps through is
    // our local proxy.
    const ipName = self.common.getIPName(
        socket.handshake.headers['x-forwarded-for'] ||
        socket.handshake.address);
    self.common.log(
        'Socket    connected HG (' + Object.keys(sockets).length + '): ' +
            ipName,
        socket.id);
    sockets[socket.id] = socket;

    // @TODO: Replace this authentication with asymmetric key signatures.
    socket.on('vaderIAmYourSon', (verification, cb) => {
      if (verification === auth.hgWebSiblingVerification) {
        siblingSockets[socket.id] = socket;
        cb(auth.hgWebSiblingVerificationResponse);

        socket.on('_guildBroadcast', (gId, ...args) => {
          for (const i in sockets) {
            if (sockets[i] && sockets[i].cachedGuilds &&
                sockets[i].cachedGuilds.includes(gId)) {
              sockets[i].emit(...args);
            }
          }
        });
      } else {
        self.common.error('Client failed to authenticate as child.', socket.id);
      }
    });

    // Unrestricted Access //
    socket.on('fetchDefaultOptions', () => {
      socket.emit('defaultOptions', hg().defaultOptions.entries);
    });
    socket.on('fetchDefaultEvents', () => {
      socket.emit('defaultEvents', hg().getDefaultEvents().serializable);
    });
    socket.on('fetchActionList', () => {
      const Action = HungryGames.Action;
      if (!Action) {
        socket.emit('actionList', null, null);
      } else {
        socket.emit('actionList', Action.actionList, Action.triggerMeta);
      }
    });
    // End Unrestricted Access \\

    // Restricted Access //
    socket.on('fetchGuilds', (...args) => handle(fetchGuilds, args, false));
    socket.on('fetchGuild', (...args) => handle(self.fetchGuild, args));
    socket.on('fetchMember', (...args) => handle(self.fetchMember, args));
    socket.on('fetchRoles', (...args) => handle(self.fetchRoles, args));
    socket.on('fetchChannel', (...args) => handle(self.fetchChannel, args));
    socket.on('fetchGames', (...args) => handle(self.fetchGames, args));
    socket.on('fetchDay', (...args) => handle(self.fetchDay, args));
    socket.on('fetchNextDay', (...args) => handle(self.fetchNextDay, args));
    socket.on('fetchActions', (...args) => handle(self.fetchActions, args));
    socket.on('insertAction', (...args) => handle(self.insertAction, args));
    socket.on('removeAction', (...args) => handle(self.removeAction, args));
    socket.on('updateAction', (...args) => handle(self.updateAction, args));
    socket.on('excludeMember', (...args) => handle(self.excludeMember, args));
    socket.on('includeMember', (...args) => handle(self.includeMember, args));
    socket.on('toggleOption', (...args) => handle(self.toggleOption, args));
    socket.on('createGame', (...args) => handle(self.createGame, args));
    socket.on('resetGame', (...args) => handle(self.resetGame, args));
    socket.on('startGame', (...args) => handle(self.startGame, args));
    socket.on('startAutoplay', (...args) => handle(self.startAutoplay, args));
    socket.on('nextDay', (...args) => handle(self.nextDay, args));
    socket.on('gameStep', (...args) => handle(self.gameStep, args));
    socket.on('endGame', (...args) => handle(self.endGame, args));
    socket.on('pauseAutoplay', (...args) => handle(self.pauseAutoplay, args));
    socket.on('pauseGame', (...args) => handle(self.pauseGame, args));
    socket.on('editTeam', (...args) => handle(self.editTeam, args));
    socket.on(
        'createEvent', (...args) => handle(self.createEvent, args, false));
    socket.on('addEvent', (...args) => handle(self.addEvent, args));
    socket.on('removeEvent', (...args) => handle(self.removeEvent, args));
    socket.on(
        'deleteEvent', (...args) => handle(self.deleteEvent, args, false));
    socket.on('toggleEvent', (...args) => handle(self.toggleEvent, args));
    socket.on(
        'replaceEvent', (...args) => handle(self.replaceEvent, args, false));
    socket.on('fetchEvent', (...args) => handle(self.fetchEvent, args, false));
    socket.on(
        'fetchUserEvents',
        (...args) => handle(self.fetchUserEvents, args, false));
    socket.on(
        'claimLegacyEvents', (...args) => handle(self.claimLegacyEvents, args));
    socket.on(
        'forcePlayerState', (...args) => handle(self.forcePlayerState, args));
    socket.on('renameGame', (...args) => handle(self.renameGame, args));
    socket.on('renameNPC', (...args) => handle(self.renameNPC, args));
    socket.on('removeNPC', (...args) => handle(self.removeNPC, args));
    socket.on(
        'fetchStatGroupList',
        (...args) => handle(self.fetchStatGroupList, args));
    socket.on(
        'fetchStatGroupMetadata',
        (...args) => handle(self.fetchStatGroupMetadata, args));
    socket.on('fetchStats', (...args) => handle(self.fetchStats, args));
    socket.on(
        'fetchLeaderboard', (...args) => handle(self.fetchLeaderboard, args));
    socket.on(
        'modifyInventory', (...args) => handle(self.modifyInventory, args));
    socket.on(
        'selectStatGroup', (...args) => handle(self.selectStatGroup, args));
    socket.on(
        'createStatGroup', (...args) => handle(self.createStatGroup, args));
    socket.on(
        'deleteStatGroup', (...args) => handle(self.deleteStatGroup, args));
    socket.on('imageChunk', (...args) => handle(self.imageChunk, args));
    socket.on('imageInfo', (...args) => handle(self.imageInfo, args));
    // End Restricted Access \\

    /**
     * Calls the functions with added arguments, and copies the request to all
     * sibling clients.
     *
     * @private
     * @param {Function} func The function to call.
     * @param {Array.<*>} args Array of arguments to send to function.
     * @param {boolean} [forward=true] Forward this request directly to all
     * siblings.
     */
    function handle(func, args, forward = true) {
      const noLog = ['fetchMember', 'fetchChannel', 'fetchEvent', 'imageChunk'];
      if (!noLog.includes(func.name.toString())) {
        const logArgs = args.map((el) => {
          if (typeof el === 'function') {
            return (el.name || 'cb') + '()';
          } else {
            return el;
          }
        });
        self.common.logDebug(`${func.name}(${logArgs.join(',')})`, socket.id);
      }
      let cb;
      if (typeof args[args.length - 1] === 'function') {
        const origCB = args[args.length - 1];
        let fired = false;
        cb = function(...args) {
          if (fired) {
            self.warn(
                'Attempting to fire callback a second time! (' + func.name +
                ')');
          }
          origCB(...args);
          fired = true;
        };
        args[args.length - 1] = cb;
      }
      try {
        func.apply(func, [args[0], socket].concat(args.slice(1)));
      } catch (err) {
        console.error(err);
        if (typeof cb === 'function') {
          cb('INTERNAL_SERVER_ERROR');
        }
        return;
      }
      if (typeof cb === 'function') {
        args[args.length - 1] = {_function: true};
      }
      if (forward) {
        Object.values(siblingSockets).forEach((s) => {
          s.emit(
              'forwardedRequest', args[0], socket.id, func.name, args.slice(1),
              (res) => {
                if (res._forward) socket.emit(...res.data);
                if (res._callback && typeof cb === 'function') {
                  cb(...res.data);
                }
              });
        });
      }
    }

    socket.on('disconnect', (reason) => {
      self.common.log(
          'Socket disconnected HG (' + (Object.keys(sockets).length - 1) +
              ')(' + reason + '): ' + ipName,
          socket.id);
      if (siblingSockets[socket.id]) delete siblingSockets[socket.id];
      delete sockets[socket.id];
    });
  }
  /**
   * Handler for connecting as a client to the server.
   *
   * @private
   * @param {socketIo~Socket} socket The socket.io socket that connected.
   */
  function clientSocketConnection(socket) {
    let authenticated = false;
    socket.on('connect', () => {
      socket.emit('vaderIAmYourSon', auth.hgWebSiblingVerification, (res) => {
        self.common.log('Sibling authenticated successfully.', socket.id);
        authenticated = res === auth.hgWebSiblingVerificationResponse;
      });
    });

    socket.on('fetchGuilds', (userData, id, cb) => {
      fetchGuilds(userData, {id: id}, cb);
    });

    socket.on('forwardedRequest', (userData, sId, func, args, cb) => {
      if (!authenticated) return;
      const fakeSocket = {
        fake: true,
        emit: function(...args) {
          if (typeof cb == 'function') cb({_forward: true, data: args});
        },
        id: sId,
      };
      if (args[args.length - 1] && args[args.length - 1]._function) {
        args[args.length - 1] = function(...a) {
          if (typeof cb === 'function') cb({_callback: true, data: a});
        };
      }
      if (!self[func]) {
        self.common.error(func + ': is not a function.', socket.id);
      } else {
        self[func].apply(self[func], [userData, fakeSocket].concat(args));
      }
    });

    const error = function(...args) {
      console.error('HG WebSocket Error:', ...args);
    };

    socket.on('connect_error', error);
    socket.on('connect_timeout', error);
    socket.on('reconnect_error', error);
    socket.on('reconnect_failed', error);
    socket.on('error', error);
  }

  /**
   * This gets fired whenever the day state of any game changes in the hungry
   * games. This then notifies all clients that the state changed, if they care
   * about the guild.
   *
   * @private
   * @param {HungryGames} hg HG object firing the event.
   * @param {string} gId Guild id of the state change.
   * @listens HG#dayStateChange
   */
  function dayStateChange(hg, gId) {
    const game = hg.getHG().getGame(gId);
    let eventState = null;
    if (!game) return;
    if (game.currentGame.day.events[game.currentGame.day.state - 2] &&
        game.currentGame.day.events[game.currentGame.day.state - 2].battle) {
      eventState =
          game.currentGame.day.events[game.currentGame.day.state - 2].state;
    }
    guildBroadcast(
        gId, 'dayState', game.currentGame.day.num, game.currentGame.day.state,
        eventState);
  }

  /**
   * Broadcast a message to all relevant clients.
   *
   * @private
   * @param {string} gId Guild ID to broadcast message for.
   * @param {string} event The name of the event to broadcast.
   * @param {*} args Data to send in broadcast.
   */
  function guildBroadcast(gId, event, ...args) {
    const keys = Object.keys(sockets);
    for (const i in keys) {
      if (!sockets[keys[i]].cachedGuilds) continue;
      if (sockets[keys[i]].cachedGuilds.find((g) => g === gId)) {
        sockets[keys[i]].emit(event, gId, ...args);
      }
    }
    if (ioClient) {
      ioClient.emit('_guildBroadcast', gId, event, gId, ...args);
    }
  }

  /**
   * Handles an option being changed and broadcasting the update to clients.
   *
   * @private
   * @listens HG#toggleOption
   * @param {HungryGames} hg HG object firing the event.
   * @param {string} gId Guild ID of the option change.
   * @param {string} opt1 Option key.
   * @param {string} opt2 Option second key or value.
   * @param {string} [opt3] Option value if object option.
   */
  function handleOptionChange(hg, gId, opt1, opt2, opt3) {
    if (opt1 === 'teamSize') {
      broadcastGame(hg, gId);
    } else {
      guildBroadcast(gId, 'option', opt1, opt2, opt3);
    }
  }

  /**
   * Handle a member being added to a guild.
   *
   * @private
   * @listens Discord~Client#guildMemberAdd
   * @param {Discord~GuildMember} member The member added.
   */
  function handleGuildMemberAdd(member) {
    handleMemberAdd(hg(), member.guild.id, member.id);
  }

  /**
   * Handle a member being removed from a guild.
   *
   * @private
   * @listens Discord~Client#guildMemberRemove
   * @param {Discord~GuildMember} member The member removed.
   */
  function handleGuildMemberRemove(member) {
    handleMemberRemove(hg(), member.guild.id, member.id);
  }

  /**
   * Handle a member being added to a guild.
   *
   * @private
   * @listens HG#memberAdd
   * @param {HungryGames} hg HG object firing the event.
   * @param {string} gId Guild ID of the member added.
   * @param {string} mId Member ID that was added.
   */
  function handleMemberAdd(hg, gId, mId) {
    guildBroadcast(gId, 'memberAdd', mId);
  }

  /**
   * Handle a member being removed from a guild.
   *
   * @private
   * @listens HG#memberRemove
   * @param {HungryGames} hg HG object firing the event.
   * @param {string} gId Guild ID of the member removed.
   * @param {string} mId Member ID that was removed.
   */
  function handleMemberRemove(hg, gId, mId) {
    guildBroadcast(gId, 'memberRemove', mId);
  }

  /**
   * Handle actions being modified in a server.
   *
   * @private
   * @listens HG#actionInsert
   * @listens HG#actionRemove
   * @listens HG#actionUpdate
   * @param {HungryGames} hg HG object firing event.
   * @param {string} gId The guild ID that was updated.
   */
  function handleActionUpdate(hg, gId) {
    const game = hg.getHG().getGame(gId);
    guildBroadcast(
        gId, 'actions', game && game.actions && game.actions.serializable);
  }

  /**
   * Handle events being toggled in a server.
   *
   * @private
   * @listens HG#eventToggled
   * @param {HungryGames} hg HG object firing event.
   * @param {string} gId The guild ID that was updated.
   * @param {string} type The category the event was toggled in.
   * @param {string} eId The ID of the event that was toggled.
   * @param {boolean} value The if event is now enabled.
   */
  function handleEventToggled(hg, gId, type, eId, value) {
    guildBroadcast(gId, 'eventToggled', type, eId, value);
  }

  /**
   * Handle events being added to a server.
   *
   * @private
   * @listens HG#eventAdded
   * @param {HungryGames} hg HG object firing event.
   * @param {string} gId The guild ID that was updated.
   * @param {string} type The event category.
   * @param {string} eId The ID of the event that was added.
   */
  function handleEventAdded(hg, gId, type, eId) {
    guildBroadcast(gId, 'eventAdded', type, eId);
  }

  /**
   * Handle events being removed from a server.
   *
   * @private
   * @listens HG#eventRemoved
   * @param {HungryGames} hg HG object firing event.
   * @param {string} gId The guild ID that was updated.
   * @param {string} type The event category.
   * @param {string} eId The ID of the event that was removed.
   */
  function handleEventRemoved(hg, gId, type, eId) {
    guildBroadcast(gId, 'eventRemoved', type, eId);
  }

  /**
   * Handles broadcasting the game data to all relevant clients.
   *
   * @private
   * @listens HG#create
   * @listens HG#refresh
   * @param {HungryGames} hg HG object firing event.
   * @param {string} gId The guild ID to data for.
   */
  function broadcastGame(hg, gId) {
    const game = hg.getHG().getGame(gId);
    guildBroadcast(gId, 'game', game && game.serializable);
  }

  /**
   * Send a message to the given socket informing the client that the command
   * they attempted failed due to insufficient permission.
   *
   * @private
   * @param {Socket} socket The socket.io socket to reply on.
   * @param {string} cmd The command the client attempted.
   * @param {string} [gId] The guild ID for locale info.
   */
  function replyNoPerm(socket, cmd, gId) {
    const output = hg().getString('genericNoPerm', gId, cmd);
    self.common.logDebug(`Attempted ${cmd} without permission.`, socket.id);
    socket.emit('message', output);
  }

  /**
   * Checks if the current shard is responsible for the requested guild.
   *
   * @private
   * @param {number|string} gId The guild id to check.
   * @returns {boolean} True if this shard has this guild.
   */
  function checkMyGuild(gId) {
    const g = self.client && self.client.guilds.resolve(gId);
    return (g && true) || false;
  }

  /**
   * Check that the given user has permission to manage the games in the given
   * guild.
   *
   * @private
   * @param {UserData} userData The user to check.
   * @param {string} gId The guild id to check against.
   * @param {?string} cId The channel id to check against.
   * @param {string} cmd The command being attempted.
   * @returns {boolean} Whther the user has permission or not to manage the
   * hungry games in the given guild.
   */
  function checkPerm(userData, gId, cId, cmd) {
    if (!userData) return false;
    const msg = makeMessage(userData.id, gId, cId, 'hg ' + cmd);
    if (!msg || !msg.author) return false;
    if (userData.id == self.common.spikeyId) return true;
    return !self.command.validate(null, msg);
  }

  /**
   * Check that the given user has permission to see and send messages in the
   * given channel, as well as manage the games in the given guild.
   *
   * @private
   * @param {UserData} userData The user to check.
   * @param {string} gId The guild id of the guild that contains the channel.
   * @param {string} cId The channel id to check against.
   * @param {string} cmd The command being attempted to check permisisons for.
   * @returns {boolean} Whther the user has permission or not to manage the
   * hungry games in the given guild and has permission to send messages in the
   * given channel.
   */
  function checkChannelPerm(userData, gId, cId, cmd) {
    if (!checkPerm(userData, gId, cId, cmd)) return false;
    if (userData.id == self.common.spikeyId) return true;
    const g = self.client.guilds.resolve(gId);

    const channel = g.channels.resolve(cId);
    if (!channel) return false;

    const m = g.members.resolve(userData.id);

    const perms = channel.permissionsFor(m);
    if (!perms.has(self.Discord.PermissionsBitField.Flags.ViewChannel)) {
      return false;
    }
    if (!perms.has(self.Discord.PermissionsBitField.Flags.SendMessages)) {
      return false;
    }
    return true;
  }

  /**
   * 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, or null if
   * invalid channel.
   */
  function makeMessage(uId, gId, cId, msg) {
    const message = new MessageMaker(self, uId, gId, cId, msg);
    return message.guild ? message : null;
  }

  /**
   * Strips a Discord~GuildMember to only the necessary data that a client will
   * need.
   *
   * @private
   * @param {Discord~GuildMember} m The guild member to strip the data from.
   * @returns {object} The minimal member.
   */
  function makeMember(m) {
    const m_ = m;
    if (typeof m !== 'object') {
      m = {
        roles: {
          cache: [],
        },
        guild: {},
        permissions: {bitfield: 0},
        user: self.client.users.resolve(m),
      };
    }
    if (!m.user) {
      self.error(`Failed to make member: ${m_}`);
      return null;
    }
    return {
      nickname: m.nickname,
      roles: m.roles.cache.map && m.roles.cache.map((el) => el.id),
      color: m.displayColor,
      guild: {id: m.guild.id},
      permissions: m.permissions.bitfield,
      premiumSinceTimestamp: m.premiumSinceTimestamp,
      user: {
        username: m.user.username,
        avatarURL: m.user.displayAvatarURL(),
        id: m.user.id,
        bot: m.user.bot,
        // m.user.descriminator seems to be broken and always returns
        // `undefined`.
        descriminator: m.user.tag.match(/#(\d{4})$/)[1],
      },
      joinedTimestamp: m.joinedTimestamp,
    };
  }

  /**
   * Cancel and clean up a current image upload.
   *
   * @private
   * @param {string} iId Image upload ID to purge and abort.
   */
  function cancelImageUpload(iId) {
    if (!imageBuffer[iId]) return;
    clearTimeout(imageBuffer[iId].timeout);
    delete imageBuffer[iId];
  }

  /**
   * Create an upload ID and buffer for a client to send to. Automatically
   * cancelled after 60 seconds.
   *
   * @private
   * @param {string} uId The user ID that started this upload.
   * @returns {object} The metadata storing object.
   */
  function beginImageUpload(uId) {
    let id;
    do {
      id = `${crypto.randomBytes(8).toString('hex').toUpperCase()}`;
    } while (imageBuffer[id]);
    imageBuffer[id] =
        {receivedBytes: 0, buffer: [], startTime: Date.now(), id: id, uId: uId};
    imageBuffer[id].timeout = setTimeout(function() {
      cancelImageUpload(id);
    }, 60000);
    return imageBuffer[id];
  }

  /**
   * @description Function calls handlers for requested commands.
   * @typedef HGWeb~SocketFunction
   * @type {Function}
   *
   * @param {WebUserData} userData The user data of the user performing the
   * request.
   * @param {socketIo~Socket} socket The socket connection firing the command.
   * Not necessarily the socket that will reply to the end client.
   * @param {...*} args Additional function-specific arguments.
   * @param {HGWeb~basicCB} [cb] Callback that fires once requested action is
   * complete or has failed. Client may not pass a callback.
   */

  /**
   * Basic callback with single argument. The argument is null if there is no
   * error, or a string if there was an error.
   *
   * @callback HGWeb~basicCB
   *
   * @param {?string} err The error response.
   * @param {*} res Response data if no error.
   */

  /**
   * Fetch all relevant data for all mutual guilds with the user and send it to
   * the user.
   *
   * @private
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {Function} [cb] Callback that fires once the requested action is
   * complete, or has failed.
   */
  function fetchGuilds(userData, socket, cb) {
    if (!userData) {
      self.common.error('Fetch Guilds without userData', socket.id);
      if (typeof cb === 'function') cb('SIGNED_OUT');
      return;
    } else if (userData.apiRequest) {
      // Disabled for API requests due to the possible issue with performance
      // fetching list of guilds.
      return;
    }

    const numReplies = (Object.entries(siblingSockets).length || 0);
    let replied = 0;
    const guildBuffer = {};
    let done;
    if (typeof cb === 'function') {
      done = cb;
    } else {
      /**
       * The callback for each response with the requested data. Replies to the
       * user once all requests have replied.
       *
       * @private
       * @param {string|object} guilds Either the guild data to send to the
       * user, or 'guilds' if this is a reply from a sibling client.
       * @param {?string} [err] The error that occurred, or null if no error.
       * @param {object} [response] The guild data if `guilds` equals 'guilds'.
       */
      done = function(guilds, err, response) {
        if (guilds === 'guilds') {
          if (err) {
            guilds = null;
          } else {
            guilds = response;
          }
        }
        for (let i = 0; guilds && i < guilds.length; i++) {
          guildBuffer[guilds[i].id] = guilds[i];
        }
        replied++;
        if (replied > numReplies) {
          if (typeof cb === 'function') cb(guildBuffer);
          socket.emit('guilds', null, guildBuffer);
          socket.cachedGuilds = Object.keys(guildBuffer || {});
        }
      };
    }
    Object.entries(siblingSockets).forEach((obj) => {
      obj[1].emit('fetchGuilds', userData, socket.id, done);
    });

    if (self.common.isMaster) {
      done([]);
      return;
    }

    try {
      let guilds = [];
      if (userData.guilds && userData.guilds.length > 0) {
        userData.guilds.forEach((el) => {
          const g = self.client && self.client.guilds.resolve(el.id);
          if (!g) return;
          guilds.push(g);
        });
      } else {
        guilds = self.client &&
            [...self.client.guilds.cache
                .filter((obj) => obj.members.resolve(userData.id))
                .values()];
      }
      const strippedGuilds = stripGuilds(guilds, userData);
      done(strippedGuilds);
    } catch (err) {
      self.common.error(
          'Error while fetching guilds (Cached: ' +
              (userData.guilds && true || false) + ')',
          socket.id);
      console.error(err);
      done();
    }
  }

  /**
   * Strip a Discord~Guild to the basic information the client will need.
   *
   * @private
   * @param {Discord~Guild[]} guilds The array of guilds to strip.
   * @param {object} userData The current user's session data.
   * @returns {Array<object>} The stripped guilds.
   */
  function stripGuilds(guilds, userData) {
    return guilds.map((g) => {
      let dOpts = self.command.getDefaultSettings() || {};
      dOpts = Object.entries(dOpts)
          .filter((el) => el[1].getFullName().startsWith('hg'))
          .reduce((p, c) => {
            p[c[0]] = c[1];
            return p;
          }, {});
      let uOpts = self.command.getUserSettings(g.id) || {};
      uOpts = Object.entries(uOpts)
          .filter((el) => el[0].startsWith('hg'))
          .reduce(
              (p, c) => {
                p[c[0]] = c[1];
                return p;
              },
              {});

      const member = g.members.resolve(userData.id);
      const newG = {};
      newG.iconURL = g.iconURL();
      newG.name = g.name;
      newG.id = g.id;
      newG.bot = self.client.user.id;
      newG.ownerId = g.ownerId;
      newG.members = g.members.cache.map((m) => m.id);
      newG.defaultSettings = dOpts;
      newG.userSettings = uOpts;
      newG.channels =
          g.channels.cache
              .filter(
                  (c) => member &&
                      (userData.id == self.common.spikeyId ||
                       c.permissionsFor(member).has(
                           self.Discord.PermissionsBitField.Flags.ViewChannel)))
              .map((c) => {
                return {
                  id: c.id,
                  permissions: userData.id == self.common.spikeyId ?
                      self.Discord.PermissionsBitField.ALL :
                      c.permissionsFor(member).bitfield,
                };
              });
      newG.myself = makeMember(member || userData.id);
      return newG;
    });
  }

  /**
   * Fetch a single guild.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {string|number} gId The ID of the guild that was requested.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchGuild = function fetchGuild(userData, socket, gId, cb) {
    if (!userData) {
      self.common.error('Fetch Guild without userData', socket.id);
      if (typeof cb === 'function') cb('SIGNED_OUT');
      return;
    }
    if (typeof cb !== 'function') {
      self.common.logWarning(
          'Fetch Guild attempted without callback', socket.id);
      return;
    }

    const guild = self.client && self.client.guilds.resolve(gId);
    if (!guild) return;
    if (userData.id != self.common.spikeyId &&
        !guild.members.resolve(userData.id)) {
      cb(null);
      return;
    }
    cb(null, stripGuilds([guild], userData)[0]);
  };
  /**
   * Fetch data about a member of a guild.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {number|string} mId The member's id to lookup.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchMember = function fetchMember(userData, socket, gId, mId, cb) {
    if (!checkPerm(userData, gId, null, 'players')) return;
    const g = self.client && self.client.guilds.resolve(gId);
    if (!g) return;
    const m = g.members.resolve(mId);
    if (!m) return;
    const finalMember = makeMember(m);

    if (typeof cb === 'function') {
      cb(null, finalMember);
    } else {
      socket.emit('member', gId, mId, finalMember);
    }
  };
  /**
   * Fetch data about a role in a guild.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchRoles = function fetchRoles(userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null)) return;
    const g = self.client && self.client.guilds.resolve(gId);
    if (!g) return;

    const roles = [...g.roles.cache.values()];

    if (typeof cb === 'function') {
      cb(null, roles);
    } else {
      socket.emit('member', gId, roles);
    }
  };
  /**
   * Fetch data about a channel of a guild.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {number|string} cId The channel's id to lookup.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchChannel = function fetchChannel(userData, socket, gId, cId, cb) {
    if (!checkChannelPerm(userData, gId, cId, '')) return;
    const g = self.client && self.client.guilds.resolve(gId);
    if (!g) return;
    const m = g.members.resolve(userData.id);
    const channel = g.channels.resolve(cId);

    const perms = channel.permissionsFor(m) || {bitfield: 0};

    const stripped = {};
    stripped.id = channel.id;
    stripped.permissions = perms.bitfield;
    stripped.name = channel.name;
    stripped.position = channel.position;
    if (channel.parent) stripped.parent = {position: channel.parent.position};
    stripped.type = channel.type;

    if (typeof cb === 'function') {
      cb(null, stripped);
    } else {
      socket.emit('channel', gId, cId, stripped);
    }
  };
  /**
   * Fetch all game data within a guild.
   *
   * @see {@link HungryGames.getGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {basicCB} [cb] Callback that fires once the requested action is
   * complete, or has failed.
   */
  this.fetchGames = function fetchGames(userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'options') ||
        !checkPerm(userData, gId, null, 'events') ||
        !checkPerm(userData, gId, null, 'players')) {
      if (!checkMyGuild(gId)) return;
      replyNoPerm(socket, 'fetchGames', gId);
      return;
    }

    const game = hg().getHG().getGame(gId);
    if (typeof cb === 'function') {
      cb(null, game && game.serializable);
    } else {
      socket.emit('game', gId, game && game.serializable);
    }
  };
  /**
   * Fetch the updated game's day information.
   *
   * @see {@link HungryGames.getGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {basicCB} [cb] Callback that fires once the requested action is
   * complete, or has failed.
   */
  this.fetchDay = function fetchDay(userData, socket, gId, cb) {
    let g; let m;
    if (!userData) {
      return;
    } else {
      g = self.client && self.client.guilds.resolve(gId);
      if (!g) {
        // Request is probably fulfilled by another sibling.
        return;
      } else {
        m = g.members.resolve(userData.id);
        if (!m) {
          self.common.log(
              'Attempted fetchDay, but unable to find member in guild' + gId +
                  '@' + userData.id,
              socket.id);
          return;
        }
      }
    }
    const game = hg().getHG().getGame(gId);
    if (!game || !game.currentGame || !game.currentGame.day) {
      if (typeof cb === 'function') {
        cb('NO_GAME_IN_GUILD');
      } else {
        socket.emit(
            'message',
            'There doesn\'t appear to be a game on this server yet.');
      }
      return;
    }

    if (!g.channels.resolve(game.outputChannel)
        .permissionsFor(m)
        .has(self.Discord.PermissionsBitField.Flags.ViewChannel)) {
      replyNoPerm(socket, 'fetchDay', gId);
      return;
    }

    if (typeof cb === 'function') {
      cb(null, game.currentGame.day, game.currentGame.includedUsers);
    } else {
      socket.emit(
          'day', gId, game.currentGame.day, game.currentGame.includedUsers);
    }
  };
  /**
   * Fetch the game's next day information.
   *
   * @see {@link HungryGames.getGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchNextDay = function fetchNextDay(userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'kill')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'fetchNextDay', gId);
      return;
    }
    const g = self.client && self.client.guilds.resolve(gId);
    const m = g.members.resolve(userData.id);
    if (!m) {
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'fetchNextDay', gId);
      return;
    }
    const game = hg().getHG().getGame(gId);
    if (!game || !game.currentGame || !game.currentGame.nextDay) {
      if (typeof cb === 'function') {
        cb('NO_GAME_IN_GUILD');
      } else {
        socket.emit(
            'message',
            'There doesn\'t appear to be a game on this server yet.');
      }
      return;
    }

    if (!g.channels.resolve(game.outputChannel)
        .permissionsFor(m)
        .has(self.Discord.PermissionsBitField.Flags.ViewChannel)) {
      replyNoPerm(socket, 'fetchNextDay', gId);
      return;
    }

    if (typeof cb === 'function') {
      cb(null, game.currentGame.nextDay);
    } else {
      socket.emit('nextDay', gId, game.currentGame.nextDay);
    }
  };
  /**
   * Fetch the game's current action/trigger information.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchActions = function fetchActions(userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'options')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'fetchActions', gId);
      return;
    }
    const game = hg().getHG().getGame(gId);
    if (!game || !game.actions) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }

    if (typeof cb === 'function') {
      cb(null, game.actions.serializable);
    } else {
      socket.emit('actions', gId, game.actions.serializable);
    }
  };
  /**
   * Add an action to a trigger in a guild.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {string} trigger The name of the trigger.
   * @param {string} action The name of the action.
   * @param {object} args Optional arguments to pass for the action creation.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.insertAction = function insertAction(
      userData, socket, gId, trigger, action, args, cb) {
    if (!checkPerm(userData, gId, null, 'options')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'insertAction', gId);
      return;
    }
    hg().getHG().insertAction(gId, trigger, action, args, cb);
  };
  /**
   * Remove an action from a trigger in a guild.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {string} trigger The name of the trigger.
   * @param {string} id The id of the action to remove.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.removeAction = function removeAction(
      userData, socket, gId, trigger, id, cb) {
    if (!checkPerm(userData, gId, null, 'options')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'removeAction', gId);
      return;
    }
    hg().getHG().removeAction(gId, trigger, id, cb);
  };
  /**
   * Update an action for a trigger in a guild.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {string} trigger The name of the trigger.
   * @param {string} id The id of the action to remove.
   * @param {string} key The key of the value to change.
   * @param {number|string} value The value to set.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.updateAction = function updateAction(
      userData, socket, gId, trigger, id, key, value, cb) {
    if (!checkPerm(userData, gId, null, 'options')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'updateAction', gId);
      return;
    }
    hg().getHG().updateAction(gId, trigger, id, key, value, cb);
  };
  /**
   * Exclude a member from the Games.
   *
   * @see {@link HungryGames.excludeUsers}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {number|string} mId The member id to exclude.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.excludeMember = function excludeMember(userData, socket, gId, mId, cb) {
    if (!checkPerm(userData, gId, null, 'exclude')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'excludeMember', gId);
      return;
    }
    if (mId === 'everyone' || mId === 'online' || mId == 'offline' ||
        mId == 'dnd' || mId == 'idle') {
      hg().excludeUsers(mId, gId, (res) => {
        if (typeof cb === 'function') cb(res);
      });
    } else {
      hg().excludeUsers([mId], gId, (res) => {
        if (typeof cb === 'function') cb(res);
      });
    }
  };
  /**
   * Include a member in the Games.
   *
   * @see {@link HungryGames.includeUsers}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {number|string} mId The member id to include.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.includeMember = function includeMember(userData, socket, gId, mId, cb) {
    if (!checkPerm(userData, gId, null, 'include')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'includeMember', gId);
      return;
    }
    if (mId === 'everyone' || mId === 'online' || mId == 'offline' ||
        mId == 'dnd' || mId == 'idle') {
      hg().includeUsers(mId, gId, (res) => {
        if (typeof cb === 'function') cb(res);
      });
    } else {
      hg().includeUsers([mId], gId, (res) => {
        if (typeof cb === 'function') cb(res);
      });
    }
  };
  /**
   * Toggle an option in the Games.
   *
   * @see {@link HungryGames.setOption}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {string} option The option to change.
   * @param {string|number} value The value to set option to.
   * @param {string} extra The extra text if the option is in an object.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.toggleOption = function toggleOption(
      userData, socket, gId, option, value, extra, cb) {
    if (!checkPerm(userData, gId, null, 'option')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'toggleOption', gId);
      return;
    }
    hg().getHG().fetchGame(gId, (game) => {
      const response = hg().setOption(gId, option, value, extra || undefined);
      if (typeof cb === 'function') {
        cb(null, response);
      } else if (!game) {
        socket.emit('message', response);
      }
    });
  };
  /**
   * Create a Game.
   *
   * @see {@link HungryGames.createGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.createGame = function createGame(userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'create')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'createGame', gId);
      return;
    }
    hg().createGame(gId, (game) => {
      if (typeof cb === 'function') cb(game ? null : 'ATTEMPT_FAILED');
    });
  };
  /**
   * Reset game data.
   *
   * @see {@link HungryGames.resetGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {string} cmd Command specifying what data to delete.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.resetGame = function resetGame(userData, socket, gId, cmd, cb) {
    if (!checkPerm(userData, gId, null, 'reset')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'resetGame', gId);
      return;
    }
    hg().getHG().fetchGame(gId, () => {
      const response = hg().getHG().resetGame(gId, cmd);
      if (typeof cb === 'function') cb(null, response);
    });
  };
  /**
   * Start the game.
   *
   * @see {@link HungryGames.startGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {number|string} cId Channel to start the game in.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.startGame = function startGame(userData, socket, gId, cId, cb) {
    if (!checkChannelPerm(userData, gId, cId, 'start')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'startGame', gId);
      return;
    }
    hg().startGame(userData.id, gId, cId);
    if (typeof cb === 'function') cb(null);
  };
  /**
   * Enable autoplay.
   *
   * @see {@link HungryGames.startAutoplay}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {number|string} cId Channel to send the messages in.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.startAutoplay = function startAutoplay(userData, socket, gId, cId, cb) {
    if (!checkChannelPerm(userData, gId, cId, 'autoplay')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'startAutoplay', gId);
      return;
    }
    hg().startAutoplay(userData.id, gId, cId);
    if (typeof cb === 'function') cb(null);
  };
  /**
   * Start the next day.
   *
   * @see {@link HungryGames.nextDay}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {number|string} cId Channel to send the messages in.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.nextDay = function nextDay(userData, socket, gId, cId, cb) {
    if (!checkChannelPerm(userData, gId, cId, 'next')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'nextDay', gId);
      return;
    }
    hg().nextDay(userData.id, gId, cId);
    if (typeof cb === 'function') cb(null);
  };
  /**
   * Step the game once.
   *
   * @see {@link HungryGames.gameStep}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {number|string} cId Channel to send the messages in.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.gameStep = function gameStep(userData, socket, gId, cId, cb) {
    if (!checkChannelPerm(userData, gId, cId, 'step')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'gameStep', gId);
      return;
    }
    hg().gameStep(userData.id, gId, cId);
    if (typeof cb === 'function') cb(null);
  };
  /**
   * End the game.
   *
   * @see {@link HungryGames.endGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.endGame = function endGame(userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'end')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'endGame', gId);
      return;
    }
    hg().endGame(userData.id, gId);
    const game = hg().getHG().getGame(gId);
    if (typeof cb === 'function') {
      cb(null, game && game.serializable);
    } else {
      socket.emit('game', gId, game && game.serializable);
    }
  };
  /**
   * Disable autoplay.
   *
   * @see {@link HungryGames.pauseAutoplay}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.pauseAutoplay = function pauseAutoplay(userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'autoplay')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'pauseAutoplay', gId);
      return;
    }
    hg().pauseAutoplay(userData.id, gId);
    const game = hg().getHG().getGame(gId);
    if (typeof cb === 'function') {
      cb(null, game && game.serializable);
    } else {
      socket.emit('game', gId, game && game.serializable);
    }
  };
  /**
   * Pause game.
   *
   * @see {@link HungryGames.pauseGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.pauseGame = function pauseGame(userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'pause')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'pauseGame', gId);
      return;
    }
    const error = hg().pauseGame(gId);
    const game = hg().getHG().getGame(gId);
    if (typeof cb === 'function') {
      if (error !== 'Success') {
        cb(error);
      } else {
        cb(null, game && game.serializable);
      }
    } else {
      if (error !== 'Success') {
        socket.emit('message', error);
      } else {
        socket.emit('game', gId, game && game.serializable);
      }
    }
  };
  /**
   * Edit the teams.
   *
   * @see {@link HungryGames.editTeam}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {string} cmd The command to run.
   * @param {string} one The first argument.
   * @param {string} two The second argument.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.editTeam = function editTeam(userData, socket, gId, cmd, one, two, cb) {
    if (!checkPerm(userData, gId, null, 'team')) {
      if (!checkMyGuild(gId)) return;
      replyNoPerm(socket, 'editTeam', gId);
      return;
    }
    const message = hg().editTeam(userData.id, gId, cmd, one, two);
    if (typeof cb === 'function') {
      cb(null, message);
    } else {
      if (message) socket.emit('message', message);
    }
  };
  /**
   * Create a game event.
   *
   * @see {@link HungryGames~createEvent}
   * @see {@link HungryGames~EventContainer~fetch}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {HungryGames~Event} evt The event data of the event to create.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.createEvent = function createEvent(userData, socket, evt, cb) {
    if (!userData) return;
    if (typeof cb !== 'function') cb = function() {};
    evt.creator = userData.id;
    evt.id = null;
    if (evt.type === 'normal') {
      const err = HungryGames.NormalEvent.validate(evt);
      if (err) {
        cb(err);
        return;
      }
      evt = HungryGames.NormalEvent.from(evt);
    } else if (evt.type === 'arena') {
      if (Array.isArray(evt.outcomes)) {
        evt.outcomes.forEach((el, i) => {
          el.creator = userData.id;
          evt.outcomes[i] = HungryGames.NormalEvent.from(el);
        });
      }
      const err = HungryGames.ArenaEvent.validate(evt);
      if (err) {
        cb(err);
        return;
      }
      evt = HungryGames.ArenaEvent.from(evt);
    } else if (evt.type === 'weapon') {
      if (Array.isArray(evt.outcomes)) {
        evt.outcomes.forEach((el, i) => {
          el.creator = userData.id;
          evt.outcomes[i] = HungryGames.NormalEvent.from(el);
        });
      }
      evt.message = 'Weapon Message';
      const err = HungryGames.WeaponEvent.validate(evt);
      if (err) {
        cb(err);
        return;
      }
      evt = HungryGames.WeaponEvent.from(evt);
    } else {
      cb('BAD_TYPE');
      return;
    }

    hg().getHG().createEvent(evt, (err, evtFinal) => {
      if (err) {
        if (typeof cb === 'function') {
          cb('ATTEMPT_FAILED', err);
        } else {
          socket.emit('message', 'Failed to create event: ' + err);
        }
      } else {
        const eId = evtFinal.id;
        self.debug(`Created HG Event: ${eId}`);
        cb(null, eId);
      }
    });
  };

  /**
   * @description Add an existing event to a guild's custom events.
   * @public
   * @see {@link HungryGames~EventContainer~fetch}
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {string} gId The guild ID of the guild to modify.
   * @param {string} type The event category to add the event to.
   * @param {string} eId The event ID to add.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.addEvent = function addEvent(userData, socket, gId, type, eId, cb) {
    if (!checkPerm(userData, gId, null, 'event')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, this.name, gId);
      return;
    }
    if (typeof cb !== 'function') cb = function() {};

    if (!type) {
      cb('BAD_TYPE');
      return;
    }

    hg().getHG().fetchGame(gId, (game) => {
      if (!game) {
        cb('NO_GAME');
        return;
      }
      game.customEventStore.fetch(eId, type, (err) => {
        if (err) {
          cb('ATTEMPT_FAILED', err);
        } else {
          cb(null);
          if (type) guildBroadcast(gId, 'eventAdded', type, eId);
        }
      });
    });
  };

  /**
   * @description Remove an event from a guild's custom events.
   * @public
   * @see {@link HungryGames~EventContainer~remove}
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {string} gId The guild ID of the guild to modify.
   * @param {string} type The event category to remove the event from.
   * @param {string} eId The event ID to remove.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.removeEvent = function removeEvent(
      userData, socket, gId, type, eId, cb) {
    if (!checkPerm(userData, gId, null, 'event')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, this.name, gId);
      return;
    }
    if (typeof cb !== 'function') cb = function() {};

    hg().getHG().fetchGame(gId, (game) => {
      if (!game) {
        cb('NO_GAME');
        return;
      }
      if (game.customEventStore.remove(eId, type)) {
        cb(null);
        if (type) guildBroadcast(gId, 'eventRemoved', type, eId);
      } else {
        cb('ATTEMPT_FAILED');
      }
    });
  };

  /**
   * Delete a game event.
   *
   * @see {@link HungryGames.deleteEvent}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {string} eId The event ID to delete.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.deleteEvent = function deleteEvent(userData, socket, eId, cb) {
    if (!userData) return;
    hg().getHG().deleteEvent(userData.id, eId, (err) => {
      if (typeof cb !== 'function') return;
      if (err) {
        cb('ATTEMPT_FAILED', err);
      } else {
        cb(null);
      }
    });
  };

  /**
   * @description Enable or disable an event without deleting it.
   * @see {@link HungryGames.toggleEvent}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo-Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to run this command on.
   * @param {string} type The type of event that we are toggling.
   * @param {string} event The ID of the event to toggle.
   * @param {?boolean} value Set the enabled value instead of toggling.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete.
   */
  this.toggleEvent = function toggleEvent(
      userData, socket, gId, type, event, value, cb) {
    if (!checkPerm(userData, gId, null, 'event')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'removeEvent', gId);
      return;
    }
    if (typeof cb !== 'function') cb = function() {};
    const err = hg().toggleEvent(gId, type, event, value);
    if (err) {
      cb('ATTEMPT_FAILED', err);
    } else {
      cb(null);
    }
  };
  /**
   * Replace a custom event with new data.
   *
   * @see {@link HungryGames~replaceEvent}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {HungryGames~Event} evt The event data to update the event to.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.replaceEvent = function replaceEvent(userData, socket, evt, cb) {
    if (!userData) return;
    if (typeof cb !== 'function') cb = function() {};
    evt.creator = userData.id;
    if (evt.type === 'normal') {
      const err = HungryGames.NormalEvent.validate(evt);
      if (err) {
        cb(err);
        return;
      }
      evt = HungryGames.NormalEvent.from(evt);
    } else if (evt.type === 'arena') {
      const err = HungryGames.ArenaEvent.validate(evt);
      if (err) {
        cb(err);
        return;
      }
      evt = HungryGames.ArenaEvent.from(evt);
    } else if (evt.type === 'weapon') {
      const err = HungryGames.WeaponEvent.validate(evt);
      if (err) {
        cb(err);
        return;
      }
      evt = HungryGames.WeaponEvent.from(evt);
    } else {
      cb('BAD_TYPE');
      return;
    }

    hg().getHG().replaceEvent(userData.id, evt, (err) => {
      if (err) {
        if (typeof cb === 'function') {
          cb('ATTEMPT_FAILED', err);
        }
      } else {
        cb(null);
      }
    });
  };

  /**
   * Fetch a single event data.
   *
   * @see {@link HungryGames~EventContainer~fetch}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo-Socket} socket The socket connection to reply on.
   * @param {string} eId The event ID to fetch.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete.
   */
  this.fetchEvent = function fetchEvent(userData, socket, eId, cb) {
    if (!userData) return;
    hg().getDefaultEvents().fetch(eId, null, (err, evt) => {
      if (err) {
        cb('ATTEMPT_FAILED', err);
      } else {
        cb(null, evt);
      }
    });
  };

  /**
   * Fetch list of IDs of all events the user has created.
   *
   * @see {@link HungryGames~fetchUserEvents}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo-Socket} socket The socket connection to reply on.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete.
   */
  this.fetchUserEvents = function fetchUserEvents(userData, socket, cb) {
    if (!userData) return;
    hg().getHG().fetchUserEvents(userData.id, cb);
  };

  /**
   * Claim legacy events to the user.
   *
   * @see {@link HungryGames~claimLegacy}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo-Socket} socket The socket connection to reply on.
   * @param {string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete.
   */
  this.claimLegacyEvents = function claimLegacyEvents(
      userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'claimlegacy')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, this.name, gId);
      return;
    }
    hg().getHG().fetchGame(gId, (game) => {
      if (typeof cb !== 'function') cb = function() {};
      if (!game) {
        cb('NO_GAME');
      } else {
        hg().claimLegacy(game, userData.id, cb);
      }
    });
  };

  /**
   * Force a player in the game to end a day in a certain state.
   *
   * @see {@link HungryGames.forcePlayerState}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo-Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to run this command on.
   * @param {string[]} list The list of user IDs of the players to effect.
   * @param {string} state The forced state.
   * @param {string} [text] The message to show in the games as a result of this
   * command.
   * @param {boolean} [persists] Will this state be forced until the game ends.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete.
   */
  this.forcePlayerState = function forcePlayerState(
      userData, socket, gId, list, state, text, persists, cb) {
    let cmdToCheck = state;
    switch (state) {
      case 'living':
      case 'thriving':
        cmdToCheck = 'heal';
        break;
      case 'dead':
        cmdToCheck = 'kill';
        break;
      case 'wounded':
        cmdToCheck = 'hurt';
        break;
    }
    if (!checkPerm(userData, gId, null, cmdToCheck)) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'forcePlayerState', gId);
      return;
    }
    const game = hg().getHG().getGame(gId);
    if (!game) return;
    if (typeof text != 'string') {
      text = hg().getHG()._defaultEventStore.getArray('player');
    }
    const locale = self.bot.getLocale && self.bot.getLocale(gId);
    HungryGames.GuildGame.forcePlayerState(
        game, list, state, hg().getHG().messages, text, locale, (res) => {
          const output = hg().getString(res, gId);
          if (typeof cb === 'function') {
            cb(null, output, game.serializable);
          } else {
            socket.emit('message', output);
          }
        });
  };

  /**
   * Rename the guild's game.
   *
   * @see {@link HungryGames.renameGame}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo-Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to run this command on.
   * @param {string} name The name to change the game to.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete.
   */
  this.renameGame = function renameGame(userData, socket, gId, name, cb) {
    if (!checkPerm(userData, gId, null, 'rename')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'renameGame', gId);
      return;
    }
    hg().renameGame(gId, name);
    if (typeof cb === 'function') {
      let name = null;
      let game = hg().getHG().getGame(gId);
      if (game) game = game.currentGame;
      if (game) name = game.name;
      cb(name);
    }
  };

  /**
   * Rename an NPC in a game.
   *
   * @see {@link HungryGames.renameNPC}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo-Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to run this command on.
   * @param {string} npcId The ID of the NPC to rename.
   * @param {string} username The new username for the NPC.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete.
   */
  this.renameNPC = function renameNPC(
      userData, socket, gId, npcId, username, cb) {
    if (!checkPerm(userData, gId, null, 'ai create')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'renameNPC', gId);
      return;
    }
    const error = hg().renameNPC(gId, npcId, username);
    if (typeof cb === 'function') {
      cb(typeof error === 'string' ? error : null);
    }
  };

  /**
   * Remove an NPC from a game.
   *
   * @see {@link HungryGames.removeNPC}
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo-Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to run this command on.
   * @param {string} npcId The ID of the NPC to remove.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete.
   */
  this.removeNPC = function removeNPC(userData, socket, gId, npcId, cb) {
    if (!checkPerm(userData, gId, null, 'ai remove')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'removeNPC', gId);
      return;
    }
    const error = hg().removeNPC(gId, npcId);
    if (typeof cb === 'function') {
      cb(typeof error === 'string' ? error : null);
    }
  };

  /**
   * Respond with list of stat groups for the requested guild.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchStatGroupList = function fetchStatGroupList(
      userData, socket, gId, cb) {
    if (!checkPerm(userData, gId, null, 'groups')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'fetchStatGroupList', gId);
      return;
    }
    const game = hg().getHG().getGame(gId);
    if (!game) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }
    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);
          if (typeof cb === 'function') cb('ATTEMPT_FAILED');
          return;
        }
      }
      if (typeof cb === 'function') {
        cb(null, list);
      } else {
        socket.emit('statGroupList', gId, list);
      }
    });
  };

  /**
   * Respond with metadata for the requested stat group.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} guildId The guild id to look at.
   * @param {string} groupId The ID of the group.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchStatGroupMetadata = function fetchStatGroupMetadata(
      userData, socket, guildId, groupId, cb) {
    if (!checkPerm(userData, guildId, null, 'groups')) {
      if (!checkMyGuild(guildId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'fetchStatGroupMetadata', guildId);
      return;
    }
    const game = hg().getHG().getGame(guildId);
    if (!game) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }
    game._stats.fetchGroup(groupId, (err, group) => {
      if (err) {
        if (typeof cb === 'function') cb('BAD_GROUP');
        return;
      }
      group.fetchMetadata((err, meta) => {
        if (err) {
          self.error(
              'Failed to fetch metadata for stat group: ' + guildId + '/' +
              group.id);
          console.error(err);
          if (typeof cb === 'function') cb('ATTEMPT_FAILED');
          return;
        }
        if (typeof cb === 'function') {
          cb(null, meta);
        } else {
          socket.emit('statGroupMetadata', guildId, groupId, meta);
        }
      });
    });
  };

  /**
   * Respond with stats for a specific user in a group.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} guildId The guild id to look at.
   * @param {string} groupId The ID of the group.
   * @param {string} userId The ID of the user.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchStats = function fetchStats(
      userData, socket, guildId, groupId, userId, cb) {
    if (!checkPerm(userData, guildId, null, 'stats')) {
      if (!checkMyGuild(guildId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'fetchStats', guildId);
      return;
    }
    const game = hg().getHG().getGame(guildId);
    if (!game) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }
    game._stats.fetchGroup(groupId, (err, group) => {
      if (err) {
        if (typeof cb === 'function') cb('BAD_GROUP');
        return;
      }
      group.fetchUser(userId, (err, data) => {
        if (err) {
          self.error(
              'Failed to fetch user stats: ' + guildId + '@' + userId + '/' +
              group.id);
          console.error(err);
          if (typeof cb === 'function') cb('ATTEMPT_FAILED');
          return;
        }
        if (typeof cb === 'function') {
          cb(null, data.serializable);
        } else {
          socket.emit('userStats', guildId, groupId, userId, data.serializable);
        }
      });
    });
  };

  /**
   * Respond with leaderboard information.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} guildId The guild id to look at.
   * @param {string} groupId The ID of the group.
   * @param {HGStatGroupUserSelectOptions} opt Data select options.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.fetchLeaderboard = function fetchLeaderboard(
      userData, socket, guildId, groupId, opt, cb) {
    if (!checkPerm(userData, guildId, null, 'stats')) {
      if (!checkMyGuild(guildId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'fetchLeaderboard', guildId);
      return;
    }
    const game = hg().getHG().getGame(guildId);
    if (!game) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }
    game._stats.fetchGroup(groupId, (err, group) => {
      if (err) {
        if (typeof cb === 'function') cb('BAD_GROUP');
        return;
      }
      group.fetchUsers(opt, (err, rows) => {
        if (err) {
          self.error(
              'Failed to fetch leaderboard: ' + guildId + '/' + group.id);
          console.error(err);
          if (typeof cb === 'function') cb('ATTEMPT_FAILED');
          return;
        }
        const serializable = rows.map((el) => {
          const out = {id: el.id};
          Object.assign(out, el.serializable);
          return out;
        });
        if (typeof cb === 'function') {
          cb(null, serializable);
        } else {
          socket.emit('userStats', guildId, groupId, opt, serializable);
        }
      });
    });
  };
  /**
   * Give or take weapons from a player.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {string} uId The ID of the user to give or take weapon from.
   * @param {string} weapon The ID of the weapon to give or take.
   * @param {number} count Number of weapons to give or take. Positive is give,
   * negative is take.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.modifyInventory = function modifyInventory(
      userData, socket, gId, uId, weapon, count, cb) {
    if (!checkPerm(userData, gId, null, 'give') ||
        !checkPerm(userData, gId, null, 'take')) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'modifyInventory', gId);
      return;
    }
    const game = hg().getHG().getGame(gId);
    if (!game) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }
    game.modifyPlayerWeapon(
        uId, weapon, hg().getHG(), count, false, (err, ...params) => {
          const res = hg().getString(err, gId, ...params);
          if (typeof cb === 'function') {
            cb(null, res, game.serializable);
          } else {
            socket.emit('message', res);
          }
        });
  };
  /**
   * Set the currently selected stat group.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {string} guildId The guild id to look at.
   * @param {?string} groupId The ID of the group to select, or null to set
   * none.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.selectStatGroup = function selectStatGroup(
      userData, socket, guildId, groupId, cb) {
    if (!checkPerm(userData, guildId, null, 'group select')) {
      if (!checkMyGuild(guildId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'selectStatGroup', guildId);
      return;
    }
    const game = hg().getHG().getGame(guildId);
    if (!game) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }
    if (!groupId || groupId.length == 0) {
      game.statGroup = null;
      if (typeof cb === 'function') cb(null);
      return;
    } else if (typeof groupId !== 'string') {
      cb('BAD_GROUP');
      return;
    }

    game._stats.fetchGroup(groupId, (err, group) => {
      if (err) {
        cb('BAD_GROUP');
        return;
      }
      game.statGroup = group.id;
      if (typeof cb === 'function') cb(null);
    });
  };
  /**
   * Create a stat group.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {string} guildId The guild id to look at.
   * @param {?string} name The name of the new group.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.createStatGroup = function createStatGroup(
      userData, socket, guildId, name, cb) {
    if (!checkPerm(userData, guildId, null, 'group create')) {
      if (!checkMyGuild(guildId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'createStatGroup', guildId);
      return;
    }
    const game = hg().getHG().getGame(guildId);
    if (!game) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }
    if (name && typeof name === 'string') {
      if (name.length === 0 || name.length > 24) {
        cb('BAD_NAME');
        return;
      }
    } else if (name) {
      cb('BAD_NAME');
      return;
    }

    game._stats.createGroup({name: name}, (group) => {
      group.fetchMetadata((err, meta) => {
        if (err) {
          cb('ATTEMPT_FAILED');
          return;
        }
        if (typeof cb === 'function') cb(null, group.id, meta);
      });
    });
  };
  /**
   * Delete a stat group.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {string} guildId The guild id to look at.
   * @param {string} groupId The group id to delete.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed.
   */
  this.deleteStatGroup = function deleteStatGroup(
      userData, socket, guildId, groupId, cb) {
    if (!checkPerm(userData, guildId, null, 'group delete')) {
      if (!checkMyGuild(guildId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'deleteStatGroup', guildId);
      return;
    }
    const game = hg().getHG().getGame(guildId);
    if (!game) {
      if (typeof cb === 'function') cb('NO_GAME_IN_GUILD');
      return;
    }

    game._stats.fetchGroup(groupId, (err, group) => {
      if (err) {
        cb('BAD_GROUP');
        return;
      }
      if (game.statGroup === group.id) {
        game.statGroup = null;
      }
      group.reset();
      if (typeof cb === 'function') cb(null);
    });
  };
  /**
   * Handle receiving image data for avatar uploading.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {string} iId The image ID that is being uploaded.
   * @param {string} chunkId Id of the chunk being received.
   * @param {?Buffer} chunk Chunk of data received, or null if all data has been
   * sent.
   * @param {Function} [cb] Callback that fires once the requested action is
   * complete, or has failed.
   */
  this.imageChunk = function imageChunk(
      userData, socket, gId, iId, chunkId, chunk, cb) {
    const meta = imageBuffer[iId];
    if (!meta) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'imageChunk', gId);
      return;
    }
    if (meta.uId != userData.id) {
      if (!checkMyGuild(gId)) return;
      if (typeof cb === 'function') cb('NO_PERM');
      replyNoPerm(socket, 'imageChunk', gId);
      return;
    }
    if (meta.type == 'NPC') {
      if (!checkPerm(userData, gId, null, 'ai create')) {
        if (!checkMyGuild(gId)) return;
        if (typeof cb === 'function') cb('NO_PERM');
        replyNoPerm(socket, 'imageChunk', gId);
        cancelImageUpload(iId);
        return;
      }
    } else {
      self.common.logWarning(
          'Unknown image type attempted to be uploaded: ' + meta.type,
          socket.id);
      cancelImageUpload(iId);
    }

    if (chunk) {
      chunk = Buffer.from(chunk);
      meta.receivedBytes += chunk.length;
      if (isNaN(chunkId * 1)) {
        cancelImageUpload(iId);
        if (typeof cb === 'function') cb('Malformed Data');
        return;
      } else if (meta.receivedBytes > hg().maxBytes) {
        cancelImageUpload(iId);
        if (typeof cb === 'function') cb('Data Overflow');
        return;
      }
      meta.buffer[chunkId] = chunk;
      if (typeof cb === 'function') cb(chunkId);
      return;
    }

    if (meta.type == 'NPC') {
      const npcId = hg().NPC.createID();
      const p = hg().NPC.saveAvatar(Buffer.concat(meta.buffer), npcId);
      if (!p) {
        cancelImageUpload(iId);
        if (typeof cb === 'function') cb('Malformed Data');
        return;
      }
      p.then((url) => {
        const error = hg().createNPC(gId, meta.username, url, npcId);
        const game = hg().getHG().getGame(gId);
        cancelImageUpload(iId);
        if (typeof cb === 'function') {
          cb(error, game && game.serializable);
        } else if (error) {
          socket.emit('message', error);
        }
        self.common.logDebug(
            'NPC Created from upload with URL: ' + url, socket.id);
      }).catch(() => {
        cancelImageUpload(iId);
        if (typeof cb === 'function') cb('Malformed Data');
      });
    } else {
      self.common.logWarning(
          'Unknown upload type completed. Data is being deleted. (' +
              meta.type + ')',
          socket.id);
      if (typeof cb === 'function') cb();
      cancelImageUpload(iId);
    }
  };
  /**
   * Handle client requesting to begin image upload.
   *
   * @public
   * @type {HGWeb~SocketFunction}
   * @param {object} userData The current user's session data.
   * @param {socketIo~Socket} socket The socket connection to reply on.
   * @param {number|string} gId The guild id to look at.
   * @param {object} meta Metadata to associate with this upload.
   * @param {HGWeb~basicCB} [cb] Callback that fires once the requested action
   * is complete, or has failed. If succeeded, an upload ID will be passed as
   * the second parameter. Any error will be the first parameter.
   */
  this.imageInfo = function imageInfo(userData, socket, gId, meta, cb) {
    if (!meta || typeof meta.type !== 'string' ||
        isNaN(meta.contentLength * 1)) {
      if (typeof cb === 'function') cb('Malformed Data');
      return;
    }
    if (meta.type === 'NPC') {
      if (meta.contentLength > hg().maxBytes) {
        if (typeof cb === 'function') cb('Excessive Payload');
        return;
      }
      if (typeof meta.username !== 'string') {
        if (typeof cb === 'function') cb('Malformed Data');
        return;
      }
      meta.username = hg().formatUsername(meta.username);
      if (meta.username.length < 2) {
        if (typeof cb === 'function') cb('Malformed Data');
        return;
      }

      if (!checkPerm(userData, gId, null, 'ai create')) {
        if (!checkMyGuild(gId)) return;
        if (typeof cb === 'function') cb('NO_PERM');
        replyNoPerm(socket, 'imageInfo', gId);
        return;
      }

      const buf = beginImageUpload(userData.id);
      buf.username = meta.username;
      buf.type = meta.type;
      if (typeof cb === 'function') cb(null, buf.id);
    } else {
      if (typeof cb === 'function') cb('NO_PERM');
    }
  };
}

module.exports = new HGWeb();