Lichess4545/Chesster

View on GitHub
src/league.ts

Summary

Maintainability
F
4 days
Test Coverage
F
20%
// -----------------------------------------------------------------------------
// Defines a league object which can be used to interact with the website
// for the given league
// -----------------------------------------------------------------------------
import _ from 'lodash'
import winston from 'winston'
import moment from 'moment-timezone'
import * as heltour from './heltour'
import * as lichess from './lichess'
import { League as LeagueConfig } from './config'
import { EventEmitter } from 'events'
import { SlackBot, LeagueMember, localTime } from './slack'
import { LogWithPrefix } from './logging'
import { isDefined } from "./utils";

// An emitter for league events
class ChessLeagueEmitter extends EventEmitter {}

export interface Player {
    username: string
    rating: number
    team: Team
    isCaptain: boolean
    boardNumber?: number
}
export enum ResultsEnum {
    UNKNOWN = '',
    WHITE_WIN = '1-0',
    BLACK_WIN = '0-1',
    DRAW = '1/2-1/2',
    SCHEDULING_DRAW = '1/2Z-1/2Z',
    BLACK_FORFEIT_WIN = '0F-1X',
    WHITE_FORFEIT_WIN = '1/2-1/2',
}
export interface Pairing {
    white: string
    // TODO: should this be optional?
    white_team?: string
    black: string
    // TODO: should this be optional?
    black_team?: string
    scheduledDate?: moment.Moment
    url?: string
    result: ResultsEnum
}
export interface PairingDetails extends Pairing {
    color: 'white' | 'black'
    player: string
    opponent: string
    rating?: number
    league: League
}
export function isPairingDetails(
    pd: PairingDetails | undefined
): pd is PairingDetails {
    return pd !== undefined
}

export interface LeagueLinks {
    rules: string
    faq: string
    league: string
    lonewolf: string
    guide: string
    captains: string
    registration: string
    source: string
    pairings: string
    standings: string
    nominate: string
    notifications: string
    availability: string
}
export interface Team {
    name: string
    players: Player[]
    captain?: Player
    number: number
    slack_channel: string
}
type RefreshRostersCallback = () => void
type RefreshPairingsCallback = () => void

function resultFromString(result: string | undefined): ResultsEnum {
    if (result === undefined) return ResultsEnum.UNKNOWN
    result = result.toLowerCase()
    if (result === '1-0') {
        return ResultsEnum.WHITE_WIN
    } else if (result === '0-1') {
        return ResultsEnum.BLACK_WIN
    } else if (result === '1/2-1/2') {
        return ResultsEnum.DRAW
    } else if (result === '1/2z-1/2z') {
        return ResultsEnum.SCHEDULING_DRAW
    } else if (result === '0F-1X') {
        return ResultsEnum.BLACK_FORFEIT_WIN
    } else if (result === '1X-0F') {
        return ResultsEnum.WHITE_FORFEIT_WIN
    } else {
        return ResultsEnum.UNKNOWN
    }
}

export class League {
    private log: LogWithPrefix

    constructor(
        public bot: SlackBot,
        public name: string,
        public config: LeagueConfig,
        public _players: Player[] = [],
        public _pairings: Pairing[] = [],
        public _teams: Team[] = []
    ) {
        this.emitter = new ChessLeagueEmitter()
        this.log = new LogWithPrefix(`[League(${this.name})]`)
    }

    // -------------------------------------------------------------------------
    // A lookup from player username to a player object (from _players)
    // -------------------------------------------------------------------------
    _playerLookup: Record<string, Player> = {}

    // -------------------------------------------------------------------------
    // A lookup from a team name to the team object.
    // -------------------------------------------------------------------------
    _teamLookup: Record<string, Team> = {}

    // -------------------------------------------------------------------------
    // A list of moderator names
    // -------------------------------------------------------------------------
    _moderators: string[] = []

    // -------------------------------------------------------------------------
    // The datetime when we were last updated
    // -------------------------------------------------------------------------
    _lastUpdated: moment.Moment = moment.utc()
    emitter: ChessLeagueEmitter

    // -------------------------------------------------------------------------
    // Canonicalize the username
    // -------------------------------------------------------------------------
    canonicalUsername(username: string): string {
        username = username.split(' ')[0]
        return username.replace('*', '')
    }

    // -------------------------------------------------------------------------
    // Lookup a player by playerName
    // -------------------------------------------------------------------------
    getPlayer(username: string): Player | undefined {
        return this._playerLookup[_.toLower(username)]
    }

