games/_Game/classes/Game.js

import options from '../../../config/options.js'
import Discord from '../../../discord_mod.js'

/**
 * The base class for all games, see {@tutorial getting_started} to get started.
 * @abstract
 */
export default class Game {
    /**
     * An object with configurable game settings. This field is currently unused.
     * @typedef GameSettings
     * @type {Object}
     */

    /**
     * Creates a new game.
     * @param {Discord.Message} msg   The message object.
     * @param {GameSettings} settings An optional object with custom settings for the game
     */
    constructor(msg, settings) {
        /**
         * The metadata for a given game.
         * @typedef GameMetadata
         * @type {Object}
         * @property {String} id              The game's unique identifier.
         * @property {String} name            The name of the game.
         * @property {String} about           A short description about the game.
         * @property {String} rules           This game's rules and instructions.
         * @property {Boolean} unlockables    Whether or not the game has unlockables in the shop.
         * @property {Object} playerCount     The number of players that can join this game.
         * @property {number} playerCount.min The minimum required number of players.
         * @property {number} playerCount.max The maximum required number of players.
         */

        /**
         * The metadata from the game, typically read from a metadata.js file.
         * @type {GameMetadata}
         * @example
         * import metadata from './metadata.js'
         * @static
         */
        this.metadata = {
            id: 'game',
            name: 'Game',
            about: 'About this game.',
            rules: 'Rules for this game.',
            playerCount: {
                min: 1,
                max: 20
            }
        }
        
        /**
         * The Discord message that initialized this game.
         * @type {Discord.Message}
         * @see {@link https://discord.js.org/#/docs/main/11.5.1/class/Message|Discord.Message}
         */
        this.msg = msg

        /**
         * The Discord channel that this game is played in.
         * @type {Discord.TextChannel}
         * @see {@link https://discord.js.org/#/docs/main/11.5.1/class/TextChannel|Discord.TextChannel}
         */
        this.channel = msg.channel

        /**
         * The Discord client that this game belongs to.
         * @type {Discord.Client}
         * @see {@link https://discord.js.org/#/docs/main/11.5.1/class/Client|Discord.Client}
         */
        this.client = msg.client

        /**
         * The Discord user  who initialized this game.
         * @type {Discord.User}
         * @see {@link https://discord.js.org/#/docs/main/11.5.1/class/User|Discord.User}
         */
        this.gameMaster = msg.author

        /**
         * The collection  of players who are added to this game during the join phase.
         * @type {Discord.Collection}
         * @see {@link https://discord.js.org/#/docs/main/11.5.1/class/Collection|Discord.Collection}
         */
        this.players = new Discord.Collection()

        /**
         * An array of players that are queued to be added, populated using the `this.addPlayer()` command
         * @type {Array}
         */
        this.playersToAdd = []

        /**
         * An array of players that are queued to be added, populated using the `this.removePlayer()` command
         * @type {Array}
         * 
         */
        this.playersToKick = []

        /**
         * Helper field that is only true when `this.forceStop()` is called. This should be used to prevent the game from continuing when unexpectedly ended.
         * @type {Boolean}
         * @example
         * const collector = this.channel.createMessageCollector(filter, options)
         * collector.on('message', message => {
         *     if(this.ending) return
         *     // ...
         * })
         * @deprecated use this.stage = 'ending' instead
         */
        this.ending = false

        /**
         * The game's minimum and maximum player count. Use `this.metadata` instead.
         * @type {Object}
         * @property {Number} min The minimum player count for this game.
         * @property {Number} max The maximum player count for this game.
         * @deprecated
         */
        this.playerCount = {
            min: this.metadata.playerCount.min,
            max: this.metadata.playerCount.max
        }

        /**
         * A list of Discord collectors that is cleared when `this.clearCollectors(this.collectors)` is called. Whenever a `MessageCollector` or `ReactionCollector` is created, push it to `this.collectors()`.
         * @type {Array.<Discord.Collector>}
         * @see {@link https://discord.js.org/#/docs/main/11.5.1/class/Collector|Discord.Collector}
         * @deprecated
         */
        this.collectors = []

        /**
         * The current stage of the game. Default stages include 'join' and 'init'.
         * @type {String}
         */
        this.stage = 'init'

        /**
         * Game-specific settings that are configurable on a class level.
         * @static
         */
        this.settings = {
            isDmNeeded: false,
            updatePlayersAnytime: true,
            defaultUpdatePlayerMessage: `✅ The player list will be updated at the start of the next round.`
        }

        /**
         * A user-configurable option for a game, modified during a game's init stage.
         * @typedef GameOption
         * @type {Object}
         * @property {String} friendlyName The user-friendly name of this option.
         * @property {String} type The type of option. Must be one of:
         * * `checkboxes`
         * * `radio`
         * * `free`
         * * `number`
         * @property {String|Number|Array.<String|Number>} default The default value for this option.
         * @property {Array.<String>} [choices] An array of choices the user can pick from. Required for checkboxes and radio types.
         * @property {String} [note] A note displayed during the selection screen
         * @property {Function(Discord.Message)} [filter] A filter function that validates the user's input message, and only accepts the input if the function returns true.
         * @see {@link https://discord.js.org/#/docs/main/11.5.1/class/Message|Discord.Message}
         */

        /**
         * The list of user-configurable options for this game. The configured options will be accessible from `this.options[friendlyName]` after the init stage.
         * @type {Array.<GameOption>}
         * @example
         * this.gameOptions = [
         *     {
         *         friendlyName: 'Categories',
         *         type: 'checkboxes'
         *         default: ['Game of Thrones']
         *         choices: ['Game of Thrones', 'Narnia', 'Lord of the Rings', 'Harry Potter'],
         *         note: 'Choose the categories for this game.'
         *     },
         *     {
         *         friendlyName: 'Game Mode',
         *         type: 'radio',
         *         default: 'Solo',
         *         choices: ['Team', 'Solo'],
         *         note: 'In team, players are matched up against each other in groups. In solo, it\'s everyone for themself!'
         *     },
         *     {
         *         friendlyName: 'Timer',
         *         type: 'number',
         *         default: 60,
         *         note: 'Enter a new value in seconds, between 30-60.',
         *         filter: m => parseInt(m.content) >= 30 && parseInt(m.content) <= 60
         *     },
         *     {
         *         friendlyName: 'Clan Tag',
         *         type: 'free',
         *         default: 'none',
         *         note: 'Enter a new 3-letter clan tag, or type \'none\' for no name.',
         *         filter: m => m.content.length == 3 || m.content == 'none'
         *     }
         * ]
         */
        this.gameOptions = []
    }

