Source: ticTacToe.js

// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
require('./subModule.js').extend(TicTacToe);  // Extends the SubModule class.

/**
 * @classdesc Manages a tic-tac-toe game.
 * @class
 * @augments SubModule
 * @listens Command#ticTacToe
 */
function TicTacToe() {
  const self = this;
  /** @inheritdoc */
  this.myName = 'TicTacToe';

  /** @inheritdoc */
  this.initialize = function() {
    self.command.on('tictactoe', commandTicTacToe);
  };

  /** @inheritdoc */
  this.shutdown = function() {
    self.command.deleteEvent('tictactoe');
  };

  /** @inheritdoc */
  this.unloadable = function() {
    return numGames === 0;
  };

  /**
   * Maximum amount of time to wait for reactions to a message. Also becomes
   * maximum amount of time a game will run with no input, because controls will
   * be disabled after this timeout.
   *
   * @private
   * @constant
   * @type {number}
   * @default 5 Minutes
   */
  const maxReactAwaitTime = 5 * 1000 * 60; // 5 Minutes

  /**
   * Helper object of emoji characters mapped to names.
   *
   * @private
   * @type {object.<string>}
   * @constant
   * @default
   */
  const emoji = {
    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',
    X: '❌',
    O: '⭕',
  };

  /**
   * The number of currently active games. Used to determine of submodule is
   * unloadable.
   *
   * @private
   * @type {number}
   * @default
   */
  let numGames = 0;

  /**
   * Starts a tic tac toe game. If someone is mentioned it will start a game
   * between the message author and the mentioned person. Otherwise, waits for
   * someone to play.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#ticTacToe
   */
  function commandTicTacToe(msg) {
    const players = {p1: msg.author, p2: null};
    if (msg.mentions.users.size > 0) {
      players.p2 = msg.mentions.users.first();
    }
    self.createGame(players, msg.channel);
  }

  /**
   * Class that stores the current state of a tic tac toe game.
   *
   * @class
   *
   * @public
   * @param {{p1: Discord~User, p2: Discord~User}} players The players in this
   * game.
   * @param {Discord~Message} msg The message displaying the current game.
   */
  this.Game = function(players, msg) {
    const game = this;
    /**
     * The players in this game.
     *
     * @type {{p1: Discord~User, p2: Discord~User}}
     */
    this.players = players;
    /**
     * An array of 9 elements that stores 0, 1, or 2 to signify who owns which
     * space of the board. 0 is nobody, 1 is player 1, 2 is player 2.
     *
     * @type {number[]}
     */
    this.board = [0, 0, 0, 0, 0, 0, 0, 0, 0];
    /**
     * Which player's turn it is. Either 1 or 2.
     *
     * @type {number}
     */
    this.turn = 1;
    /**
     * The message displaying the current game.
     *
     * @type {Discord~Message}
     */
    this.msg = msg;

    /**
     * The template string for the game's board.
     *
     * @private
     * @type {string}
     * @constant
     * @default
     */
    const boardString = '```css\n   |   |   \n{0}|{1}|{2}\n___|___|___\n' +
        '   |   |   \n{3}|{4}|{5}\n___|___|___\n' +
        '   |   |   \n{6}|{7}|{8}\n   |   |   \n```';

    /**
     * Edit the current message to show the current board.
     *
     * @param {number} [winner=0] The player who has won the game. 0 is game not
     * done, 1 is player 1, 2 is player 2, 3 is draw.
     */
    this.print = function(winner = 0) {
      const embed = new self.Discord.EmbedBuilder();
      const names = ['Nobody', 'Nobody'];
      let gameFull = true;
      if (this.players.p1) {
        names[0] = this.players.p1.username;
      } else {
        gameFull = false;
      }
      if (this.players.p2) {
        names[1] = this.players.p2.username;
      } else {
        gameFull = false;
      }
      embed.setTitle(names[0] + ' vs ' + names[1]);
      if (!gameFull) {
        embed.setDescription('To join the game, just make a move!');
      }

      const finalBoard = boardString.replace(/\{(.)\}/g, function(match, num) {
        switch (game.board[num]) {
          case 1:
            if (winner > 0 && winner != 1) return ' x ';
            return ' X ';
          case 2:
            if (winner > 0 && winner != 2) return ' o ';
            return ' O ';
          default:
            if (winner > 0) return '   ';
            return ' ' + num + ' ';
        }
      });

      embed.addFields([{name: '\u200B', value: finalBoard}]);
      if (winner == 0) {
        embed.addFields([{
          name: names[this.turn - 1] + '\'s turn (' +
              (this.turn == 1 ? 'X' : 'O') + ')',
          value: '`' + names[0] + '` is X\n`' + names[1] + '` is O',
        }]);
      } else {
        numGames--;
        embed.addFields([{
          name: '\u200B',
          value: '`' + names[0] + '` was X\n`' + names[1] + '` was O',
        }]);
      }

      if (winner == 3) {
        embed.addFields([{name: 'Draw game!', value: 'Nobody wins'}]);
      } else if (winner == 2) {
        embed.addFields([{
          name: names[1] + ' Won! ' + emoji.O,
          value: names[0] + ', try harder next time.',
        }]);
      } else if (winner == 1) {
        embed.addFields([{
          name: names[0] + ' Won! ' + emoji.X,
          value: names[1] + ', try harder next time.',
        }]);
      }
      msg.edit({content: '\u200B', embeds: [embed]});
    };
  };

  /**
   * Create a game with the given players in a given text channel.
   *
   * @public
   * @param {{p1: Discord~User, p2: Discord~User}} players The players in the
   * game.
   * @param {Discord~TextChannel} channel The text channel to send messages.
   */
  this.createGame = function(players, channel) {
    numGames++;
    channel.send({content: '`Loading TicTacToe...`'}).then((msg) => {
      const game = new self.Game(players, msg);
      game.print();
      addReactions(msg);
      addListener(msg, game);
    });
  };

  /**
   * Add the reactions to a message for controls of the game. Recursive.
   *
   * @private
   * @param {Discord~Message} msg The message to add the reactions to.
   * @param {number} index The number of reactions we have added so far.
   */
  function addReactions(msg, index = 0) {
    msg.react(emoji[index]).then(() => {
      if (index < 8) addReactions(msg, index + 1);
    });
  }

  /**
   * Add the listener for reactions to the game.
   *
   * @private
   * @param {Discord~Message} msg The message to add the reactions to.
   * @param {TicTacToe~Game} game The game to update when changes are made.
   */
  function addListener(msg, game) {
    const filter = (reaction, user) => {
      if (user.id != self.client.user.id) {
        // reaction.users.remove(user).catch(() => {});
      } else {
        return false;
      }

      if (game.turn == 1 && game.players.p1 &&
             user.id != game.players.p1.id) {
        return false;
      }
      if (game.turn == 2 && game.players.p2 &&
             user.id != game.players.p2.id) {
        return false;
      }
      for (let i = 0; i < 9; i++) {
        if (emoji[i] == reaction.emoji.name) return true;
      }
      return false;
    };
    msg.awaitReactions({filter, max: 1, time: maxReactAwaitTime})
        .then((reactions) => {
          if (reactions.size == 0) {
            msg.reactions.removeAll().catch(() => {});
            msg.edit({
              content:
                  'Game timed out!\nThe game has ended because nobody made a ' +
                  'move in too long!',
            });
            game.print(game.turn == 1 ? 2 : 1);
            return;
          }
          if (!game.players.p1 && game.turn == 1) {
            game.players.p1 = reactions.first().users.cache.first(2)[1];
          }
          if (!game.players.p2 && game.turn == 2) {
            game.players.p2 = reactions.first().users.cache.first(2)[1];
          }
          // reactions.first().users.remove(self.client.user).catch(() => {});

          let move = -1;
          const choice = reactions.first().emoji;
          for (let i = 0; i < 9; i++) {
            if (emoji[i] == choice.name && game.board[i] === 0) {
              move = i;
              break;
            }
          }
          if (move == -1) {
            addListener(msg, game);
            return;
          }
          game.board[move] = game.turn;
          const winner = checkWin(game.board, move);
          if (winner != 0) {
            msg.reactions.removeAll().catch(() => {});
          } else {
            game.turn = game.turn === 1 ? 2 : 1;
            addListener(msg, game);
          }
          game.print(winner);
        });
  }
  /**
   * Checks if the given board has a winner, or if the game is over.
   *
   * @param {number[]} board Array of 9 numbers defining a board. 0 is nobody, 1
   * is player 1, 2 is player 2.
   * @param {number} latest The index where the latest move occurred.
   * @returns {number} Returns 0 if game is not over, 1 if player 1 won, 2 if
   * player 2 won, 3 if draw.
   */
  function checkWin(board, latest) {
    const player = board[latest];
    // Column
    const col = latest % 3;
    for (let i = 0; i < 3; i++) {
      if (board[i * 3 + col] != player) break;
      if (i == 2) return player;
    }
    // Row
    const row = Math.floor(latest / 3);
    for (let i = 0; i < 3; i++) {
      if (board[i + row * 3] != player) break;
      if (i == 2) return player;
    }
    // Diagonals
    switch (latest) {
      case 0:
      case 4:
      case 8:
        if (board[0] == board[4] && board[4] == board[8]) return player;
        break;
      default:
        break;
    }
    switch (latest) {
      case 2:
      case 4:
      case 6:
        if (board[2] == board[4] && board[4] == board[6]) return player;
        break;
      default:
        break;
    }
    // Is board full
    for (let i = 0; i < 9; i++) {
      if (board[i] == 0) return 0;
    }
    return 3;
  }
}

module.exports = new TicTacToe();