    // -------------------------------------------------------------------------
    // Register a refreshRosters event
    // -------------------------------------------------------------------------
    onRefreshRosters(fn: RefreshRostersCallback) {
        this.emitter.on('refreshRosters', fn)
    }

    // -------------------------------------------------------------------------
    // Register a refreshPairings event
    // -------------------------------------------------------------------------
    onRefreshPairings(fn: RefreshPairingsCallback) {
        this.emitter.on('refreshPairings', fn)
    }

    // -------------------------------------------------------------------------
    // Refreshes everything
    // -------------------------------------------------------------------------
    refresh() {
        return Promise.all([
            this.refreshRosters()
                .then(() => {
                    this._lastUpdated = moment.utc()
                    this.emitter.emit('refreshRosters', this)
                })
                .catch((error) => {
                    winston.error(
                        `${this.name}: Unable to refresh rosters: ${error}`
                    )
                    throw error
                }),
            this.refreshCurrentRoundSchedules()
                .then(() => {
                    this._lastUpdated = moment.utc()
                    this.emitter.emit('refreshPairings', this)
                })
                .catch((error) => {
                    if (error !== 'no_matching_rounds') {
                        winston.error(
                            `${this.name}: Unable to refresh pairings: ${error}`
                        )
                    }
                    throw error
                }),
            this.refreshLeagueModerators()
                .then(() => {
                    this._lastUpdated = moment.utc()
                })
                .catch((error) => {
                    winston.error(
                        `${this.name}: Unable to refresh moderators: ${error}`
                    )
                    throw error
                }),
        ])
    }

    // -------------------------------------------------------------------------
    // Refreshes the latest roster information
    // -------------------------------------------------------------------------
    async refreshRosters() {
        const newPlayers: Player[] = []
        const newPlayerLookup: Record<string, Player> = {}
        const newTeams: Team[] = []
        const newTeamLookup: Record<string, Team> = {}

        const roster = await heltour.getRoster(
            this.config.heltour,
            this.config.heltour.leagueTag
        )
        const incomingPlayerLookup: Record<string, heltour.Player> = {}
        roster.players.map((p) => {
            incomingPlayerLookup[p.username.toLowerCase()] = p
        })
        if (heltour.isTeamLeagueRoster(roster)) {
            roster.teams.map((team) => {
                const newTeam: Team = {
                    name: team.name,
                    players: [],
                    number: team.num,
                    slack_channel: team.slackChannel,
                }
                team.players.forEach(({ username, isCaptain, boardNumber }) => {
                    const key = username.toLowerCase()
                    const { rating } = incomingPlayerLookup[key]
                    const newPlayer = {
                        username,
                        team: newTeam,
                        isCaptain,
                        boardNumber,
                        rating,
                    }
                    if (newPlayer.isCaptain) {
                        newTeam.captain = newPlayer
                    }
                    newTeam.players.push(newPlayer)
                    newPlayerLookup[key] = newPlayer
                    newPlayers.push(newPlayer)
                })
                newTeamLookup[newTeam.name.toLowerCase()] = newTeam
                newTeams.push(newTeam)
            })
        }

        this.log.info('Setting new teams and players')
        this._teams = newTeams
        this._teamLookup = newTeamLookup
        this._players = newPlayers
        this._playerLookup = newPlayerLookup
    }

    // -------------------------------------------------------------------------
    // Calls into the website moderators endpoint to get the list of moderators
    // for the league.
    // -------------------------------------------------------------------------
    async refreshLeagueModerators() {
        const moderators = await heltour.getLeagueModerators(
            this.config.heltour
        )
        this._moderators = moderators.map((m) => m.toLowerCase())
    }

    // -------------------------------------------------------------------------
    // Figures out the current scheduling information for the round.
    // -------------------------------------------------------------------------
    async refreshCurrentRoundSchedules() {
        return heltour
            .getAllPairings(this.config.heltour, this.config.heltour.leagueTag)
            .then((pairings: heltour.Pairing[]) => {
                const newPairings: Pairing[] = []
                _.each(pairings, (heltourPairing) => {
                    const date = moment.utc(heltourPairing.datetime)
                    const newPairing: Pairing = {
                        white: heltourPairing.white,
                        black: heltourPairing.black,
                        scheduledDate: date.isValid() ? date : undefined,
                        url: heltourPairing.gameLink,
                        result: resultFromString(heltourPairing.result),
                    }
                    if (heltour.isTeamPairing(heltourPairing)) {
                        newPairing.white_team = heltourPairing.whiteTeam
                        newPairing.black_team = heltourPairing.blackTeam
                    }
                    newPairings.push(newPairing)
                })
                this._pairings = newPairings
                return this._pairings
            })
    }