    /**
     * @returns {Discord.User} The game leader.
     * @see {@link https://discord.js.org/#/docs/main/11.5.1/class/User|Discord.User}
     */
    get leader () { return this.gameMaster }

    /**
     * Begins a new game. This will be called by the play command.
     */
    async init() {
        this.stage = 'init'

        this.client.logger.log('Game started', {
            game: this.metadata.game,
            id: this.metadata.id
        })

        // Check if downtime is going to start
        // Refresh downtime
        let timeToDowntime = await this.msg.client.getTimeToDowntime()
        let downtime = Math.ceil(timeToDowntime / 60000)
        if(timeToDowntime > 0 && timeToDowntime <= 5 * 60000) {
            const downtime = Math.round(timeToDowntime / 60000)
            this.msg.channel.sendMsgEmbed(`Gamebot is going to be temporarily offline for maintenance in ${downtime} minute${downtime == 1 ? '': 's'}. Games cannot be started right now. For more information, [see our support server.](${options.serverInvite}?ref=downtimeError)`, 'Error!', options.colors.error)
            this.forceStop()
            return
        } else if(timeToDowntime > 0) {
            this.msg.channel.sendMsgEmbed(`Gamebot is going to be temporarily offline for maintenance in ${downtime} minute${downtime == 1 ? '': 's'}. Any active games will be automatically ended. For more information, [see our support server.](${options.serverInvite}?ref=downtimeWarning)`, 'Warning!', options.colors.warning)
        }

        this.stage = 'join'
        await this.join()

        // Generate the game-specific option lists
        await this.generateOptions()
        
        if(this.gameOptions) {
            this.stage = 'options'
            // Allow game leader to configure options. Configured options will be outputted to this.options
            await this.configureOptions()
        }

        // Prevent players from joining and leaving freely during the game
        this.settings.updatePlayersAnytime = false

        // Initialize specific game
        this.stage = 'gameinit'
        await this.gameInit()

        // Begin playing the game
        this.stage = 'play'
        await this.play()
    }

