Source

lib/matchTracker.js

/**
 * @module matchTracker
 */

const { EventEmitter } = require('events');
const got = require('got');
const convertor = require('steam-id-convertor');
const heroes = require('dotaconstants/build/heroes.json');
const logger = require('./logger');
const CONSTANTS = require('./constants');
const Lobby = require('./lobby');
const Db = require('./db');
const Fp = require('./util/fp');
const { cache } = require('./cache');

const calcEloChange = (r1, r2, K, S) => {
    const E = 1 / (1 + (10 ** ((r2 - r1) / 400)));
    return K * (S - E);
};

const updatePlayerRatings = async (lobbyState) => {
    const lobby = await Lobby.getLobby(lobbyState);
    const league = await Db.findLeagueById(lobby.leagueId);
    const faction1 = await Lobby.getFaction1Players()(lobby);
    const faction2 = await Lobby.getFaction2Players()(lobby);
    const r1 = faction1.reduce((total, player) => total + player.rating, 0) / faction1.length;
    const r2 = faction2.reduce((total, player) => total + player.rating, 0) / faction2.length;
    const K = league.eloKFactor;
    const S1 = lobby.winner === 1 ? 1 : 0;
    const S2 = lobby.winner === 2 ? 1 : 0;
    const D1 = Math.round(calcEloChange(r1, r2, K, S1));
    const D2 = Math.round(calcEloChange(r2, r1, K, S2));
    logger.silly(`updatePlayerRatings ${lobby.winner}`);
    logger.silly(`updatePlayerRatings ${r1} ${S1} ${D1}`);
    logger.silly(`updatePlayerRatings ${r2} ${S2} ${D2}`);
    return Fp.allPromise([
        faction1.map(player => Fp.allPromise([
            Lobby.updateLobbyPlayer({ ratingDiff: D1 })(lobby)(player.id),
            Db.updateUserRating(player)(player.rating + D1),
            Db.findOrCreateLeaderboard(lobby)(player)(player.rating + D1)
                .then(leaderboard => Db.incrementLeaderboardRecord(S1)(S2)(leaderboard)),
        ])),
        faction2.map(player => Fp.allPromise([
            Lobby.updateLobbyPlayer({ ratingDiff: D2 })(lobby)(player.id),
            Db.updateUserRating(player)(player.rating + D2),
            Db.findOrCreateLeaderboard(lobby)(player)(player.rating + D1)
                .then(leaderboard => Db.incrementLeaderboardRecord(S2)(S1)(leaderboard)),
        ])),
    ]);
};

const getHeroNameFromId = id => (heroes[id] ? heroes[id].localized_name : 'Unknown');

const createMatchPlayerDetails = data => `**${data.personaname}**
*${getHeroNameFromId(data.hero_id)}* (${data.level})
KDA: ${data.kills}/${data.deaths}/${data.assists}
CS: ${data.last_hits}/${data.denies}
Gold: ${data.total_gold} (${data.gold_per_min}/min)`;

const getOpenDotaMatchDetails = async (matchId) => {
    logger.silly(`matchTracker getOpenDotaMatchDetails ${matchId}`);
    try {
        const response = await got(`https://api.opendota.com/api/matches/${matchId}`, { json: true });
        return response.body && !response.body.error ? response.body : null;
    }
    catch (e) {
        logger.error(e);
        return null;
    }
};

const getValveMatchDetails = async (matchId) => {
    logger.silly(`matchTracker getValveMatchDetails ${matchId}`);
    try {
        const response = await got(`http://api.steampowered.com/IDOTA2Match_570/GetMatchDetails/v1/?key=${process.env.STEAM_API_KEY}&match_id=${matchId}`, { json: true });
        return response.body && response.body.result && !response.body.result.error ? response.body : null;
    }
    catch (e) {
        logger.error(e);
        return null;
    }
};