    // -------------------------------------------------------------------------
    // Finds the pairing for this current round given either a black or a white
    // username.
    // -------------------------------------------------------------------------
    findPairing(white: string, black?: string): Pairing[] {
        if (!white) {
            throw new Error('findPairing requires at least one username.')
        }
        let possibilities = this._pairings
        const filter = (playerName: string) => {
            if (playerName) {
                possibilities = _.filter(possibilities, (item) => {
                    return (
                        item.white.toLowerCase().includes(playerName) ||
                        item.black.toLowerCase().includes(playerName)
                    )
                })
            }
        }
        filter(white)
        if (black) filter(black)
        return possibilities
    }

    // -------------------------------------------------------------------------
    // Returns whether someone is a moderator or not.
    // -------------------------------------------------------------------------
    isModerator(name: string) {
        return _.includes(this._moderators, _.toLower(name))
    }

    // -------------------------------------------------------------------------
    // Prepare a debug message for this league
    // -------------------------------------------------------------------------
    formatDebugResponse(): string {
        const name = this.name
        const pairingsCount = this._pairings.length
        const lastUpdated = this._lastUpdated.format('YYYY-MM-DD HH:mm UTC')
        const since = this._lastUpdated.fromNow(true)
        return `DEBUG:\nLeague: ${name}\nTotal Pairings: ${pairingsCount}\nLast Updated: ${lastUpdated} [${since} ago]`
    }

    // -------------------------------------------------------------------------
    // Generates the appropriate data format for pairing result for this league.
    // -------------------------------------------------------------------------
    async getPairingDetails(
        lichessUsername: string
    ): Promise<PairingDetails | undefined> {
        return new Promise(async (resolve) => {
            const pairings = this.findPairing(lichessUsername)
            if (pairings.length < 1) {
                return resolve(undefined)
            }
            // TODO: determine what to do if multiple pairings are returned. ?
            const pairing = pairings[0]
            const details: PairingDetails = {
                ...pairing,
                player: lichessUsername,
                color: 'white',
                opponent: pairing.black,
                league: this,
            }
            if (
                !_.isEqual(
                    pairing.white.toLowerCase(),
                    lichessUsername.toLowerCase()
                )
            ) {
                details.color = 'black'
                details.opponent = pairing.white
            }
            try {
                const rating = await lichess.getPlayerRating(details.opponent)
                details.rating = rating
                resolve(details)
            } catch (error) {
                winston.error(JSON.stringify(error))
            }
            resolve(undefined)
        })
    }

    // -------------------------------------------------------------------------
    // Formats the pairing result for this league
    // -------------------------------------------------------------------------
    formatPairingResponse(
        requestingPlayer: LeagueMember,
        details: PairingDetails
    ) {
        let time
        if (details.scheduledDate) {
            time = localTime(requestingPlayer, details.scheduledDate)
        }
        let schedulePhrase = ''
        let playedPhrase = ''

        if (!time || !time.isValid()) {
            playedPhrase = 'will play'
            schedulePhrase = '. The game is unscheduled.'
        } else if (moment.utc().isAfter(time)) {
            // If the match took place in the past, display the date instead of the day
            playedPhrase = 'played'
            const localDateTimeString = time.format('YYYY-MM-DD [at] HH:mm')
            schedulePhrase = ` on ${localDateTimeString} in your timezone.`
        } else {
            playedPhrase = 'will play'
            const localDateTimeString = time.format('MM/DD _(dddd)_ [at] HH:mm')
            const timeUntil = time.fromNow(true)
            schedulePhrase = ` on ${localDateTimeString} in your timezone, which is in ${timeUntil}.`
        }

        // Otherwise display the time until the match
        const opponentColor = details.color === 'white' ? 'black' : 'white'
        const rating = details.rating ? '(' + details.rating + ')' : ''
        return (
            `[${this.name}]: :${details.color}pieces: _${details.player}_ ${playedPhrase} against ` +
            `:${opponentColor}pieces: _${details.opponent}_ ${rating}${schedulePhrase}`
        )
    }

    // -------------------------------------------------------------------------
    // Formats the captains Guidelines
    // -------------------------------------------------------------------------
    formatCaptainGuidelinesResponse() {
        if (this.config.links && this.config.links.captains) {
            return `Here are the captain's guidelines:\n${this.config.links.captains}`
        } else {
            return `The ${this.name} league does not have captains guidelines.`
        }
    }

