您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Displays various user stats below the race track.
当前为
// ==UserScript== // @name Nitro Type - Racing Stats // @version 0.1.4 // @description Displays various user stats below the race track. // @author Toonidy // @match *://*.nitrotype.com/race // @match *://*.nitrotype.com/race/* // @icon data:image/png;base64,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 // @grant none // @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js#sha512-qTXRIMyZIFb8iQcfjXWCO8+M5Tbc38Qi5WzdPOYZHIlZpzBHG3L3by84BBBOiRGiEb7KKtAOAs5qYdUiZiQNNQ== // @require https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.1/dexie.min.js#sha512-ybuxSW2YL5rQG/JjACOUKLiosgV80VUfJWs4dOpmSWZEGwdfdsy2ldvDSQ806dDXGmg9j/csNycIbqsrcqW6tQ== // @require https://greasyfork.org/scripts/443718-nitro-type-userscript-utils/code/Nitro%20Type%20Userscript%20Utils.js?version=1042360 // @license MIT // @namespace https://greasyfork.org/users/858426 // ==/UserScript== /* global Dexie moment NTGLOBALS createLogger findReact */ const logging = createLogger("Nitro Type Racing Stats") /* Config storage */ const db = new Dexie("NTRacingStats") db.version(1).stores({ backupStatData: "userID", }) db.open().catch(function (e) { logging.error("Init")("Failed to open up the racing stat cache database", e) }) //////////// // Init // //////////// const raceContainer = document.getElementById("raceContainer"), raceObj = raceContainer ? findReact(raceContainer) : null, server = raceObj?.server, currentUser = raceObj?.props.user if (!raceContainer || !raceObj) { logging.error("Init")("Could not find the race track") return } if (!currentUser?.loggedIn) { logging.error("Init")("Not available for Guest Racing") return } ////////////////// // Components // ////////////////// /** Styles for the following components. */ const style = document.createElement("style") style.appendChild( document.createTextNode(` #raceContainer { margin-bottom: 0; } .nt-stats-root { background-color: #222; } .nt-stats-body { display: flex; justify-content: space-between; padding: 8px; } .nt-stats-left-section, .nt-stats-right-section { display: flex; flex-direction: column; row-gap: 8px; } .nt-stats-toolbar { display: flex; justify-content: space-between; align-items: center; padding-left: 8px; color: rgba(255, 255, 255, 0.8); background-color: #03111a; font-size: 12px; } .nt-stats-toolbar-status { display: flex; } .nt-stats-toolbar-status .nt-stats-toolbar-status-item { padding: 0 8px; background-color: #0a2c42; } .nt-stats-toolbar-status .nt-stats-toolbar-status-item-alt { padding: 0 8px; background-color: #22465c; } .nt-stats-daily-challenges { width: 350px; } .nt-stats-daily-challenges .daily-challenge-progress--badge { z-index: 0; } .nt-stats-season-progress { padding: 8px; margin: 0 auto; border-radius: 8px; background-color: #1b83d0; 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%); } .nt-stats-season-progress .season-progress-widget { width: 350px; } .nt-stats-season-progress .season-progress-widget--level-progress-bar { transition: width 0.3s ease; } .nt-stats-info { text-align: center; color: #eee; font-size: 14px; } .nt-stats-metric-row { margin-bottom: 4px; } .nt-stats-metric-value, .nt-stats-metric-suffix { font-weight: 600; } .nt-stats-metric-suffix { color: #aaa; } .nt-stats-right-section { flex-grow: 1; margin-left: 15px; }`) ) document.head.appendChild(style) /** Populates daily challenge data merges in the given progress. */ const mergeDailyChallengeData = (progress) => { const { CHALLENGES, CHALLENGE_TYPES } = NTGLOBALS, now = Math.floor(Date.now() / 1000) return CHALLENGES.filter((c) => c.expiration > now) .slice(0, 3) .map((c, i) => { const userProgress = progress.find((p) => p.challengeID === c.challengeID), challengeType = CHALLENGE_TYPES[c.type], field = challengeType[1], title = challengeType[0].replace(/\$\{goal\}/, c.goal).replace(/\$\{field\}/, `${challengeType[1]}${c.goal !== 1 ? "s" : ""}`) return { ...c, title, field, goal: c.goal, progress: userProgress?.progress || 0, } }) } /** Grab NT Racing Stats from various sources. */ const getStats = async () => { let backupUserStats = null try { backupUserStats = await db.backupStatData.get(currentUser.userID) } catch (ex) { logging.warn("Update")("Unable to get backup stats", ex) } try { const persistStorageStats = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user), user = !backupUserStats || typeof backupUserStats.lastConsecRace !== "number" || persistStorageStats.lastConsecRace >= backupUserStats.lastConsecRace ? persistStorageStats : backupUserStats, dailyChallenges = mergeDailyChallengeData(user.challenges) return { user, dailyChallenges } } catch (ex) { logging.error("Update")("Unable to get stats", ex) } return Promise.reject(new Error("Unable to get stats")) } /** Grab Summary Stats. */ const getSummaryStats = () => { const authToken = localStorage.getItem("player_token") return fetch("/api/v2/stats/summary", { headers: { Authorization: `Bearer ${authToken}`, }, }) .then((r) => r.json()) .then((r) => { return { seasonBoard: r?.results?.racingStats?.find((b) => b.board === "season"), dailyBoard: r?.results?.racingStats?.find((b) => b.board === "daily"), } }) .catch((err) => Promise.reject(err)) } /** Grab Stats from Team Data. */ const getTeamStats = () => { if (!currentUser?.tag) { return Promise.reject(new Error("User is not in a team")) } const authToken = localStorage.getItem("player_token") return fetch(`/api/v2/teams/${currentUser.tag}`, { headers: { Authorization: `Bearer ${authToken}`, }, }) .then((r) => r.json()) .then((r) => { return { leaderboard: r?.results?.leaderboard, motd: r?.results?.motd, info: r?.results?.info, stats: r?.results?.stats, member: r?.results?.members?.find((u) => u.userID === currentUser.userID), season: r?.results?.season?.find((u) => u.userID === currentUser.userID), } }) .catch((err) => Promise.reject(err)) } /** Stat Manager widget (basically a footer with settings button). */ const ToolbarWidget = ((user) => { const root = document.createElement("div") root.classList.add("nt-stats-toolbar") root.innerHTML = ` <div> NOTE: Team Stats and Season Stats are cached. </div> <div class="nt-stats-toolbar-status"> <div class="nt-stats-toolbar-status-item"> <span class=" nt-cash-status as-nitro-cash--prefix">N/A</span> </div> <div class="nt-stats-toolbar-status-item-alt"> 📦 Mystery Box: <span class="mystery-box-status">N/A</span> </div> </div>` /** Mystery Box **/ const rewardCountdown = user.rewardCountdown, mysteryBoxStatus = root.querySelector(".mystery-box-status") let isDisabled = Date.now() < user.rewardCountdown * 1e3, timer = null const syncCountdown = () => { isDisabled = Date.now() < user.rewardCountdown * 1e3 if (!isDisabled) { if (timer) { clearInterval(timer) } mysteryBoxStatus.textContent = "Claim Now!" return } mysteryBoxStatus.textContent = moment(user.rewardCountdown * 1e3).fromNow(false) } syncCountdown() if (isDisabled) { timer = setInterval(syncCountdown, 6e3) } /** NT Cash. */ const amountNode = root.querySelector(".nt-cash-status") return { root, updateStats: (user) => { if (typeof user?.money === "number") { amountNode.textContent = `$${user.money.toLocaleString()}` } }, } })(raceObj.props.user) /** Daily Challenge widget. */ const DailyChallengeWidget = (() => { const root = document.createElement("div") root.classList.add("nt-stats-daily-challenges", "profile-dailyChallenges", "card", "card--open", "card--d", "card--grit", "card--shadow-l") root.innerHTML = ` <div class="daily-challenge-list--heading"> <h4>Daily Challenges</h4> <div class="daily-challenge-list--arriving"> <div class="daily-challenge-list--arriving-label"> <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> New <span></span> </div> </div> </div> <div class="daily-challenge-list--challenges"></div>` const dailyChallengesContainer = root.querySelector(".daily-challenge-list--challenges"), dailyChallengesExpiry = root.querySelector(".daily-challenge-list--arriving-label span") const dailyChallengeItem = document.createElement("div") dailyChallengeItem.classList.add("raceResults--dailyChallenge") dailyChallengeItem.innerHTML = ` <div class="daily-challenge-progress"> <div class="daily-challenge-progress--info"> <div class="daily-challenge-progress--requirements"> <div class="daily-challenge-progress--name"> <div style="height: 19px;"> <div align="left" style="white-space: nowrap; pavgSpeedosition: absolute; transform: translate(0%, 0px) scale(1, 1); left: 0px;"> </div> </div> </div> <div class="daily-challenge-progress--status"></div> </div> <div class="daily-challenge-progress--progress"> <div class="daily-challenge-progress--progress-bar-container"> <div class="daily-challenge-progress--progress-bar" style="width: 40%"></div> <div class="daily-challenge-progress--progress-bar--earned" style="width: 40%"></div> </div> </div> </div> <div class="daily-challenge-progress--badge"> <div class="daily-challenge-progress--success"></div> <div class="daily-challenge-progress--xp"> <span class="daily-challenge-progress--value"></span><span class="daily-challenge-progress--divider">/</span><span class="daily-challenge-progress--target"></span> </div> <div class="daily-challenge-progress--label"></div> </div> </div>` const updateDailyChallengeNode = (node, challenge) => { let progressPercentage = challenge.goal > 0 ? (challenge.progress / challenge.goal) * 100 : 0 if (challenge.progress === challenge.goal) { progressPercentage = 100 node.querySelector(".daily-challenge-progress").classList.add("is-complete") } else { node.querySelector(".daily-challenge-progress").classList.remove("is-complete") } node.querySelector(".daily-challenge-progress--name div div").textContent = challenge.title node.querySelector(".daily-challenge-progress--label").textContent = `${challenge.field}s` node.querySelector(".daily-challenge-progress--value").textContent = challenge.progress node.querySelector(".daily-challenge-progress--target").textContent = challenge.goal node.querySelector(".daily-challenge-progress--status").textContent = `Earn ${Math.floor(challenge.reward / 100) / 10}k XP` node.querySelectorAll(".daily-challenge-progress--progress-bar, .daily-challenge-progress--progress-bar--earned").forEach((bar) => { bar.style.width = `${progressPercentage}%` }) } let dailyChallengeNodes = null getStats().then(({ dailyChallenges }) => { const dailyChallengeFragment = document.createDocumentFragment() dailyChallengeNodes = dailyChallenges.map((c) => { const node = dailyChallengeItem.cloneNode(true) updateDailyChallengeNode(node, c) dailyChallengeFragment.append(node) return node }) dailyChallengesContainer.append(dailyChallengeFragment) }) const updateStats = (data) => { if (!data || !dailyChallengeNodes || data.length === 0) { return } if (data[0] && data[0].expiration) { const t = 1000 * data[0].expiration if (!isNaN(t)) { dailyChallengesExpiry.textContent = moment(t).fromNow() } } data.forEach((c, i) => { if (dailyChallengeNodes[i]) { updateDailyChallengeNode(dailyChallengeNodes[i], c) } }) } return { root, updateStats, } })() /** Display Season Progress and next Reward. */ const SeasonProgressWidget = ((raceObj) => { const currentSeason = NTGLOBALS.ACTIVE_SEASONS.find((s) => { const now = Date.now() return now >= s.startStamp * 1e3 && now <= s.endStamp * 1e3 }) const seasonRewards = raceObj.props?.seasonRewards, user = raceObj.props?.user const root = document.createElement("div") root.classList.add("nt-stats-season-progress", "theme--pDefault") root.innerHTML = ` <div class="season-progress-widget"> <div class="season-progress-widget--info"> <div class="season-progress-widget--title">Season Progress${currentSeason ? "" : " (starting soon)"}</div> <div class="season-progress-widget--current-xp"></div> <div class="season-progress-widget--current-level"> <div class="season-progress-widget--current-level--prefix">Level</div> <div class="season-progress-widget--current-level--number"></div> </div> <div class="season-progress-widget--level-progress"> <div class="season-progress-widget--level-progress-bar" style="width: 0%;"></div> </div> </div> <div class="season-progress-widget--next-reward"> <div class="season-progress-widget--next-reward--display"> <div class="season-reward-mini-preview"> <div class="season-reward-mini-preview--locked"> <div class="tooltip--season tooltip--xs tooltip--c" data-ttcopy="Upgrade to Nitro Gold to Unlock!"> <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> </div> </div> <a class="season-reward-mini-preview" href="/season"> <div class="season-reward-mini-preview--frame"> <div class="rarity-frame rarity-frame--small"> <div class="rarity-frame--extra"></div> <div class="rarity-frame--content"> <div class="season-reward-mini-preview--preview"></div> <div class="season-reward-mini-preview--label"></div> </div> </div> </div> </a> </div> </div> </div> </div>` const xpTextNode = root.querySelector(".season-progress-widget--current-xp"), xpProgressBarNode = root.querySelector(".season-progress-widget--level-progress-bar"), levelNode = root.querySelector(".season-progress-widget--current-level--number"), nextRewardRootNode = root.querySelector(".season-reward-mini-preview"), nextRewardTypeLabelNode = root.querySelector(".season-reward-mini-preview--label"), nextRewardTypeLockedNode = root.querySelector(".season-reward-mini-preview--locked"), nextRewardTypePreviewNode = root.querySelector(".season-reward-mini-preview--preview"), nextRewardTypePreviewImgNode = document.createElement("img"), nextRewardRarityFrameNode = root.querySelector(".rarity-frame.rarity-frame--small") nextRewardTypePreviewImgNode.classList.add("season-reward-mini-previewImg") if (!currentSeason) { nextRewardRootNode.remove() } /** Work out how much experience required to reach specific level. */ const getExperienceRequired = (lvl) => { if (lvl < 1) { lvl = 1 } const { startingLevels, experiencePerStartingLevel, experiencePerAchievementLevel, experiencePerExtraLevels } = NTGLOBALS.SEASON_LEVELS let totalExpRequired = 0, amountExpRequired = experiencePerStartingLevel for (let i = 1; i < lvl; i++) { if (i <= startingLevels) { totalExpRequired += experiencePerStartingLevel } else if (currentSeason && i > currentSeason.totalRewards) { totalExpRequired += experiencePerExtraLevels amountExpRequired = experiencePerExtraLevels } else { totalExpRequired += experiencePerAchievementLevel amountExpRequired = experiencePerAchievementLevel } } return [amountExpRequired, totalExpRequired] } /** Get next reward. */ const getNextRewardID = (currentXP) => { currentXP = currentXP || user.experience if (!seasonRewards || seasonRewards.length === 0) { return null } if (user.experience === 0) { return seasonRewards[0] ? seasonRewards[0].achievementID : null } let claimed = false let nextReward = seasonRewards.find((r, i) => { if (!r.bonus && (claimed || r.experience === currentXP)) { claimed = true return false } return r.experience > currentXP || i + i === seasonRewards.length }) if (!nextReward) { nextReward = seasonRewards[seasonRewards.length - 1] } return nextReward ? nextReward.achievementID : null } return { root, updateStats: (data) => { // XP Progress if (typeof data.experience === "number") { const [amountExpRequired, totalExpRequired] = getExperienceRequired(data.level + 1), progress = Math.max(5, ((amountExpRequired - (totalExpRequired - data.experience)) / amountExpRequired) * 100.0) || 5 xpTextNode.textContent = `${(amountExpRequired - (totalExpRequired - data.experience)).toLocaleString()} / ${amountExpRequired / 1e3}k XP` xpProgressBarNode.style.width = `${progress}%` } levelNode.textContent = currentSeason && data.level > currentSeason.totalRewards + 1 ? `∞${data.level - currentSeason.totalRewards - 1}` : data.level || 1 // Next Reward if (typeof data.experience !== "number") { return } const nextRewardID = getNextRewardID(data.experience), achievement = nextRewardID ? NTGLOBALS.ACHIEVEMENTS.LIST.find((a) => a.achievementID === nextRewardID) : null if (!achievement) { return } const { type, value } = achievement.reward if (["loot", "car"].includes(type)) { const item = type === "loot" ? NTGLOBALS.LOOT.find((l) => l.lootID === value) : NTGLOBALS.CARS.find((l) => l.carID === value) if (!item) { logging.warn("Update")(`Unable to find next reward ${type}`, achievement.reward) return } nextRewardRootNode.className = `season-reward-mini-preview season-reward-mini-preview--${type === "loot" ? item?.type : "car"}` nextRewardTypeLabelNode.textContent = type === "loot" ? item.type || "???" : "car" nextRewardRarityFrameNode.className = `rarity-frame rarity-frame--small${item.options?.rarity ? ` rarity-frame--${item.options.rarity}` : ""}` if (item?.type === "title") { nextRewardTypePreviewImgNode.remove() nextRewardTypePreviewNode.textContent = `"${item.name}"` } else { nextRewardTypePreviewImgNode.src = type === "loot" ? item.options?.src : `/cars/${item.options?.smallSrc}` nextRewardTypePreviewNode.innerHTML = "" nextRewardTypePreviewNode.append(nextRewardTypePreviewImgNode) } } else if (type === "money") { nextRewardTypeLabelNode.innerHTML = `<div class="as-nitro-cash--prefix">$${value.toLocaleString()}</div>` nextRewardTypePreviewImgNode.src = "/dist/site/images/pages/race/race-results-prize-cash.2.png" nextRewardRootNode.className = "season-reward-mini-preview season-reward-mini-preview--money" nextRewardRarityFrameNode.className = "rarity-frame rarity-frame--small rarity-frame--legendary" nextRewardTypePreviewNode.innerHTML = "" nextRewardTypePreviewNode.append(nextRewardTypePreviewImgNode) } else { logging.warn("Update")(`Unhandled next reward type ${type}`, achievement.reward) return } if (!achievement.free && user.membership === "basic") { nextRewardRootNode.firstElementChild.before(nextRewardTypeLockedNode) } else { nextRewardTypeLockedNode.remove() } }, } })(raceObj) /** Displays list of player stats. */ const StatWidget = (() => { const root = document.createElement("div") root.classList.add("nt-stats-info") root.innerHTML = ` <div class="nt-stats-metric-row"> <span class="nt-stats-metric nt-stats-metric-total-races"> <span class="nt-stats-metric-heading">Total Races:</span> <span class="nt-stats-metric-value">0</span> </span> <span class="nt-stats-metric-separator">|</span> <span class="nt-stats-metric nt-stats-metric-season-races"> <span class="nt-stats-metric-heading">Season Races:</span> <span class="nt-stats-metric-value">N/A</span> </span> ${ currentUser.tag ? `<span class="nt-stats-metric-separator">|</span> <span class="nt-stats-metric nt-stats-metric-team-races"> <span class="nt-stats-metric-heading">Team Races:</span> <span class="nt-stats-metric-value">N/A</span> </span>` : `` } <span class="nt-stats-metric-separator">|</span> <span class="nt-stats-metric nt-stats-metric-session-races"> <span class="nt-stats-metric-heading">Current Session:</span> <span class="nt-stats-metric-value">0</div> </span> </div> <div class="nt-stats-metric-row"> <span class="nt-stats-metric nt-stats-metric-avg-speed"> <span class="nt-stats-metric-heading">Avg Speed:</span> <span class="nt-stats-metric-value">0</span> <span class="nt-stats-metric-suffix">WPM</span> </span> <span class="nt-stats-metric-separator">|</span> <span class="nt-stats-metric nt-stats-metric-avg-accuracy"> <span class="nt-stats-metric-heading">Avg Acc:</span> <span class="nt-stats-metric-value">0</span><span class="nt-stats-metric-suffix nt-stats-metric-suffix-no-space">%</span> </span> </div>` const totalRaces = root.querySelector(".nt-stats-metric-total-races .nt-stats-metric-value"), sessionRaces = root.querySelector(".nt-stats-metric-session-races .nt-stats-metric-value"), teamRaces = currentUser.tag ? root.querySelector(".nt-stats-metric-team-races .nt-stats-metric-value") : null, seasonRaces = root.querySelector(".nt-stats-metric-season-races .nt-stats-metric-value"), avgSpeed = root.querySelector(".nt-stats-metric-avg-speed .nt-stats-metric-value"), avgAccuracy = root.querySelector(".nt-stats-metric-avg-accuracy .nt-stats-metric-value") return { root, updateStats: (data) => { if (typeof data?.racesPlayed === "number") { totalRaces.textContent = data.racesPlayed.toLocaleString() } if (typeof data?.sessionRaces === "number") { sessionRaces.textContent = data.sessionRaces.toLocaleString() } if (typeof data?.seasonRaces === "string") { const value = parseInt(data.seasonRaces, 10) seasonRaces.textContent = isNaN(value) ? data.seasonRaces : value.toLocaleString() } if (typeof data?.teamRaces === "number" && teamRaces) { teamRaces.textContent = data.teamRaces.toLocaleString() } if (typeof data?.avgAcc === "string" || typeof data?.avgAcc === "number") { avgAccuracy.textContent = data.avgAcc } if (typeof data?.avgSpeed === "number") { avgSpeed.textContent = data.avgSpeed } else if (typeof data?.avgScore === "number") { avgSpeed.textContent = data.avgScore } }, } })() //////////// // Main // //////////// /* Add stats into race page with current values */ getStats().then(({ user, dailyChallenges }) => { StatWidget.updateStats(user) SeasonProgressWidget.updateStats(user) DailyChallengeWidget.updateStats(dailyChallenges) ToolbarWidget.updateStats(user) logging.info("Update")("Start of race") const root = document.createElement("div"), body = document.createElement("div") root.classList.add("nt-stats-root") body.classList.add("nt-stats-body") const leftSection = document.createElement("div") leftSection.classList.add("nt-stats-left-section") leftSection.append(DailyChallengeWidget.root) const rightSection = document.createElement("div") rightSection.classList.add("nt-stats-right-section") rightSection.append(StatWidget.root, SeasonProgressWidget.root) body.append(leftSection, rightSection) root.append(body, ToolbarWidget.root) raceContainer.parentElement.append(root) }) getTeamStats().then( (data) => { const { member } = data StatWidget.updateStats({ teamRaces: member.played, }) }, (err) => { if (err.message !== "User is not in a team") { return Promise.reject(err) } } ) getSummaryStats().then(({ seasonBoard }) => { if (!seasonBoard) { return } StatWidget.updateStats({ seasonRaces: seasonBoard.played, }) }) /** Broadcast Channel to let other windows know that stats updated. */ const MESSGAE_LAST_RACE_UPDATED = "last_race_updated", MESSAGE_DAILY_CHALLANGE_UPDATED = "stats_daily_challenge_updated", MESSAGE_USER_STATS_UPDATED = "stats_user_updated" const statChannel = new BroadcastChannel("NTRacingStats") statChannel.onmessage = (e) => { const [type, payload] = e.data switch (type) { case MESSGAE_LAST_RACE_UPDATED: getStats().then(({ user, dailyChallenges }) => { StatWidget.updateStats(user) SeasonProgressWidget.updateStats(user) DailyChallengeWidget.updateStats(dailyChallenges) ToolbarWidget.updateStats(user) }) break case MESSAGE_DAILY_CHALLANGE_UPDATED: DailyChallengeWidget.updateStats(payload) break case MESSAGE_USER_STATS_UPDATED: StatWidget.updateStats(payload) SeasonProgressWidget.updateStats(payload) break } } /** Sync Daily Challenge data. */ server.on("setup", (e) => { const dailyChallenges = mergeDailyChallengeData(e.challenges) DailyChallengeWidget.updateStats(dailyChallenges) statChannel.postMessage([MESSAGE_DAILY_CHALLANGE_UPDATED, dailyChallenges]) }) /** Sync some of the User Stat data. */ server.on("joined", (e) => { if (e.userID !== currentUser.userID) { return } const payload = { level: e.profile?.level, racesPlayed: e.profile?.racesPlayed, sessionRaces: e.profile?.sessionRaces, avgSpeed: e.profile?.avgSpeed, } StatWidget.updateStats(payload) SeasonProgressWidget.updateStats(payload) statChannel.postMessage([MESSAGE_USER_STATS_UPDATED, payload]) }) /** Track Race Finish exact time. */ let hasCollectedResultStats = false server.on("update", (e) => { const me = e?.racers?.find((r) => r.userID === currentUser.userID) if (me.progress.completeStamp > 0 && me.rewards?.current && !hasCollectedResultStats) { hasCollectedResultStats = true db.backupStatData.put({ ...me.rewards.current, challenges: me.challenges, userID: currentUser.userID }).then(() => { statChannel.postMessage([MESSGAE_LAST_RACE_UPDATED]) }) } }) /** Mutation observer to check if Racing Result has shown up. */ const resultObserver = new MutationObserver(([mutation], observer) => { for (const node of mutation.addedNodes) { if (node.classList?.contains("race-results")) { observer.disconnect() logging.info("Update")("Race Results received") getStats().then(({ user, dailyChallenges }) => { StatWidget.updateStats(user) SeasonProgressWidget.updateStats(user) DailyChallengeWidget.updateStats(dailyChallenges) ToolbarWidget.updateStats(user) }) getSummaryStats().then(({ seasonBoard }) => { if (!seasonBoard) { return } StatWidget.updateStats({ seasonRaces: seasonBoard.played, }) }) getTeamStats().then((data) => { const { member } = data StatWidget.updateStats({ teamRaces: member.played, }) }) break } } }) resultObserver.observe(raceContainer, { childList: true })