Nitro Type - Racing Stats

Displays various user stats below the race track.

  1. // ==UserScript==
  2. // @name Nitro Type - Racing Stats
  3. // @version 0.1.6
  4. // @description Displays various user stats below the race track.
  5. // @author Toonidy
  6. // @match *://*.nitrotype.com/race
  7. // @match *://*.nitrotype.com/race/*
  8. // @icon https://i.ibb.co/YRs06pc/toonidy-userscript.png
  9. // @grant none
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js#sha512-qTXRIMyZIFb8iQcfjXWCO8+M5Tbc38Qi5WzdPOYZHIlZpzBHG3L3by84BBBOiRGiEb7KKtAOAs5qYdUiZiQNNQ==
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.1/dexie.min.js#sha512-ybuxSW2YL5rQG/JjACOUKLiosgV80VUfJWs4dOpmSWZEGwdfdsy2ldvDSQ806dDXGmg9j/csNycIbqsrcqW6tQ==
  12. // @require https://greasyfork.org/scripts/443718-nitro-type-userscript-utils/code/Nitro%20Type%20Userscript%20Utils.js?version=1042360
  13. // @license MIT
  14. // @namespace https://greasyfork.org/users/858426
  15. // ==/UserScript==
  16.  
  17. /* global Dexie moment NTGLOBALS createLogger findReact */
  18.  
  19. const logging = createLogger("Nitro Type Racing Stats")
  20.  
  21. /* Config storage */
  22. const db = new Dexie("NTRacingStats")
  23. db.version(1).stores({
  24. backupStatData: "userID",
  25. })
  26. db.open().catch(function (e) {
  27. logging.error("Init")("Failed to open up the racing stat cache database", e)
  28. })
  29.  
  30. ////////////
  31. // Init //
  32. ////////////
  33.  
  34. const raceContainer = document.getElementById("raceContainer"),
  35. raceObj = raceContainer ? findReact(raceContainer) : null,
  36. server = raceObj?.server,
  37. currentUser = raceObj?.props.user
  38. if (!raceContainer || !raceObj) {
  39. logging.error("Init")("Could not find the race track")
  40. return
  41. }
  42. if (!currentUser?.loggedIn) {
  43. logging.error("Init")("Not available for Guest Racing")
  44. return
  45. }
  46.  
  47. //////////////////
  48. // Components //
  49. //////////////////
  50.  
  51. /** Styles for the following components. */
  52. const style = document.createElement("style")
  53. style.appendChild(
  54. document.createTextNode(`
  55. #raceContainer {
  56. margin-bottom: 0;
  57. }
  58. .nt-stats-root {
  59. background-color: #222;
  60. }
  61. .nt-stats-body {
  62. display: flex;
  63. justify-content: space-between;
  64. padding: 8px;
  65. }
  66. .nt-stats-left-section, .nt-stats-right-section {
  67. display: flex;
  68. flex-direction: column;
  69. row-gap: 8px;
  70. }
  71. .nt-stats-toolbar {
  72. display: flex;
  73. justify-content: space-between;
  74. align-items: center;
  75. padding-left: 8px;
  76. color: rgba(255, 255, 255, 0.8);
  77. background-color: #03111a;
  78. font-size: 12px;
  79. }
  80. .nt-stats-toolbar-status {
  81. display: flex;
  82. }
  83. .nt-stats-toolbar-status .nt-stats-toolbar-status-item {
  84. padding: 0 8px;
  85. background-color: #0a2c42;
  86. }
  87. .nt-stats-toolbar-status .nt-stats-toolbar-status-item-alt {
  88. padding: 0 8px;
  89. background-color: #22465c;
  90. }
  91. .nt-stats-daily-challenges {
  92. width: 350px;
  93. }
  94. .nt-stats-daily-challenges .daily-challenge-progress--badge {
  95. z-index: 0;
  96. }
  97. .nt-stats-season-progress {
  98. padding: 8px;
  99. margin: 0 auto;
  100. border-radius: 8px;
  101. background-color: #1b83d0;
  102. box-shadow: 0 28px 28px 0 rgb(2 2 2 / 5%), 0 17px 17px 0 rgb(2 2 2 / 20%), 0 8px 8px 0 rgb(2 2 2 / 15%);
  103. }
  104. .nt-stats-season-progress .season-progress-widget {
  105. width: 350px;
  106. }
  107. .nt-stats-season-progress .season-progress-widget--level-progress-bar {
  108. transition: width 0.3s ease;
  109. }
  110. .nt-stats-info {
  111. text-align: center;
  112. color: #eee;
  113. font-size: 14px;
  114. }
  115. .nt-stats-metric-row {
  116. margin-bottom: 4px;
  117. }
  118. .nt-stats-metric-value, .nt-stats-metric-suffix {
  119. font-weight: 600;
  120. }
  121. .nt-stats-metric-suffix {
  122. color: #aaa;
  123. }
  124. .nt-stats-right-section {
  125. flex-grow: 1;
  126. margin-left: 15px;
  127. }`)
  128. )
  129. document.head.appendChild(style)
  130.  
  131. /** Populates daily challenge data merges in the given progress. */
  132. const mergeDailyChallengeData = (progress) => {
  133. const { CHALLENGES, CHALLENGE_TYPES } = NTGLOBALS,
  134. now = Math.floor(Date.now() / 1000)
  135. return CHALLENGES.filter((c) => c.expiration > now)
  136. .slice(0, 3)
  137. .map((c, i) => {
  138. const userProgress = progress.find((p) => p.challengeID === c.challengeID),
  139. challengeType = CHALLENGE_TYPES[c.type],
  140. field = challengeType[1],
  141. title = challengeType[0].replace(/\$\{goal\}/, c.goal).replace(/\$\{field\}/, `${challengeType[1]}${c.goal !== 1 ? "s" : ""}`)
  142. return {
  143. ...c,
  144. title,
  145. field,
  146. goal: c.goal,
  147. progress: userProgress?.progress || 0,
  148. }
  149. })
  150. }
  151.  
  152. /** Grab NT Racing Stats from various sources. */
  153. const getStats = async () => {
  154. let backupUserStats = null
  155. try {
  156. backupUserStats = await db.backupStatData.get(currentUser.userID)
  157. } catch (ex) {
  158. logging.warn("Update")("Unable to get backup stats", ex)
  159. }
  160. try {
  161. const persistStorageStats = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user),
  162. user =
  163. !backupUserStats || typeof backupUserStats.lastConsecRace !== "number" || persistStorageStats.lastConsecRace >= backupUserStats.lastConsecRace
  164. ? persistStorageStats
  165. : backupUserStats,
  166. dailyChallenges = mergeDailyChallengeData(user.challenges)
  167. return { user, dailyChallenges }
  168. } catch (ex) {
  169. logging.error("Update")("Unable to get stats", ex)
  170. }
  171. return Promise.reject(new Error("Unable to get stats"))
  172. }
  173.  
  174. /** Grab Summary Stats. */
  175. const getSummaryStats = () => {
  176. const authToken = localStorage.getItem("player_token")
  177. return fetch("/api/v2/stats/summary", {
  178. headers: {
  179. Authorization: `Bearer ${authToken}`,
  180. },
  181. })
  182. .then((r) => r.json())
  183. .then((r) => {
  184. return {
  185. seasonBoard: r?.results?.racingStats?.find((b) => b.board === "season"),
  186. dailyBoard: r?.results?.racingStats?.find((b) => b.board === "daily"),
  187. }
  188. })
  189. .catch((err) => Promise.reject(err))
  190. }
  191.  
  192. /** Grab Stats from Team Data. */
  193. const getTeamStats = () => {
  194. if (!currentUser?.tag) {
  195. return Promise.reject(new Error("User is not in a team"))
  196. }
  197. const authToken = localStorage.getItem("player_token")
  198. return fetch(`/api/v2/teams/${currentUser.tag}`, {
  199. headers: {
  200. Authorization: `Bearer ${authToken}`,
  201. },
  202. })
  203. .then((r) => r.json())
  204. .then((r) => {
  205. return {
  206. leaderboard: r?.results?.leaderboard,
  207. motd: r?.results?.motd,
  208. info: r?.results?.info,
  209. stats: r?.results?.stats,
  210. member: r?.results?.members?.find((u) => u.userID === currentUser.userID),
  211. season: r?.results?.season?.find((u) => u.userID === currentUser.userID),
  212. }
  213. })
  214. .catch((err) => Promise.reject(err))
  215. }
  216.  
  217. /** Stat Manager widget (basically a footer with settings button). */
  218. const ToolbarWidget = ((user) => {
  219. const root = document.createElement("div")
  220. root.classList.add("nt-stats-toolbar")
  221. root.innerHTML = `
  222. <div>
  223. NOTE: Team Stats and Season Stats are cached.
  224. </div>
  225. <div class="nt-stats-toolbar-status">
  226. <div class="nt-stats-toolbar-status-item">
  227. <span class=" nt-cash-status as-nitro-cash--prefix">N/A</span>
  228. </div>
  229. <div class="nt-stats-toolbar-status-item-alt">
  230. 📦 Mystery Box: <span class="mystery-box-status">N/A</span>
  231. </div>
  232. </div>`
  233.  
  234. /** Mystery Box **/
  235. const rewardCountdown = user.rewardCountdown,
  236. mysteryBoxStatus = root.querySelector(".mystery-box-status")
  237.  
  238. let isDisabled = Date.now() < user.rewardCountdown * 1e3,
  239. timer = null
  240.  
  241. const syncCountdown = () => {
  242. isDisabled = Date.now() < user.rewardCountdown * 1e3
  243. if (!isDisabled) {
  244. if (timer) {
  245. clearInterval(timer)
  246. }
  247. mysteryBoxStatus.textContent = "Claim Now!"
  248. return
  249. }
  250. mysteryBoxStatus.textContent = moment(user.rewardCountdown * 1e3).fromNow(false)
  251. }
  252. syncCountdown()
  253. if (isDisabled) {
  254. timer = setInterval(syncCountdown, 6e3)
  255. }
  256.  
  257. /** NT Cash. */
  258. const amountNode = root.querySelector(".nt-cash-status")
  259.  
  260. return {
  261. root,
  262. updateStats: (user) => {
  263. if (typeof user?.money === "number") {
  264. amountNode.textContent = `$${user.money.toLocaleString()}`
  265. }
  266. },
  267. }
  268. })(raceObj.props.user)
  269.  
  270. /** Daily Challenge widget. */
  271. const DailyChallengeWidget = (() => {
  272. const root = document.createElement("div")
  273. root.classList.add("nt-stats-daily-challenges", "profile-dailyChallenges", "card", "card--open", "card--d", "card--grit", "card--shadow-l")
  274. root.innerHTML = `
  275. <div class="daily-challenge-list--heading">
  276. <h4>Daily Challenges</h4>
  277. <div class="daily-challenge-list--arriving">
  278. <div class="daily-challenge-list--arriving-label">
  279. <svg class="icon icon-recent-time"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.1494.svg#icon-recent-time"></use></svg>
  280. New <span></span>
  281. </div>
  282. </div>
  283. </div>
  284. <div class="daily-challenge-list--challenges"></div>`
  285.  
  286. const dailyChallengesContainer = root.querySelector(".daily-challenge-list--challenges"),
  287. dailyChallengesExpiry = root.querySelector(".daily-challenge-list--arriving-label span")
  288.  
  289. const dailyChallengeItem = document.createElement("div")
  290. dailyChallengeItem.classList.add("raceResults--dailyChallenge")
  291. dailyChallengeItem.innerHTML = `
  292. <div class="daily-challenge-progress">
  293. <div class="daily-challenge-progress--info">
  294. <div class="daily-challenge-progress--requirements">
  295. <div class="daily-challenge-progress--name">
  296. <div style="height: 19px;">
  297. <div align="left" style="white-space: nowrap; pavgSpeedosition: absolute; transform: translate(0%, 0px) scale(1, 1); left: 0px;">
  298. </div>
  299. </div>
  300. </div>
  301. <div class="daily-challenge-progress--status"></div>
  302. </div>
  303. <div class="daily-challenge-progress--progress">
  304. <div class="daily-challenge-progress--progress-bar-container">
  305. <div class="daily-challenge-progress--progress-bar" style="width: 40%"></div>
  306. <div class="daily-challenge-progress--progress-bar--earned" style="width: 40%"></div>
  307. </div>
  308. </div>
  309. </div>
  310. <div class="daily-challenge-progress--badge">
  311. <div class="daily-challenge-progress--success"></div>
  312. <div class="daily-challenge-progress--xp">
  313. <span class="daily-challenge-progress--value"></span><span class="daily-challenge-progress--divider">/</span><span class="daily-challenge-progress--target"></span>
  314. </div>
  315. <div class="daily-challenge-progress--label"></div>
  316. </div>
  317. </div>`
  318.  
  319. const updateDailyChallengeNode = (node, challenge) => {
  320. let progressPercentage = challenge.goal > 0 ? (challenge.progress / challenge.goal) * 100 : 0
  321. if (challenge.progress === challenge.goal) {
  322. progressPercentage = 100
  323. node.querySelector(".daily-challenge-progress").classList.add("is-complete")
  324. } else {
  325. node.querySelector(".daily-challenge-progress").classList.remove("is-complete")
  326. }
  327. node.querySelector(".daily-challenge-progress--name div div").textContent = challenge.title
  328. node.querySelector(".daily-challenge-progress--label").textContent = `${challenge.field}s`
  329. node.querySelector(".daily-challenge-progress--value").textContent = challenge.progress
  330. node.querySelector(".daily-challenge-progress--target").textContent = challenge.goal
  331. node.querySelector(".daily-challenge-progress--status").textContent = `Earn ${Math.floor(challenge.reward / 100) / 10}k XP`
  332. node.querySelectorAll(".daily-challenge-progress--progress-bar, .daily-challenge-progress--progress-bar--earned").forEach((bar) => {
  333. bar.style.width = `${progressPercentage}%`
  334. })
  335. }
  336.  
  337. let dailyChallengeNodes = null
  338.  
  339. getStats().then(({ dailyChallenges }) => {
  340. const dailyChallengeFragment = document.createDocumentFragment()
  341.  
  342. dailyChallengeNodes = dailyChallenges.map((c) => {
  343. const node = dailyChallengeItem.cloneNode(true)
  344. updateDailyChallengeNode(node, c)
  345.  
  346. dailyChallengeFragment.append(node)
  347.  
  348. return node
  349. })
  350. dailyChallengesContainer.append(dailyChallengeFragment)
  351. })
  352.  
  353. const updateStats = (data) => {
  354. if (!data || !dailyChallengeNodes || data.length === 0) {
  355. return
  356. }
  357. if (data[0] && data[0].expiration) {
  358. const t = 1000 * data[0].expiration
  359. if (!isNaN(t)) {
  360. dailyChallengesExpiry.textContent = moment(t).fromNow()
  361. }
  362. }
  363. data.forEach((c, i) => {
  364. if (dailyChallengeNodes[i]) {
  365. updateDailyChallengeNode(dailyChallengeNodes[i], c)
  366. }
  367. })
  368. }
  369.  
  370. return {
  371. root,
  372. updateStats,
  373. }
  374. })()
  375.  
  376. /** Display Season Progress and next Reward. */
  377. const SeasonProgressWidget = ((raceObj) => {
  378. const currentSeason = NTGLOBALS.ACTIVE_SEASONS.find((s) => {
  379. const now = Date.now()
  380. return now >= s.startStamp * 1e3 && now <= s.endStamp * 1e3
  381. })
  382.  
  383. const seasonRewards = raceObj.props?.seasonRewards,
  384. user = raceObj.props?.user
  385.  
  386. const root = document.createElement("div")
  387. root.classList.add("nt-stats-season-progress", "theme--pDefault")
  388. root.innerHTML = `
  389. <div class="season-progress-widget">
  390. <div class="season-progress-widget--info">
  391. <div class="season-progress-widget--title">Season Progress${currentSeason ? "" : " (starting soon)"}</div>
  392. <div class="season-progress-widget--current-xp"></div>
  393. <div class="season-progress-widget--current-level">
  394. <div class="season-progress-widget--current-level--prefix">Level</div>
  395. <div class="season-progress-widget--current-level--number"></div>
  396. </div>
  397. <div class="season-progress-widget--level-progress">
  398. <div class="season-progress-widget--level-progress-bar" style="width: 0%;"></div>
  399. </div>
  400. </div>
  401. <div class="season-progress-widget--next-reward">
  402. <div class="season-progress-widget--next-reward--display">
  403. <div class="season-reward-mini-preview">
  404. <div class="season-reward-mini-preview--locked">
  405. <div class="tooltip--season tooltip--xs tooltip--c" data-ttcopy="Upgrade to Nitro Gold to Unlock!">
  406. <svg class="icon icon-lock"><use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="/dist/site/images/icons/icons.css.svg#icon-lock"></use></svg>
  407. </div>
  408. </div>
  409. <a class="season-reward-mini-preview" href="/season">
  410. <div class="season-reward-mini-preview--frame">
  411. <div class="rarity-frame rarity-frame--small">
  412. <div class="rarity-frame--extra"></div>
  413. <div class="rarity-frame--content">
  414. <div class="season-reward-mini-preview--preview"></div>
  415. <div class="season-reward-mini-preview--label"></div>
  416. </div>
  417. </div>
  418. </div>
  419. </a>
  420. </div>
  421. </div>
  422. </div>
  423. </div>`
  424.  
  425. const xpTextNode = root.querySelector(".season-progress-widget--current-xp"),
  426. xpProgressBarNode = root.querySelector(".season-progress-widget--level-progress-bar"),
  427. levelNode = root.querySelector(".season-progress-widget--current-level--number"),
  428. nextRewardRootNode = root.querySelector(".season-reward-mini-preview"),
  429. nextRewardTypeLabelNode = root.querySelector(".season-reward-mini-preview--label"),
  430. nextRewardTypeLockedNode = root.querySelector(".season-reward-mini-preview--locked"),
  431. nextRewardTypePreviewNode = root.querySelector(".season-reward-mini-preview--preview"),
  432. nextRewardTypePreviewImgNode = document.createElement("img"),
  433. nextRewardRarityFrameNode = root.querySelector(".rarity-frame.rarity-frame--small")
  434.  
  435. nextRewardTypePreviewImgNode.classList.add("season-reward-mini-previewImg")
  436.  
  437. if (!currentSeason) {
  438. nextRewardRootNode.remove()
  439. }
  440.  
  441. /** Work out how much experience required to reach specific level. */
  442. const getExperienceRequired = (lvl) => {
  443. if (lvl < 1) {
  444. lvl = 1
  445. }
  446. const { startingLevels, experiencePerStartingLevel, experiencePerAchievementLevel, experiencePerExtraLevels } = NTGLOBALS.SEASON_LEVELS
  447.  
  448. let totalExpRequired = 0,
  449. amountExpRequired = experiencePerStartingLevel
  450. for (let i = 1; i < lvl; i++) {
  451. if (i <= startingLevels) {
  452. totalExpRequired += experiencePerStartingLevel
  453. } else if (currentSeason && i > currentSeason.totalRewards) {
  454. totalExpRequired += experiencePerExtraLevels
  455. amountExpRequired = experiencePerExtraLevels
  456. } else {
  457. totalExpRequired += experiencePerAchievementLevel
  458. amountExpRequired = experiencePerAchievementLevel
  459. }
  460. }
  461. return [amountExpRequired, totalExpRequired]
  462. }
  463.  
  464. /** Get next reward. */
  465. const getNextRewardID = (currentXP) => {
  466. currentXP = currentXP || user.experience
  467. if (!seasonRewards || seasonRewards.length === 0) {
  468. return null
  469. }
  470. if (user.experience === 0) {
  471. return seasonRewards[0] ? seasonRewards[0].achievementID : null
  472. }
  473. let claimed = false
  474. let nextReward = seasonRewards.find((r, i) => {
  475. if (!r.bonus && (claimed || r.experience === currentXP)) {
  476. claimed = true
  477. return false
  478. }
  479. return r.experience > currentXP || i + 1 === seasonRewards.length
  480. })
  481. if (!nextReward) {
  482. nextReward = seasonRewards[seasonRewards.length - 1]
  483. }
  484. return nextReward ? nextReward.achievementID : null
  485. }
  486.  
  487. return {
  488. root,
  489. updateStats: (data) => {
  490. // XP Progress
  491. if (typeof data.experience === "number") {
  492. const [amountExpRequired, totalExpRequired] = getExperienceRequired(data.level + 1),
  493. progress = Math.max(5, ((amountExpRequired - (totalExpRequired - data.experience)) / amountExpRequired) * 100.0) || 5
  494. xpTextNode.textContent = `${(amountExpRequired - (totalExpRequired - data.experience)).toLocaleString()} / ${amountExpRequired / 1e3}k XP`
  495. xpProgressBarNode.style.width = `${progress}%`
  496. }
  497. levelNode.textContent = currentSeason && data.level > currentSeason.totalRewards + 1 ? `∞${data.level - currentSeason.totalRewards - 1}` : data.level || 1
  498.  
  499. // Next Reward
  500. if (typeof data.experience !== "number") {
  501. return
  502. }
  503. const nextRewardID = getNextRewardID(data.experience),
  504. achievement = nextRewardID ? NTGLOBALS.ACHIEVEMENTS.LIST.find((a) => a.achievementID === nextRewardID) : null
  505. if (!achievement) {
  506. return
  507. }
  508. const { type, value } = achievement.reward
  509. if (["loot", "car"].includes(type)) {
  510. const item = type === "loot" ? NTGLOBALS.LOOT.find((l) => l.lootID === value) : NTGLOBALS.CARS.find((l) => l.carID === value)
  511. if (!item) {
  512. logging.warn("Update")(`Unable to find next reward ${type}`, achievement.reward)
  513. return
  514. }
  515.  
  516. nextRewardRootNode.className = `season-reward-mini-preview season-reward-mini-preview--${type === "loot" ? item?.type : "car"}`
  517. nextRewardTypeLabelNode.textContent = type === "loot" ? item.type || "???" : "car"
  518. nextRewardRarityFrameNode.className = `rarity-frame rarity-frame--small${item.options?.rarity ? ` rarity-frame--${item.options.rarity}` : ""}`
  519.  
  520. if (item?.type === "title") {
  521. nextRewardTypePreviewImgNode.remove()
  522. nextRewardTypePreviewNode.textContent = `"${item.name}"`
  523. } else {
  524. nextRewardTypePreviewImgNode.src = type === "loot" ? item.options?.src : `/cars/${item.options?.smallSrc}`
  525. nextRewardTypePreviewNode.innerHTML = ""
  526. nextRewardTypePreviewNode.append(nextRewardTypePreviewImgNode)
  527. }
  528. } else if (type === "money") {
  529. nextRewardTypeLabelNode.innerHTML = `<div class="as-nitro-cash--prefix">$${value.toLocaleString()}</div>`
  530. nextRewardTypePreviewImgNode.src = "/dist/site/images/pages/race/race-results-prize-cash.2.png"
  531. nextRewardRootNode.className = "season-reward-mini-preview season-reward-mini-preview--money"
  532. nextRewardRarityFrameNode.className = "rarity-frame rarity-frame--small rarity-frame--legendary"
  533. nextRewardTypePreviewNode.innerHTML = ""
  534. nextRewardTypePreviewNode.append(nextRewardTypePreviewImgNode)
  535. } else {
  536. logging.warn("Update")(`Unhandled next reward type ${type}`, achievement.reward)
  537. return
  538. }
  539.  
  540. if (!achievement.free && user.membership === "basic") {
  541. nextRewardRootNode.firstElementChild.before(nextRewardTypeLockedNode)
  542. } else {
  543. nextRewardTypeLockedNode.remove()
  544. }
  545. },
  546. }
  547. })(raceObj)
  548.  
  549. /** Displays list of player stats. */
  550. const StatWidget = (() => {
  551. const root = document.createElement("div")
  552. root.classList.add("nt-stats-info")
  553. root.innerHTML = `
  554. <div class="nt-stats-metric-row">
  555. <span class="nt-stats-metric nt-stats-metric-total-races">
  556. <span class="nt-stats-metric-heading">Total Races:</span>
  557. <span class="nt-stats-metric-value">0</span>
  558. </span>
  559. <span class="nt-stats-metric-separator">|</span>
  560. <span class="nt-stats-metric nt-stats-metric-season-races">
  561. <span class="nt-stats-metric-heading">Season Races:</span>
  562. <span class="nt-stats-metric-value">N/A</span>
  563. <span class="nt-stats-metric-separator">|</span>
  564. </span>
  565. <span class="nt-stats-metric nt-stats-metric-session-races">
  566. <span class="nt-stats-metric-heading">Current Session:</span>
  567. <span class="nt-stats-metric-value">0</div>
  568. </span>
  569. </div>
  570. <div class="nt-stats-metric-row">
  571. ${
  572. currentUser.tag
  573. ? `<span class="nt-stats-metric nt-stats-metric-team-races">
  574. <span class="nt-stats-metric-heading">Team Races:</span>
  575. <span class="nt-stats-metric-value">N/A</span>
  576. </span>
  577. <span class="nt-stats-metric-separator">|</span>
  578. <span class="nt-stats-metric nt-stats-metric-season-points">
  579. <span class="nt-stats-metric-heading">Season Points:</span>
  580. <span class="nt-stats-metric-value">N/A</span>
  581. </span>
  582. <span class="nt-stats-metric-separator">|</span>`
  583. : ``
  584. }
  585. <span class="nt-stats-metric nt-stats-metric-avg-speed">
  586. <span class="nt-stats-metric-heading">Avg Speed:</span>
  587. <span class="nt-stats-metric-value">0</span>
  588. <span class="nt-stats-metric-suffix">WPM</span>
  589. </span>
  590. <span class="nt-stats-metric-separator">|</span>
  591. <span class="nt-stats-metric nt-stats-metric-avg-accuracy">
  592. <span class="nt-stats-metric-heading">Avg Acc:</span>
  593. <span class="nt-stats-metric-value">0</span><span class="nt-stats-metric-suffix nt-stats-metric-suffix-no-space">%</span>
  594. </span>
  595. </div>`
  596.  
  597. const totalRaces = root.querySelector(".nt-stats-metric-total-races .nt-stats-metric-value"),
  598. sessionRaces = root.querySelector(".nt-stats-metric-session-races .nt-stats-metric-value"),
  599. teamRaces = currentUser.tag ? root.querySelector(".nt-stats-metric-team-races .nt-stats-metric-value") : null,
  600. seasonRaces = root.querySelector(".nt-stats-metric-season-races .nt-stats-metric-value"),
  601. seasonPoints = root.querySelector(".nt-stats-metric-season-points .nt-stats-metric-value"),
  602. avgSpeed = root.querySelector(".nt-stats-metric-avg-speed .nt-stats-metric-value"),
  603. avgAccuracy = root.querySelector(".nt-stats-metric-avg-accuracy .nt-stats-metric-value")
  604.  
  605. return {
  606. root,
  607. updateStats: (data) => {
  608. if (typeof data?.racesPlayed === "number") {
  609. totalRaces.textContent = data.racesPlayed.toLocaleString()
  610. }
  611. if (typeof data?.sessionRaces === "number") {
  612. sessionRaces.textContent = data.sessionRaces.toLocaleString()
  613. }
  614. if (typeof data?.seasonRaces === "string") {
  615. const value = parseInt(data.seasonRaces, 10)
  616. seasonRaces.textContent = isNaN(value) ? data.seasonRaces : value.toLocaleString()
  617. }
  618. if (typeof data?.seasonPoints === "number") {
  619. seasonPoints.textContent = data.seasonPoints.toLocaleString()
  620. }
  621. if (typeof data?.teamRaces === "number" && teamRaces) {
  622. teamRaces.textContent = data.teamRaces.toLocaleString()
  623. }
  624. if (typeof data?.avgAcc === "string" || typeof data?.avgAcc === "number") {
  625. avgAccuracy.textContent = data.avgAcc
  626. }
  627. if (typeof data?.avgSpeed === "number") {
  628. avgSpeed.textContent = data.avgSpeed
  629. } else if (typeof data?.avgScore === "number") {
  630. avgSpeed.textContent = data.avgScore
  631. }
  632. },
  633. }
  634. })()
  635.  
  636. ////////////
  637. // Main //
  638. ////////////
  639.  
  640. /* Add stats into race page with current values */
  641. getStats().then(({ user, dailyChallenges }) => {
  642. StatWidget.updateStats(user)
  643. SeasonProgressWidget.updateStats(user)
  644. DailyChallengeWidget.updateStats(dailyChallenges)
  645. ToolbarWidget.updateStats(user)
  646. logging.info("Update")("Start of race")
  647.  
  648. const root = document.createElement("div"),
  649. body = document.createElement("div")
  650. root.classList.add("nt-stats-root")
  651. body.classList.add("nt-stats-body")
  652.  
  653. const leftSection = document.createElement("div")
  654. leftSection.classList.add("nt-stats-left-section")
  655. leftSection.append(DailyChallengeWidget.root)
  656.  
  657. const rightSection = document.createElement("div")
  658. rightSection.classList.add("nt-stats-right-section")
  659.  
  660. rightSection.append(StatWidget.root, SeasonProgressWidget.root)
  661.  
  662. body.append(leftSection, rightSection)
  663. root.append(body, ToolbarWidget.root)
  664.  
  665. raceContainer.parentElement.append(root)
  666. })
  667.  
  668. getTeamStats().then(
  669. (data) => {
  670. const { member, season } = data
  671. StatWidget.updateStats({
  672. teamRaces: member.played,
  673. seasonPoints: season.points,
  674. })
  675. },
  676. (err) => {
  677. if (err.message !== "User is not in a team") {
  678. return Promise.reject(err)
  679. }
  680. }
  681. )
  682.  
  683. getSummaryStats().then(({ seasonBoard }) => {
  684. if (!seasonBoard) {
  685. return
  686. }
  687. StatWidget.updateStats({
  688. seasonRaces: seasonBoard.played,
  689. })
  690. })
  691.  
  692. /** Broadcast Channel to let other windows know that stats updated. */
  693. const MESSGAE_LAST_RACE_UPDATED = "last_race_updated",
  694. MESSAGE_DAILY_CHALLANGE_UPDATED = "stats_daily_challenge_updated",
  695. MESSAGE_USER_STATS_UPDATED = "stats_user_updated"
  696.  
  697. const statChannel = new BroadcastChannel("NTRacingStats")
  698. statChannel.onmessage = (e) => {
  699. const [type, payload] = e.data
  700. switch (type) {
  701. case MESSGAE_LAST_RACE_UPDATED:
  702. getStats().then(({ user, dailyChallenges }) => {
  703. StatWidget.updateStats(user)
  704. SeasonProgressWidget.updateStats(user)
  705. DailyChallengeWidget.updateStats(dailyChallenges)
  706. ToolbarWidget.updateStats(user)
  707. })
  708. break
  709. case MESSAGE_DAILY_CHALLANGE_UPDATED:
  710. DailyChallengeWidget.updateStats(payload)
  711. break
  712. case MESSAGE_USER_STATS_UPDATED:
  713. StatWidget.updateStats(payload)
  714. SeasonProgressWidget.updateStats(payload)
  715. break
  716. }
  717. }
  718.  
  719. /** Sync Daily Challenge data. */
  720. server.on("setup", (e) => {
  721. const dailyChallenges = mergeDailyChallengeData(e.challenges)
  722. DailyChallengeWidget.updateStats(dailyChallenges)
  723. statChannel.postMessage([MESSAGE_DAILY_CHALLANGE_UPDATED, dailyChallenges])
  724. })
  725.  
  726. /** Sync some of the User Stat data. */
  727. server.on("joined", (e) => {
  728. if (e.userID !== currentUser.userID) {
  729. return
  730. }
  731. const payload = {
  732. level: e.profile?.level,
  733. racesPlayed: e.profile?.racesPlayed,
  734. sessionRaces: e.profile?.sessionRaces,
  735. avgSpeed: e.profile?.avgSpeed,
  736. }
  737. StatWidget.updateStats(payload)
  738. SeasonProgressWidget.updateStats(payload)
  739. statChannel.postMessage([MESSAGE_USER_STATS_UPDATED, payload])
  740. })
  741.  
  742. /** Track Race Finish exact time. */
  743. let hasCollectedResultStats = false
  744.  
  745. server.on("update", (e) => {
  746. const me = e?.racers?.find((r) => r.userID === currentUser.userID)
  747. if (me.progress.completeStamp > 0 && me.rewards?.current && !hasCollectedResultStats) {
  748. hasCollectedResultStats = true
  749. db.backupStatData.put({ ...me.rewards.current, challenges: me.challenges, userID: currentUser.userID }).then(() => {
  750. statChannel.postMessage([MESSGAE_LAST_RACE_UPDATED])
  751. })
  752. }
  753. })
  754.  
  755. /** Mutation observer to check if Racing Result has shown up. */
  756. const resultObserver = new MutationObserver(([mutation], observer) => {
  757. for (const node of mutation.addedNodes) {
  758. if (node.classList?.contains("race-results")) {
  759. observer.disconnect()
  760. logging.info("Update")("Race Results received")
  761. getStats().then(({ user, dailyChallenges }) => {
  762. StatWidget.updateStats(user)
  763. SeasonProgressWidget.updateStats(user)
  764. DailyChallengeWidget.updateStats(dailyChallenges)
  765. ToolbarWidget.updateStats(user)
  766. })
  767. getSummaryStats().then(({ seasonBoard }) => {
  768. if (!seasonBoard) {
  769. return
  770. }
  771. StatWidget.updateStats({
  772. seasonRaces: seasonBoard.played,
  773. })
  774. })
  775. getTeamStats().then((data) => {
  776. const { member, season } = data
  777. StatWidget.updateStats({
  778. teamRaces: member.played,
  779. seasonPoints: season.points,
  780. })
  781. })
  782. break
  783. }
  784. }
  785. })
  786. resultObserver.observe(raceContainer, { childList: true, subtree: true })