    // -------------------------------------------------------------------------
    // Formats the pairings response
    // -------------------------------------------------------------------------
    formatPairingsLinkResponse() {
        if (this.config.links && this.config.links.league) {
            return (
                'The pairings can be found on the website:\n' +
                this.config.links.pairings +
                '\nAlternatively, try [ @chesster pairing [competitor] ]'
            )
        } else {
            return `The ${this.name} league does not have a pairings link.`
        }
    }

    // -------------------------------------------------------------------------
    // Formats the standings response
    // -------------------------------------------------------------------------
    formatStandingsLinkResponse() {
        if (this.config.links && this.config.links.league) {
            return (
                'The standings can be found on the website:\n' +
                this.config.links.standings
            )
        } else {
            return `The ${this.name} league does not have a standings link.`
        }
    }

    // -------------------------------------------------------------------------
    // Formats the rules link response
    // -------------------------------------------------------------------------
    formatRulesLinkResponse() {
        if (this.config.links.rules) {
            return `Here are the rules and regulations:\n${this.config.links.rules}`
        } else {
            return `The ${this.name} league does not have a rules link.`
        }
    }

    // -------------------------------------------------------------------------
    // Formats the starter guide message
    // -------------------------------------------------------------------------
    formatStarterGuideResponse() {
        if (this.config.links.guide) {
            return `Here is everything you need to know:\n${this.config.links.guide}`
        } else {
            return `The ${this.name} league does not have a starter guide.`
        }
    }

    // -------------------------------------------------------------------------
    // Formats the signup response
    // -------------------------------------------------------------------------
    formatRegistrationResponse() {
        if (this.config.links.registration) {
            return `You can sign up here:\n${this.config.links.registration}`
        } else {
            return `The ${this.name} league does not have an active signup form at the moment.`
        }
    }

    // -------------------------------------------------------------------------
    // Get a list of captain names
    // -------------------------------------------------------------------------
    getCaptains() {
        return this._teams.map((t) => t.captain)
    }

    // -------------------------------------------------------------------------
    // Get the list of teams
    // -------------------------------------------------------------------------
    getTeams() {
        return this._teams
    }

    // -------------------------------------------------------------------------
    // Get the team for a given player name
    // -------------------------------------------------------------------------
    getTeamByPlayerName(playerName: string): Team | undefined {
        playerName = playerName.toLowerCase()
        const directTeamMapping =
            this._playerLookup[playerName] &&
            this._playerLookup[playerName].team
        if (directTeamMapping) return directTeamMapping
        // Try to find the team by looking through the pairings for this
        // playername.  This will find alternates.
        const possiblePairings = this.findPairing(playerName)
        if (possiblePairings.length === 1) {
            const pairing = possiblePairings[0]
            if (_.isEqual(pairing.white.toLowerCase(), playerName)) {
                if (isDefined(pairing.white_team)) {
                    return this.getTeam(pairing.white_team)
                }
            } else if (_.isEqual(pairing.black.toLowerCase(), playerName)) {
                if (isDefined(pairing.black_team)) {
                    return this.getTeam(pairing.black_team)
                }
            }
        }
        return undefined
    }

    // -------------------------------------------------------------------------
    // Get the team for a given team name
    // -------------------------------------------------------------------------
    getTeam(teamName: string): Team | undefined {
        return this._teamLookup[teamName.toLowerCase()]
    }

    // -------------------------------------------------------------------------
    // Get the the players from a particular board
    // -------------------------------------------------------------------------
    getBoard(boardNumber: number) {
        const players: Player[] = []
        _.each(this._teams, (team) => {
            if (boardNumber - 1 < team.players.length) {
                players.push(team.players[boardNumber - 1])
            }
        })
        return players
    }

    // -------------------------------------------------------------------------
    // Format the response for the list of captains
    // -------------------------------------------------------------------------
    formatCaptainsResponse() {
        if (this._teams.length === 0) {
            return `The ${this.name} league does not have captains`
        }
        let message = 'Team Captains:\n'
        let teamIndex = 1
        this._teams.forEach((team) => {
            const captainName = team.captain?.username || 'Unchosen'
            message +=
                '\t' +
                teamIndex++ +
                '. ' +
                team.name +
                ': ' +
                captainName +
                '\n'
        })
        return message
    }