    /**
     * Begins the join phase of the game.
     * @param {Function} callback The callback that is executed when the players are collected.
     */
    join() {
        return new Promise(async (resolve, reject) => {
            // allow players to join
            await this.msg.channel.send({
                embed: {
                    title: `${this.msg.author.tag} is starting a ${this.metadata.name} game!`,
                    description: `Type \`${this.channel.prefix}join\` to join in the next **120 seconds**.\n\n${this.leader}, type \`${this.channel.prefix}start\` to begin once everyone has joined.`,
                    color: options.colors.info
                }
            })

            // Add gameMaster
            await this.addPlayer(this.gameMaster.id, null)
            this.updatePlayers()

            const filter = m => (m.content.startsWith(`${this.channel.prefix}join`) && !this.players.has(m.author.id)) || (m.author.id == this.gameMaster.id && m.content.startsWith(`${this.channel.prefix}start`))
            const collector = this.channel.createMessageCollector(filter, { time: 120000 })
            this.collectors.push(collector)
            collector.on('collect', async m => {
                if(this.ending) return
                if(m.content.startsWith(`${this.channel.prefix}start`)) {
                    collector.stop()
                    return
                }
                await this.addPlayer(m.author.id, null)
                this.updatePlayers()
                m.delete()
            })

            collector.on('end', collected => {
                if(this.ending) return
                // check if there are enough players
                let size = this.players.size
                if(this.playersToAdd)  size += this.playersToAdd.length
                if(this.playersToKick) size -= this.playersToKick.length
                if(size >= this.metadata.playerCount.min) {
                    let players = []
                    this.players.forEach(player => { players.push(player.user) })
                    this.msg.channel.sendMsgEmbed(`${players.join(", ")} joined the game!`, 'Time\'s up!')
                } else {
                    this.msg.channel.sendMsgEmbed(`Not enough players joined the game!`, 'Time\'s up!')
                    this.forceStop()
                    return
                }
                // continue playing
                resolve(true)
            })
        })
    }

    /**
     * Generates option lists, each game implements this method in its own way.
     * @abstract
     */
    async generateOptions() {
        return
    }

    /**
     * Display the choices of a game's option.
     * @param {GameOption} option The option to render into text.
     */
    renderOptionInfo (option) {
        let response = ''
        if(option.type == 'checkboxes') {
            option.choices.forEach((choice, index) => {
                response += `${option.value.includes(choice) ? '**✓**' : '☐'} ${index + 1}: ${choice}\n`
            })
        } else if (option.type == 'radio') {
            option.choices.forEach((choice, index) => {
                response += `${option.value == choice ? '🔘' : '⚪️'} ${index + 1}: ${choice}\n`
            })
        } else if (option.type == 'free') {
            response = `**Current value:** ${option.value}`
        } else {
            response = `**Current value:** ${option.value}`
        }
        response += option.note ? '\n\n' + option.note : ''
        return response
    }

    /**
     * An option that has been configured by the game leader and has a set value.
     * @typedef ConfiguredOption
     * @type {String|Number|Array.<String|Number>}
     */