const setMatchDetails = async (lobbyOrState) => {
    logger.silly(`matchTracker setMatchDetails matchId ${lobbyOrState.matchId}`);
    let lobby = await Lobby.getLobby(lobbyOrState);
    logger.silly(`matchTracker setMatchDetails lobby.id ${lobby.id} ${lobby.matchId}`);
    if (!lobby.odotaData) {
        const odotaData = await getOpenDotaMatchDetails(lobby.matchId);
        if (odotaData) {
            await Db.updateLobby({
                odotaData,
                finishedAt: odotaData.start_time + odotaData.duration * 1000,
                id: lobby.id,
            });
        }
    }
    lobby = await lobby.reload();
    cache.Lobby.set(lobby.id, lobby);
    return lobby;
};

const setMatchPlayerDetails = async (_lobby) => {
    const lobby = await Lobby.getLobby(_lobby);
    const players = await Lobby.getPlayers()(lobby);
    let winner = 0;
    const tasks = [];
    for (const playerData of lobby.odotaData.players) {
        if (playerData.account_id) {
            const steamId64 = convertor.to64(playerData.account_id);
            const player = players.find(p => p.steamId64 === steamId64);
            if (player) {
                const data = {
                    win: playerData.win,
                    lose: playerData.lose,
                    heroId: playerData.hero_id,
                    kills: playerData.kills,
                    deaths: playerData.deaths,
                    assists: playerData.assists,
                    gpm: playerData.gold_per_min,
                    xpm: playerData.xp_per_min,
                };
                if (player.id === lobby.captain1UserId) {
                    if (playerData.win === 1) {
                        winner = 1;
                    }
                }
                else if (player.id === lobby.captain2UserId) {
                    if (playerData.win === 1) {
                        winner = 2;
                    }
                }
                tasks.push(Lobby.updateLobbyPlayerBySteamId(data)(_lobby)(steamId64));
            }
        }
    }
    await Fp.allPromise(tasks);
    await Db.updateLobbyWinner(lobby)(winner);
};

const createMatchEndMessageEmbed = async (matchId) => {
    const lobby = await Db.findLobbyByMatchId(matchId);
    const { odotaData } = lobby;

    const players = await Lobby.getPlayers()({ id: lobby.id });
    const captain1 = players.find(player => player.id === lobby.captain1UserId);
    const captain2 = players.find(player => player.id === lobby.captain2UserId);
    const captain1Data = odotaData.players.find(player => player.account_id.toString() === convertor.to32(captain1.steamId64));
    const captain2Data = odotaData.players.find(player => player.account_id.toString() === convertor.to32(captain2.steamId64));
    const radiantCaptainPlayer = captain1Data.isRadiant ? captain1Data : captain2Data;
    const direCaptainPlayer = captain1Data.isRadiant ? captain2Data : captain1Data;
    logger.silly(`createMatchEndMessageEmbed radiantName ${radiantCaptainPlayer.personaname}`);
    logger.silly(`createMatchEndMessageEmbed direName ${direCaptainPlayer.personaname}`);
    const radiantName = radiantCaptainPlayer.personaname;
    const direName = direCaptainPlayer.personaname;

    const radiantDetails = odotaData.players.filter(player => player.isRadiant).map(createMatchPlayerDetails).join('\t\n\n');
    const direDetails = odotaData.players.filter(player => !player.isRadiant).map(createMatchPlayerDetails).join('\t\n\n');

    const title = `${radiantName} vs ${direName}`;

    const winner = odotaData.radiant_win ? radiantName : direName;

    const goldLead = odotaData.radiant_gold_adv[odotaData.radiant_gold_adv.length - 1];
    const goldLeadTeam = goldLead > 0 ? 'Radiant' : 'Dire';

    const xpLead = odotaData.radiant_xp_adv[odotaData.radiant_xp_adv.length - 1];
    const xpLeadTeam = xpLead > 0 ? 'Radiant' : 'Dire';

    const durationHours = Math.floor(odotaData.duration / 3600);
    const durationMinutes = Math.floor((odotaData.duration % 3600) / 60);
    const durationSeconds = odotaData.duration % 3600 % 60;

    const duration = `${durationHours > 0 ? `${durationHours.toString()}:` : ''}${`${durationMinutes.toString().padStart(2, '0')}:`}${durationSeconds.toString().padStart(2, '0')}`;

    const description = `**${winner}** Victory!
Match ID: ${matchId} [DB](https://www.dotabuff.com/matches/${matchId})/[OD](https://www.opendota.com/matches/${matchId})/[SZ](https://stratz.com/en-us/match/${matchId})`;

    const matchDetails = `**Duration:** ${duration}
**Score:** ${odotaData.radiant_score} - ${odotaData.dire_score}
**Gold:** +${Math.abs(goldLead)} ${goldLeadTeam}
**Exp:** +${Math.abs(xpLead)} ${xpLeadTeam}
`;
    return {
        embed: {
            color: 3447003,
            title,
            description,
            fields: [
                {
                    name: 'Match Details',
                    value: matchDetails,
                },
                {
                    name: radiantName,
                    value: radiantDetails,
                    inline: true,
                },
                {
                    name: direName,
                    value: direDetails,
                    inline: true,
                },
            ],
        },
    };
};