    // -------------------------------------------------------------------------
    // Format the response for the list of captains
    // -------------------------------------------------------------------------
    formatTeamCaptainResponse(teamName: string) {
        if (this._teams.length === 0) {
            return `The {this.name} league does not have captains`
        }
        const teams = _.filter(this._teams, (t) =>
            _.isEqual(t.name.toLowerCase(), teamName.toLowerCase())
        )
        if (teams.length === 0) {
            return 'No team by that name'
        }
        if (teams.length > 1) {
            return 'Too many teams by that name'
        }
        const team = teams[0]
        if (team && team.captain) {
            return 'Captain of ' + team.name + ' is ' + team.captain.username
        } else {
            return team.name + ' has not chosen a team captain. '
        }
    }

    // -------------------------------------------------------------------------
    // Format the teams response
    // -------------------------------------------------------------------------
    formatTeamsResponse() {
        if (this._teams.length === 0) {
            return `The ${name} league does not have teams`
        }
        let message =
            'There are currently ' + this._teams.length + ' teams competing. \n'
        this._teams.forEach((team) => {
            message += '\t' + team.number + '. ' + team.name + '\n'
        })
        return message
    }

    // -------------------------------------------------------------------------
    // Format board response
    // -------------------------------------------------------------------------
    formatBoardResponse(boardNumber: number) {
        if (this._teams.length === 0) {
            return `The ${this.name} league does not have teams`
        }
        const players = this.getBoard(boardNumber)
        let message = `Board ${boardNumber}consists of... \n`
        players.sort((e1, e2) => {
            return e1.rating > e2.rating ? -1 : e1.rating < e2.rating ? 1 : 0
        })
        players.forEach((member) => {
            message +=
                '\t' +
                member.username +
                ': (Rating: ' +
                member.rating +
                '; Team: ' +
                member.team.name +
                ')\n'
        })
        return message
    }

    // -------------------------------------------------------------------------
    // Format team members response
    // -------------------------------------------------------------------------
    formatTeamMembersResponse(teamName: string) {
        if (this._teams.length === 0) {
            return `The ${this.name} league does not have teams`
        }
        const teams = _.filter(this._teams, (t) => {
            return _.isEqual(t.name.toLowerCase(), teamName.toLowerCase())
        })
        if (teams.length === 0) {
            return 'No team by that name'
        }
        if (teams.length > 1) {
            return 'Too many teams by that name'
        }
        const team = teams[0]
        let message = 'The members of ' + team.name + ' are \n'
        team.players.forEach((member, index) => {
            message +=
                '\tBoard ' +
                (index + 1) +
                ': ' +
                member.username +
                ' (' +
                this.getPlayer(member.username)?.rating +
                ')\n'
        })
        return message
    }

    // -------------------------------------------------------------------------
    // Format the mods message
    // -------------------------------------------------------------------------
    formatModsResponse() {
        const moderators = _.map(this._moderators, (name) => {
            return name[0] + '\u200B' + name.slice(1)
        })
        return (
            `${this.name} mods: ` +
            moderators.join(', ') +
            ' (**Note**: this does not ping the moderators. Use: `@chesster: summon mods` to ping them)'
        )
    }

    // -------------------------------------------------------------------------
    // Format the summon mods message
    // -------------------------------------------------------------------------
    formatSummonModsResponse() {
        const moderators = _.map(this._moderators, (name) => {
            return this.bot.users.getIdString(name)
        })
        return (
            `${this.name} mods: ` +
            moderators.join(', ') +
            ' Please wait for the mods to contact you. Do not send messages directly.'
        )
    }

    formatFAQResponse() {
        return `The FAQ for ${this.name} can be found: ${this.config.links.faq}`
    }
}

function isLeague(l: League | undefined): l is League {
    return l !== undefined
}

export function getAllLeagues(bot: SlackBot): League[] {
    const allLeagueConfigs = bot.config.leagues || {}
    return _(allLeagueConfigs)
        .keys()
        .map((key) => getLeague(bot, key))
        .filter(isLeague)
        .value()
}

export const getLeague = (() => {
    const _leagueCache: Record<string, League> = {}
    return (bot: SlackBot, leagueName: string): League | undefined => {
        if (!_leagueCache[leagueName]) {
            // Else create it if there is a config for it.
            const allLeagueConfigs = bot.config.leagues || {}
            let thisLeagueConfig = allLeagueConfigs[leagueName] || undefined
            if (thisLeagueConfig) {
                winston.info('Creating new league for ' + leagueName)
                thisLeagueConfig = _.clone(thisLeagueConfig)
                thisLeagueConfig.name = leagueName
                const league = new League(bot, leagueName, thisLeagueConfig)
                _leagueCache[leagueName] = league
            } else {
                return undefined
            }
        }
        return _leagueCache[leagueName]
    }
})()