    /**
     * Allow the game leader to set options, and outputs the options to `this.options`.
     * @returns {Object<String,ConfiguredOption>} The configured options, with their key as the option friendly name, and value as the configured value.
     */
    async configureOptions () {
        let isConfigured = false
        let optionMessage
        for(let i = 0; i < this.gameOptions.length; i++) {
            // configure options
            let option = this.gameOptions[i]
            Object.defineProperty(option, 'value', {
                value: option.default,
                enumerable: true,
                writable: true
            })
        }

        await this.channel.send('Loading options...').then(m => optionMessage = m)
        do {
            // Display options
            let optionsDisplay = ''
            for(let i = 0; i < this.gameOptions.length; i++) {
                // write to options display
                let option = this.gameOptions[i]
                optionsDisplay += `**${i + 1}.** ${option.friendlyName}: ${typeof option.value == 'object' ? option.value.join(', ') : option.value }\n`
            }
            optionsDisplay += `\nType \`${this.channel.prefix}start\` to start the game.`
            optionMessage.edit({
                embed: {
                    title: 'Configure Settings',
                    description: optionsDisplay,
                    color: options.colors.info,
                    footer: {
                        text: 'Type an option\'s number to edit the value.'
                    }
                }
            })

            const filter = m => m.author.id == this.gameMaster.id && ((!isNaN(m.content) && parseInt(m.content) <= this.gameOptions.length && parseInt(m.content) > 0) || m.content.toLowerCase() == this.channel.prefix + 'start')
            await this.channel.awaitMessages(filter, {
                time: 60000,
                max: 1,
                errors: ['time'],
            }).then(async collected => {
                if(this.ending) return
                const message = collected.first()
                message.delete()
                if(message.content == `${this.channel.prefix}start`) {
                    optionMessage.edit({
                        embed: {
                            title: 'Options saved!',
                            description: 'The game is starting...',
                            color: options.colors.info
                        }
                    })
                    isConfigured = true
                    return
                } else {
                    let option = this.gameOptions[parseInt(message.content) - 1]
                    const optionData = {
                        'checkboxes': {
                            footer: `Type multiple numbers (separated by a space) to select and deselect items.`,
                            filter: m => m.author.id == this.gameMaster.id
                        },
                        'free': {
                            footer: 'Enter a new value.',
                            filter:  m => m.author.id == this.gameMaster.id
                        },
                        'number': {
                            footer: 'Enter a new value.',
                            filter:  m => m.author.id == this.gameMaster.id && !isNaN(m.content)
                        },
                        'radio': {
                            footer: `Type the option's number to select a new option.`,
                            filter: m => m.author.id == this.gameMaster.id && !isNaN(m.content) && parseInt(m.content) <= option.choices.length && parseInt(m.content) > 0
                        }
                    }

                    // send option info
                    await optionMessage.edit({
                        embed: {
                            title: `Edit option: ${option.friendlyName}`,
                            description: this.renderOptionInfo(option),
                            color: options.colors.info,
                            footer: {
                                text: optionData[option.type].footer
                            }

                        }
                    }).catch(err => {
                        console.error(err)
                        this.channel.sendMsgEmbed(`Something went wrong when editing the message. Type \`${this.channel.prefix}start\` to begin the game.`, 'Error!', options.colors.error).delete(5000)
                    })

                    // await a response for the option
                    await this.channel.awaitMessages(m => (optionData[option.type].filter)(m), {
                        time: 60000, max: 1
                    }).then(collected => {
                        if(this.ending) return
                        const message = collected.first()
                        message.delete()
                        
                        // add custom filter options
                        if(option.filter) {
                            if(!option.filter(message)) {
                                this.msg.channel.sendMsgEmbed('You have entered an invalid value. Please read the instructions and try again.', 'Error!', options.colors.error).then(m => m.delete(2000))
                                return
                            }
                        }

                        // edit checkboxes
                        if(option.type == 'checkboxes') {
                            const numbers = message.content.split(/\ /g)
                            numbers.forEach(number => {
                                const index = parseInt(number) - 1
                                const choice = option.choices[index]
                                if(!choice) return
                                // remove what is already there
                                if(option.value.includes(choice)) {
                                    option.value.splice(option.value.indexOf(choice), 1)
                                }
                                // add what isn't there
                                else {
                                    option.value.push(choice)
                                }
                            })
                        }
                        // edit free response
                        else if(option.type == 'free') {
                            option.value = message.content
                        }
                        // edit radio
                        else if (option.type == 'radio') {
                            const index = parseInt(message.content) - 1
                            const newChoice = option.choices[index]
                            if(!newChoice) {
                                this.msg.channel.sendMsgEmbed('You entered an invalid value.', 'Error!', options.colors.error).then(m => m.delete(2000))
                            } else {
                                option.value = newChoice
                            }
                        }
                    })
                    .catch(err => {
                        this.channel.sendMsgEmbed(`Please select an option! Type \`${this.channel.prefix}start\` to start the game.`, 'Error!', options.colors.error)
                    })
                    // on timeout restart this
                }
            }).catch(err => {
                // time has run out
                if(err.size === 0) {
                    optionMessage.edit({
                        embed: {
                            title: 'Time has run out!',
                            description: 'The game is starting...',
                            color: options.colors.error
                        }
                    })
                    isConfigured = true
                } else {
                    console.error(err)
                    optionMessage.edit({
                        embed: {
                            title: 'Error!',
                            description: `An unknown error occurred when loading into the game. The game is now starting. Please report this to Gamebot support in our [support server](${options.serverInvite}?ref=gameLoadInError).`,
                            color: options.colors.error
                        }
                    })
                }
            })
        } while(!isConfigured)
        // add to options
        this.options = {}
        this.gameOptions.forEach(option => {
            this.options[option.friendlyName] = option.value 
        })
        return this.options
    }