/**
 * Match tracker checks opendota and valve match apis to see if a match has finished
 * and saves the match data to the database. */
class MatchTracker extends EventEmitter {
    /**
     * Creates an inhouse league match tracker.
     */
    constructor(interval) {
        super();
        this.interval = interval;
        this.enabled = true;
        this.blocking = false;
        this.lobbies = [];
        this.runTimer = null;
    }

    async loadLobbies() {
        let lobbies = await Db.findAllInProgressLobbies();
        this.lobbies.push(...lobbies.map(lobby => ({
            lobby,
            lastCheck: null,
        })));
        lobbies = await Db.findAllMatchEndedLobbies();
        this.lobbies.push(...lobbies.map(lobby => ({
            lobby,
            lastCheck: null,
        })));
    }

    addLobby(lobby) {
        this.lobbies.push({
            lobby,
            lastCheck: null,
        });
    }

    /**
     * Event for returning stats from a lobby.
     *
     * @event module:ihlManager~EVENT_MATCH_STATS
     * @param {module:db.Lobby} lobby - The lobby with stats.
     */

    /**
     * The match polling loop
     * @async
     * @fires module:ihlManager~EVENT_MATCH_STATS
     */
    async run() {
        logger.silly(`matchTracker run ${this.blocking}`);
        if (this.blocking) return;
        if (this.enabled && this.lobbies.length) {
            this.blocking = true;
            const data = this.lobbies.shift();
            if (!data.lastCheck || data.lastCheck < Date.now() - this.interval) {
                data.lastCheck = Date.now();
                const lobby = await setMatchDetails(data.lobby);
                if (lobby.odotaData) {
                    await setMatchPlayerDetails(lobby);
                    this.emit(CONSTANTS.EVENT_MATCH_STATS, lobby);
                }
                else if (lobby.state === CONSTANTS.STATE_MATCH_IN_PROGRESS
                         || lobby.state === CONSTANTS.STATE_MATCH_ENDED) {
                    logger.silly(`matchTracker no data, queueing ${lobby.id}`);
                    // startedAtExpiration is four hours before the current time
                    // any match started before that is considered expired
                    const startedAtExpiration = new Date();
                    startedAtExpiration.setHours(startedAtExpiration.getHours() - 4);
                    if (lobby.startedAt > startedAtExpiration) {
                        this.lobbies.push(data);
                    }
                    else {
                        this.emit(CONSTANTS.EVENT_MATCH_NO_STATS, lobby);
                    }
                }

                if (this.lobbies.length) {
                    logger.silly('matchTracker looping');
                    this.runTimer = setTimeout(() => this.run(), 1000);
                }
            }
            else {
                data.lastCheck = Date.now();
                this.lobbies.push(data);
            }
            this.blocking = false;
        }
    }

    /**
     * Enables the match polling loop
     * @async
     */
    enable() {
        logger.silly('matchTracker start');
        this.enabled = true;
    }

    /**
     * Disables the match polling loop
     * @async
     */
    disable() {
        this.enabled = false;
        clearTimeout(this.runTimer);
    }
}

module.exports = {
    calcEloChange,
    updatePlayerRatings,
    getOpenDotaMatchDetails,
    getValveMatchDetails,
    setMatchDetails,
    setMatchPlayerDetails,
    createMatchEndMessageEmbed,
    MatchTracker,
};