TypeRacer Private Racetrack Bot

Preview texts, race alone, and keep a log of races by having this bot host a private racetrack.

// ==UserScript==
// @name        TypeRacer Private Racetrack Bot
// @namespace   morinted
// @description Preview texts, race alone, and keep a log of races by having this bot host a private racetrack.
// @include     https://play.typeracer.com/
// @version     5.3.2
// @grant       none
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js
// ==/UserScript==
/* jshint asi:true */

var nbsp = ' '

function getNumberOfUsers() {
  return $('.users-list .userNameLabel').length
}

function isBotInChat() {
  return !!$('.users-list .userNameLabel.userNameLabel-self').length
}

// Check number of users in race, but only if they are not finished and haven't left.
function getNumberOfRacingUsers() {
  return $('.scoreboard > tbody > tr').map((index, row) => {
    row = $(row)
    // .avatar-dead means they've left, if rank is empty they haven't finished the race.
    if (row.find('.avatar-dead').length || (row.find('div.rank')[0].textContent || '').trim()) {
      return 0
    }
    return 1
  }).get().reduce((result, x) => result + x)
}

function capitalize(string) {
    return string.charAt(0).toUpperCase() + string.slice(1)
}

function postRaceResults() {
    var raceResults = $('.scoreboard > tbody > tr').map((index, row) => {
      row = $(row)
      if ((row.find('div.rank')[0].textContent || '').trim()) {
        var place = parseInt(row.find('div.rank')[0].textContent)
        var wpm = parseInt(row.find('.rankPanelWpm')[0].textContent)
        var username = (row.find('.lblUsername')[0].textContent || '').trim()
        username = username ? username.slice(1, -1) : 'Guest'
        return { place: place, wpm: wpm, username: username }
      }
      return null
    }).get().filter(x => x).sort((a, b) => a.place - b.place)
      .map((user, index) => {
        const badge = index === 0 ? '?' : index === 1 ? '?' : index === 2 ? '?' : '?'
        return [
            badge,
            user.username.split('_').map(x => capitalize(x)).join('_'),
            user.wpm + 'wpm'
        ].filter(x => x).join(' ')
      }).join(', ')
    if (raceResults) sendChatMessage(raceResults)
    var quitters = $('.scoreboard > tbody > tr').map((index, row) => {
      row = $(row)
      if (!(row.find('div.rank')[0].textContent || '').trim() &&
          row.find('.avatar-dead').length &&
          !row.find('.avatar-self').length) {
        var username = (row.find('.lblUsername')[0].textContent || '').trim()
        username = username ? username.slice(1, -1) : 'Guest'
        return username
      }
      return null
    }).get().filter(x => x).join(', ')
    if (quitters) sendChatMessage('? Quitters: ' + quitters)
}

function inRace() {
  // Check for presence and visibility of "(you)" label.
  return !!document.querySelector('.lblUsername[style=""]')
}

function getGameStatus() {
  return ((document.getElementsByClassName('gameStatusLabel') || [])[0] || {}).innerHTML || ''
}

function leaveRace() {
  document.querySelector('table.navControls > tbody > tr > td > a:first-child').click()
}

function fakeActivity() {
  // Ted: Huge thank you to *Nimble* for figuring this out. Drove me crazy!
  // Mouse move event clientX needs to be different from last event
  // to work as an activity event.
  // Emit 2 different events at once so this function can be stateless.
  document.dispatchEvent(new MouseEvent("mousemove", {
     'clientX': 0
  }));
  document.dispatchEvent(new MouseEvent("mousemove", {
     'clientX': 1
  }));
}

function joinRace() {
  (document.getElementsByClassName('raceAgainLink') || [])[0].click()
}

function getQuoteText() {
  return ((document.querySelector('table.inputPanel table tr:first-child') || {}).textContent || '').trim()
}

function rejoinRacetrack() {
  $('.lnkRejoin').click() // Rejoin
}

function isOnHomePage() {
  return !!(document.querySelector('.mainMenuItemText'))
}

function raceYourFriends() {
  return $(
    'div.mainViewport > div > table > tbody > tr:nth-child(4) > td > table > tbody > tr > td:nth-child(2) > table > tbody > tr:nth-child(1) > td > a'
  ).click()
}

function leaveRacetrack() {
  $('.roomSection > table > tbody > tr > td > a.gwt-Anchor').click()
}

function status() {
  var gameStatus = getGameStatus()
  return {
    noRace: gameStatus.startsWith('Join'),
    waitingForOthers: gameStatus.startsWith('Waiting'),
    countingDown: gameStatus.startsWith('The race is about to start'),
    racing: gameStatus.startsWith('Go!') || gameStatus.startsWith('The race is on!'),
    raceOver: gameStatus.startsWith('The race has ended')
  }
}

function sendChatMessage(message) {
     var chatInput = $('input.txtChatMsgInput')
     chatInput.click()
     chatInput.val(message)
     chatInput.focus()
     var keyboardEvent = jQuery.Event('keydown')
     keyboardEvent.which = 13
     keyboardEvent.keyCode = 13
     chatInput.trigger(keyboardEvent)
}

function waitingForNewQuote() {
     return !!$('.mainViewport .timeDisplay .caption').length
}

function sendQuoteText() {
   var quoteText = getQuoteText()
   if (quoteText) {
     log('Sending quote text')
     log(quoteText)
     sendChatMessage('“' + quoteText + '”')
   }
}

function tick() {
    setTimeout(mainLoop, 500)
}

lastMessage = ''
function log(message) {
    if (message != lastMessage) {
      lastMessage = message
      console.log('BOT:', message)
    }
}

function refreshPage() {
 location.reload()
}

function mainLoop() {
    fakeActivity()
    if (isOnHomePage()) {
        log('on the homepage, going to private track')
        raceYourFriends()
        return tick()
    }
    if (!isBotInChat()) {
        // Check again in a second to be sure.
        setTimeout(function() {
          if (!isBotInChat()) {
            log('leaving the racetrack because it seems I am not in it anymore')
            if (inRace()) leaveRace()
              leaveRacetrack()
          }
          return tick()
        }, 1000)
        return
    }
    if (getNumberOfUsers() < 2) {
        if (inRace()) {
            log('alone, so leaving the current race')
            leaveRace()
            return tick()
        }
        log('alone on the private track')
        // No one but the bot in the track.
        return tick()
    }
    // We have another user in the track
    var trackIs = status()
    if (trackIs.noRace && !waitingForNewQuote()) {
        log('Joining race')
        // Give a little time just to try and avoid glitchy track issue.
        setTimeout(joinRace, 500)
        setTimeout(sendQuoteText, 700)
        setTimeout(tick, 1000)
        return
    }
    if (trackIs.waitingForOthers){
        log('waiting for others...')
        return tick()
    }
    if (trackIs.countingDown || trackIs.racing) {
        if (inRace() && getNumberOfRacingUsers() < 2) {
          log('leaving race because I am the only racer')
          postRaceResults()
          leaveRace()
          // Delay after leaving to allow new quote to queue.
          setTimeout(tick, 2000)
          return
        }
        if (inRace()) {
            log('in race, waiting for everyone to finish')
        } else {
            log('race is going, not in it...')
        }
        return tick()
    }
    if (trackIs.raceOver) {
        log('race over...')
        return tick()
    }
    log('unknown state')
    return tick()
}

function kickOffLoop() {
  if ($('.mainMenuItem').is(':visible')) {
      mainLoop()
  } else {
      setTimeout(kickOffLoop, 100)
  }
}
kickOffLoop()