    /**
     * Sets or changes the game leader during the game.
     * @param {Discord.Member|string} member The member or id of the member to add
     * @param {string} message The message to send if the user is successfully queued. Set to "null" for no message to be sent.
     */
    async setLeader(member) {
        if(typeof member == 'string') {
            member = await this.msg.guild.members.fetch(member).catch(async () => {
                await this.msg.channel.sendMsgEmbed('Invalid user.', 'Error!', 13632027).catch(console.error)
                return false
            })
            if(member === false) return
        }

        if(!member || this.leader.id == member.id || !this.players.has(member.id)) {
            await this.msg.channel.sendMsgEmbed('Invalid user.', 'Error!', 13632027).catch(console.error)
            return
        }

        this.gameMaster = this.players.get(member.id)

        if(message !== null) {
            await this.channel.sendMsgEmbed(message || this.settings.defaultUpdatePlayerMessage).catch(console.error)
        }

    }

    /**
     * Add a player to the queue to be added to the game.
     * @param {Discord.Member|string} member The member or id of the member to add
     * @param {string} message The message to send if the user is successfully queued. Set to "null" for no message to be sent.
     */
    async addPlayer(member, message) {
        if(typeof member === 'string') {
            member = await this.msg.guild.members.fetch(member).catch(async err => {
                console.error(err)
                await this.channel.sendMsgEmbed('Invalid user.', 'Error!', 13632027).catch(console.error)
                return false
            })
            if(member === false) return
        }

        if(!member || this.players.has(member.id) || (member.bot && !this.client.isTestingMode)) {
            await this.msg.channel.sendMsgEmbed('Invalid user.', 'Error!', 13632027).catch(console.error)
            return
        }

        let futureSize = this.players.size + this.playersToAdd.length - this.playersToKick.length
        if(futureSize >= this.metadata.playerCount.max) {
            this.channel.sendMsgEmbed(`The game can't have more than ${this.metadata.playerCount.min} player${this.metadata.playerCount.min == 1 ? '' : 's'}! ${member.user} could not be added.`).catch(console.error)
            return
        }

        if(message !== null) {
            await this.channel.sendMsgEmbed(message || this.settings.defaultUpdatePlayerMessage).catch(console.error)
        }
        
        this.playersToAdd.push(member)
    }

    /**
     * Add a player to the queue to be removed from the game.
     * @param {string|Discord.Member} member The member or member id to kick.
     * @param {string} message The message to send if the user is successfully queued. Set to "null" for no message to be sent.
     */
    async removePlayer(member, message) {
        if(typeof member == 'string') {
            member = await this.msg.guild.members.fetch(member).catch(async () => {
                await this.channel.sendMsgEmbed('Invalid user.', 'Error!', options.colors.error).catch(console.error)
                return false
            })
            if(member === false) return
        }

        if(this.leader.id == member.id) {
            await this.channel.send({
                embed: {
                    title: 'Error!',
                    description: 'The leader cannot leave the game.',
                    color: options.colors.error
                }
            }).catch(console.error)
        }
        
        if(!this.players.has(member.id) || !member || (member.bot && !this.client.isTestingMode)) {
            await this.channel.sendMsgEmbed('Invalid user.', 'Error!', options.colors.error).catch(console.error)
            return
        }

        let futureSize = this.players.size + this.playersToAdd.length - this.playersToKick.length
        if(futureSize <= this.metadata.playerCount.min) {
            this.channel.sendMsgEmbed(`The game can't have fewer than ${this.metadata.playerCount.min} player${this.metadata.playerCount.min == 1 ? '' : 's'}! ${member.user} could not be removed.`).catch(console.error)
            return
        }

        if(message !== null) {
            await this.channel.sendMsgEmbed(message || this.settings.defaultUpdatePlayerMessage).catch(console.error)
        }
        
        this.playersToKick.push(member)
    }

    /**
     * Update players who are queued to join or leave the game.
     */
    updatePlayers() {
        this.playersToKick.forEach(member => {
            this.msg.channel.sendMsgEmbed(`${member.user} was removed from the game!`).catch(console.error)
            this.players.delete(member.id)
        })
        this.playersToKick = []

        this.playersToAdd.forEach(member => {
            member.user.createDM().then(dmChannel => {
                return new Promise(resolve => {
                    let player = { user: member.user, dmChannel }
                    // Construct default player object
                    for(let key in this.defaultPlayer) {
                        if(this.defaultPlayer[key] == 'String') {
                            player[key] = ''
                        } else if (this.defaultPlayer[key] == 'Array') {
                            player[key] = []
                        } else if (this.defaultPlayer[key] == 'Object') {
                            player[key] = {}
                        } else {
                            player[key] = this.defaultPlayer[key]
                        }
                    }
                    this.players.set(member.id, player)
                    resolve(dmChannel)
                })
            }).then(async dmChannel => {
                if(this.settings.isDmNeeded){
                    await dmChannel.sendMsgEmbed(`You have joined a ${this.metadata.name} game in <#${this.msg.channel.id}>.`).catch(console.error)
                }
                this.msg.channel.sendMsgEmbed(`${member.user} was added to the game!`).catch(console.error)
            }).catch(err => {
                // Remove errored player
                if(this.players.has(member.id)) this.players.delete(member.id)

                // Filter out privacy errors
                if(err.message != 'Cannot send messages to this user') {
                    this.msg.channel.sendMsgEmbed(`An unknown error occurred, and ${member.user} could not be added.`, `Error: Player could not be added.`, options.colors.error).catch(console.error)
                    console.error(err)
                }

                // Notify user
                if(member.id == this.gameMaster.id) {
                    this.msg.channel.sendMsgEmbed(`You must change your privacy settings to allow direct messages from members of this server before playing this game. [See this article for more information.](https://support.discordapp.com/hc/en-us/articles/217916488-Blocking-Privacy-Settings-)`, `Error: You could not start this game.`, options.colors.error).catch(console.error)
                    this.forceStop()
                } else {
                    this.msg.channel.sendMsgEmbed(`${member.user} must change their privacy settings to allow direct messages from members of this server before playing this game. [See this article for more information.](https://support.discordapp.com/hc/en-us/articles/217916488-Blocking-Privacy-Settings-)`, `Error: Player could not be added.`, options.colors.error).catch(console.error)
                }
            })
        })
        this.playersToAdd = []
    }
    

    /**
     * The method called after user configuration. This will be custom for each game.
     * @abstract
     */
    play() {
        return
    }

    /**
     * Stop all collectors and reset collector list.
     * @param {Array.<Discord.Collector>} collectors List of collectors
     * @deprecated
     */
    async clearCollectors(collectors) {
        collectors.forEach(collector => {
            collector.stop('Force stopped.')
        })
        collectors = []
    }

    /**
     * End a game. This will be called when a player wins or the game is force stopped.
     * @param {object} winner The game winner
     * @param {string} endPhrase The message to be sent at the end of the game.
     */
    end(winner, endPhrase) {
        this.stage = 'over'

        this.client.logger.log('Game ended', {
            game: this.metadata.game,
            id: this.metadata.id,
            duration: Date.now() - this.msg.createdTimestamp,
            players: this.players.size,
        })

        if(!endPhrase) {
            if(winner) {
                endPhrase = `${winner.user} has won!`
            } else {
                endPhrase = ''
            }

            endPhrase += `\nTo play games with the community, [join our server](${options.serverInvite}?ref=gameEnd)!`
        }

        // Send a message in the game channel (this.msg.channel) that the game is over.
        this.msg.channel.sendMsgEmbed(endPhrase, 'Game over!', options.colors.economy).then(msg => {
            this.clearCollectors(this.collectors).catch(console.error)
            // Remove all event listeners created during this game.
            this.msg.channel.gamePlaying = false
            this.msg.channel.game = undefined
        }).catch(console.error)

        if(this.metadata.unlockables) {
            this.channel.send({
                embed: {
                    title: `This game contains unlockable content!`,
                    description: `Check out the [Gamebot shop for more](${process.env.BASE_URL}/shop)!`,
                    color: options.colors.economy
                }
            }).catch(console.error)
        }

        // If there's downtime and there was the last game, let the owner know that the bot is safe to restart.
        this.msg.client.getTimeToDowntime().then(async timeToDowntime => {
            if(timeToDowntime > 0) {
                let activeGames = await this.msg.client.shard.broadcastEval('this.channels.filter(c => c.game).size')
                if(activeGames.reduce((prev, val) => prev + val) == 0) {
                    let channel = this.msg.client.channels.cache.get(options.loggingChannel)
                    if(channel) channel.send(`All games finished, <@${options.ownerID}>`)
                }
            }
        }).catch(console.error)
    }

    /**
     * Force ends a game. This will be called by the end command.
     */
    forceStop() {
        this.ending = true
        this.end()
    }
}