Nitro Type - DPH configuration

Stats & Minimap & Reload

当前为 2024-09-24 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Nitro Type - DPH configuration
// @version      0.1.0
// @description  Stats & Minimap & Reload
// @author       dphdmn / A lot of code by Toonidy is used
// @match        *://*.nitrotype.com/race
// @match        *://*.nitrotype.com/race/*
// @icon         https://static.wikia.nocookie.net/nitro-type/images/8/85/175_large_1.png/revision/latest?cb=20181229003942
// @grant GM_setValue
// @grant GM_getValue
// @namespace    dphdmn
// @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://cdnjs.cloudflare.com/ajax/libs/pixi.js/6.5.4/browser/pixi.min.js
// @license      MIT
// ==/UserScript==

/* global Dexie moment NTGLOBALS PIXI */

//scroll page to the bottom, requires a combination with my css theme to work properly
const scrollPage = true;
//if greedy is activated, script will check stats every 50ms, and if new stats are available,
//immediately update the widget and reload (it's really fast and should never fail unlike arbitrary delay using classic update script
const greedyStatsReload = true;
const greedyStatsReloadInt = 50;
//reload on stats (unecessary slow, after stats window is displayed)
const reloadOnStats = true;
//const autoReloadMS = 0;
//difference of total races and races in current team, for more accurate value in team races stats
const TEAM_RACES_DIF = 1493;
//difference of total races and races done outside of current season for more accurate value
const CURRENT_SEASON_DIF = 25 + 5594;
const ENABLE_MINI_MAP = true;

//MINIMAP config
const config = {
    colors: {
        me: 0xFF69B4, // Bright pink
        opponentPlayer: 0x00FFFF, // Cyan
        opponentBot: 0xbbbbbb, // This stays the same
        opponentWampus: 0xFFA500, // Bright orange
        nitro: 0xef9e18, // This stays the same
        //background: 0x000000,
        raceLane: 0x555555, // This stays the same
        startLine: 0x929292, // This stays the same
        finishLine: 0x929292 // This stays the same
    },
    trackLocally: true, // whether to track your player's position without server data
    moveDestination: {
        enabled: true,
        alpha: 0.3, // range 0.0 - 1.0 (the lower the value, the more transparent)
    },
}


/** Finds the React Component from given dom. */
const findReact = (dom, traverseUp = 0) => {
    const key = Object.keys(dom).find((key) => key.startsWith("__reactFiber$"))
    const domFiber = dom[key]
    if (domFiber == null) return null
    const getCompFiber = (fiber) => {
        let parentFiber = fiber?.return
        while (typeof parentFiber?.type == "string") {
            parentFiber = parentFiber?.return
        }
        return parentFiber
    }
    let compFiber = getCompFiber(domFiber)
    for (let i = 0; i < traverseUp && compFiber; i++) {
        compFiber = getCompFiber(compFiber)
    }
    return compFiber?.stateNode
}

/** Create a Console Logger with some prefixing. */
const createLogger = (namespace) => {
    const logPrefix = (prefix = "") => {
        const formatMessage = `%c[${namespace}]${prefix ? `%c[${prefix}]` : ""}`
        let args = [console, `${formatMessage}%c`, "background-color: #D62F3A; color: #fff; font-weight: bold"]
        if (prefix) {
            args = args.concat("background-color: #4f505e; color: #fff; font-weight: bold")
        }
        return args.concat("color: unset")
    }
    return {
        info: (prefix) => Function.prototype.bind.apply(console.info, logPrefix(prefix)),
        warn: (prefix) => Function.prototype.bind.apply(console.warn, logPrefix(prefix)),
        error: (prefix) => Function.prototype.bind.apply(console.error, logPrefix(prefix)),
        log: (prefix) => Function.prototype.bind.apply(console.log, logPrefix(prefix)),
        debug: (prefix) => Function.prototype.bind.apply(console.debug, logPrefix(prefix)),
    }
}

function logstats() {
    const raceContainer = document.getElementById("raceContainer"),
        canvasTrack = raceContainer?.querySelector("canvas"),
        raceObj = raceContainer ? findReact(raceContainer) : null;
    const currentUserID = raceObj.props.user.userID;
    const currentUserResult = raceObj.state.racers.find((r) => r.userID === currentUserID)
    if (!currentUserResult || !currentUserResult.progress || typeof currentUserResult.place === "undefined") {
        console.log("STATS LOGGER: Unable to find race results");
        return
    }

    const {
        typed,
        skipped,
        startStamp,
        completeStamp,
        errors
    } = currentUserResult.progress,
        wpm = Math.round((typed - skipped) / 5 / ((completeStamp - startStamp) / 6e4)),
        time = ((completeStamp - startStamp) / 1e3).toFixed(2),
        acc = ((1 - errors / (typed - skipped)) * 100).toFixed(2),
        points = Math.round((100 + wpm / 2) * (1 - errors / (typed - skipped))),
        place = currentUserResult.place

    console.log(`STATS LOGGER: ${place} | ${acc}% Acc | ${wpm} WPM | ${points} points | ${time} secs`)
}
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(`

   .racev3-track {
   margin-top: -30px;
   }

 .header-bar--return-to-garage{
 display: none !important;
 }

.dropdown {
display: none !important;
}

.header-nav {
display: none !important;
}
.logo-SVG {
height: 50% !important;
width: 50% important;
}
#raceContainer {
    margin-bottom: 0;
}
.nt-stats-root {
    text-shadow:
        0.05em 0 black,
        0 0.05em black,
        -0.05em 0 black,
        0 -0.05em black,
        -0.05em -0.05em black,
        -0.05em 0.05em black,
        0.05em -0.05em black,
        0.05em 0.05em black;
}
.nt-stats-body {
    display: flex;
    justify-content: space-between;
    padding: 8px;
        background: linear-gradient(rgba(0, 0, 0, 0.66), rgba(0, 0, 0, 0.66)), fixed url(https://getwallpapers.com/wallpaper/full/1/3/a/171084.jpg);
}
.nt-stats-left-section {
    display: none;
}
.nt-stats-right-section  {
    display: flex;
    flex-direction: column;
    row-gap: 8px;
}
.nt-stats-toolbar {
    display: none;
    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 {
    display: none;
    padding: 8px;
    margin: 0 auto;
    border-radius: 8px;
    background-color: #3b3b3b;
    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;
    opacity: 80%
}
.nt-stats-metric-row {
    margin-bottom: 4px;
}
.nt-stats-metric-value, .nt-stats-metric-suffix {
    font-weight: 300;
    color: cyan;
}
.nt-stats-metric-value {
color: rgb(0, 245, 245);
}
.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 () => {
    //await new Promise(resolve => setTimeout(resolve, 3000));
    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 + 1 === 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-session-races">
                <span class="nt-stats-metric-heading">Session:</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-rta">
                <span class="nt-stats-metric-heading">Real time:</span>
                <span class="nt-stats-metric-value">0</span>
            </span>
        </div>
        <div class="nt-stats-metric-row">
            <span class="nt-stats-metric nt-stats-metric-total-races">
                <span class="nt-stats-metric-heading">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:</span>
                <span class="nt-stats-metric-value">N/A</span>
            <span class="nt-stats-metric-separator">|</span>
            </span>
            ${
                currentUser.tag
                    ? `<span class="nt-stats-metric nt-stats-metric-team-races">
                <span class="nt-stats-metric-heading">Team:</span>
                <span class="nt-stats-metric-value">N/A</span>
                <span class="nt-stats-metric-separator">)</span>
            </span>`
                    : ``
            }
        </div>
        <div class="nt-stats-metric-row">
            <span class="nt-stats-metric nt-stats-metric-playtime">
                <span class="nt-stats-metric-heading">Playtime:</span>
                <span class="nt-stats-metric-value">0</span>
            </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:</span>
                <span class="nt-stats-metric-value">0</span>
                <span class="nt-stats-metric-suffix">WPM | </span>
            </span>
            <span class="nt-stats-metric nt-stats-metric-avg-accuracy">
                <span class="nt-stats-metric-value">0</span>
                <span class="nt-stats-metric-suffix nt-stats-metric-suffix-no-space">% | </span>
            </span>
            <span class="nt-stats-metric nt-stats-metric-avg-time">
                <span class="nt-stats-metric-value">0</span>
                <span class="nt-stats-metric-suffix nt-stats-metric-suffix-no-space">s</span>
            </span>
        </div>
        <div class="nt-stats-metric-row">
            <span class="nt-stats-metric nt-stats-metric-last-race">
                <span class="nt-stats-metric-heading">Last:</span>
                <span class="nt-stats-metric-value">N/A</span>
            </span>
        </div>
        </div>`

    if (greedyStatsReload) {
        const currentTime = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user).lastConsecRace;

        function checkendgreedy(lasttime) {
            // console.log("Running another interval");
            const newtime = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user).lastConsecRace;
            if (newtime > lasttime) {
                // console.log("new time is different!");
                clearInterval(intervalId);
                getStats().then(({
                    user,
                    dailyChallenges
                }) => {
                    StatWidget.updateStats(user)
                    if (reloadOnStats) {
                        location.reload()
                    }
                })
            }
        }
        const intervalId = setInterval(checkendgreedy, greedyStatsReloadInt, currentTime);
    }


    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"),
        lastRace = root.querySelector(".nt-stats-metric-last-race .nt-stats-metric-value"),
        playtime = root.querySelector(".nt-stats-metric-playtime .nt-stats-metric-value"),
        rta = root.querySelector(".nt-stats-metric-rta .nt-stats-metric-value"),
        avgtime = root.querySelector(".nt-stats-metric-avg-time .nt-stats-metric-value")


    // Function to save the current timestamp using GM_setValue
    function saveTimestamp() {
        const currentTimestamp = Date.now(); // Get current time in milliseconds since Unix epoch
        GM_setValue("savedTimestamp", currentTimestamp.toString()); // Convert to string and save the timestamp
    }

    // Function to load the timestamp and calculate the time difference
    function loadTimeDif() {
        const savedTimestampStr = GM_getValue("savedTimestamp", null); // Load the saved timestamp as a string

        if (savedTimestampStr === null) {
            console.log("No timestamp saved.");
            return null;
        }

        // Convert the retrieved string back to a number
        const savedTimestamp = parseInt(savedTimestampStr, 10);

        // Validate the loaded timestamp
        if (isNaN(savedTimestamp)) {
            console.log("Invalid timestamp.");
            return null;
        }

        const currentTimestamp = Date.now(); // Get the current timestamp
        const timeDiff = currentTimestamp - savedTimestamp; // Calculate the difference in milliseconds

        // Convert the time difference to minutes and seconds
        const minutes = Math.floor(timeDiff / 60000); // Convert to minutes
        const seconds = Math.floor((timeDiff % 60000) / 1000); // Convert remaining milliseconds to seconds

        // Format the time difference as "00:00 MM:SS"
        const formattedTimeDiff = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;

        return formattedTimeDiff;
    }

    function formatPlayTime(seconds) {
        let hours = Math.floor(seconds / 3600);
        let minutes = Math.floor((seconds % 3600) / 60);
        let remainingSeconds = seconds % 60;

        return `${hours}h ${minutes}m ${remainingSeconds}s`;
    }

    function lastRaceStat(data) {
        let lastRaceT = data.lastRaces.split('|').pop();
        console.log(lastRaceT);
        let [chars, duration, errors] = lastRaceT.split(',').map(Number);

        let speed = Math.ceil((chars / duration) * 12);
        let accuracy = ((chars - errors) * 100) / chars;
        accuracy = accuracy.toFixed(2);

        return `${speed} WPM | ${accuracy} % | ${duration.toFixed(2)} s`;
    }

    function getAverageTime(data) {
        let races = data.lastRaces.split('|');
        let totalDuration = 0;

        races.forEach(race => {
            let [, duration] = race.split(',').map(Number);
            totalDuration += duration;
        });

        let averageDuration = totalDuration / races.length;
        return averageDuration.toFixed(2); // Return average duration rounded to 2 decimal places
    }

    function timeSinceLastLogin(data) {
        let lastLogin = data.lastLogin; // Timestamp of last login (in seconds)
        let currentTime = Math.floor(Date.now() / 1000); // Current time in seconds
        currentTime = data.lastConsecRace;
        let elapsedTime = currentTime - lastLogin; // Time since last login in seconds
        let minutes = Math.floor(elapsedTime / 60);
        let seconds = elapsedTime % 60;

        // Format the output as "MM:SS"
        return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
    }

    function handleSessionRaces(data) {
        const sessionRaces = data.sessionRaces; // Get sessionRaces from data

        if (sessionRaces === 0) {
            const lastSavedTimestampStr = GM_getValue("savedTimestamp", null);

            if (lastSavedTimestampStr !== null) {
                const lastSavedTimestamp = parseInt(lastSavedTimestampStr, 10);

                // Check if the last saved timestamp was less than 30 minutes ago
                // otherwise, it is not possible, because game resets session after at least 30 minutes
                // necessary, because it might call save function multiple times for same session at the end of the race
                // it would not fix value if page was loaded at first race and it was not succesful
                // so value would overshoot in that case by whenever frist race attempt of the session started
                const fifteenMinutesInMs = 30 * 60 * 1000;
                const currentTimestamp = Date.now();

                if (currentTimestamp - lastSavedTimestamp < fifteenMinutesInMs) {
                    return; // Exit the function to avoid saving again
                }
            }

            // If no recent timestamp or no timestamp at all, save the current time
            saveTimestamp();
        } else {
            // If sessionRaces is not 0, load the time difference
            const timeDifference = loadTimeDif();

            if (timeDifference !== null) {
                rta.textContent = timeDifference;
            } else {
                rta.textContent = "N/A";
            }
        }
    }
    return {
        root,
        updateStats: (data) => {
            if (typeof data?.playTime === "number") {
                playtime.textContent = formatPlayTime(data.playTime);
            }
            if (typeof data?.lastRaces === "string") {
                lastRace.textContent = lastRaceStat(data);
                avgtime.textContent = getAverageTime(data);
            }
            if (typeof data?.racesPlayed === "number") {
                //console.log(data);
                totalRaces.textContent = data.racesPlayed.toLocaleString();
                if (teamRaces) {
                    const trueTeamRaces = (data.racesPlayed - TEAM_RACES_DIF).toLocaleString();
                    teamRaces.textContent = `${trueTeamRaces}`;
                }
                const trueSeasonRaces = (data.racesPlayed - CURRENT_SEASON_DIF).toLocaleString();
                seasonRaces.textContent = `${trueSeasonRaces}`;
            }
            if (typeof data?.sessionRaces === "number") {
                sessionRaces.textContent = data.sessionRaces.toLocaleString();
                handleSessionRaces(data);
            }
            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,
            season
        } = data
        StatWidget.updateStats({
            teamRaces: member.played,
            seasonPoints: season.points,
        })
    },
    (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")

            //AUTO RELOAD
            //logstats();
            //setTimeout(() => location.reload(), autoReloadMS);
            //AUTO RELOAD

            getStats().then(({
                user,
                dailyChallenges
            }) => {
                StatWidget.updateStats(user)
                SeasonProgressWidget.updateStats(user)
                DailyChallengeWidget.updateStats(dailyChallenges)
                ToolbarWidget.updateStats(user)
                if (reloadOnStats) {
                    location.reload()
                }
            })
            break
        }
    }
})
resultObserver.observe(raceContainer, {
    childList: true,
    subtree: true
})


///MINI MAP




PIXI.utils.skipHello()

style.appendChild(
    document.createTextNode(`
.nt-racing-mini-map-root canvas {
    display: block;
}`))
document.head.appendChild(style)

const racingMiniMap = new PIXI.Application({
        width: 1024,
        height: 100,
        backgroundColor: config.colors.background,
        backgroundAlpha: 0.66
    }),
    container = document.createElement("div");

container.className = "nt-racing-mini-map-root"

///////////////////////
//  Prepare Objects  //
///////////////////////
if(scrollPage){window.scrollTo(0, document.body.scrollHeight);}

const RACER_WIDTH = 28,
    CROSSING_LINE_WIDTH = 32,
    PADDING = 2,
    racers = Array(5).fill(null),
    currentUserID = raceObj.props.user.userID

// Draw mini racetrack
const raceTrackBG = new PIXI.TilingSprite(PIXI.Texture.EMPTY, racingMiniMap.renderer.width, racingMiniMap.renderer.height),
    startLine = PIXI.Sprite.from(PIXI.Texture.WHITE),
    finishLine = PIXI.Sprite.from(PIXI.Texture.WHITE)

startLine.x = CROSSING_LINE_WIDTH
startLine.y = 0
startLine.width = 1
startLine.height = racingMiniMap.renderer.height
startLine.tint = config.colors.startLine

finishLine.x = racingMiniMap.renderer.width - CROSSING_LINE_WIDTH - 1
finishLine.y = 0
finishLine.width = 1
finishLine.height = racingMiniMap.renderer.height
finishLine.tint = config.colors.finishLine

raceTrackBG.addChild(startLine, finishLine)

for (let i = 1; i < 5; i++) {
    const lane = PIXI.Sprite.from(PIXI.Texture.WHITE)
    lane.x = 0
    lane.y = i * (racingMiniMap.renderer.height / 5)
    lane.width = racingMiniMap.renderer.width
    lane.height = 1
    lane.tint = config.colors.raceLane
    raceTrackBG.addChild(lane)
}

racingMiniMap.stage.addChild(raceTrackBG)

/* Mini Map movement animation update. */
function animateRacerTicker() {
    const r = this
    const lapse = Date.now() - r.lastUpdated
    if (r.sprite.x < r.toX) {
        const distance = r.toX - r.fromX
        r.sprite.x = r.fromX + Math.min(distance, distance * (lapse / r.moveMS))
        if (r.ghostSprite && r.sprite.x === r.ghostSprite.x) {
            r.ghostSprite.renderable = false
        }
    }
    if (r.skipped > 0) {
        const nitroTargetWidth = r.nitroToX - r.nitroFromX
        if (r.nitroSprite.width < nitroTargetWidth) {
            r.nitroSprite.width = Math.min(nitroTargetWidth, r.sprite.x - r.nitroFromX)
        } else if (r.nitroSprite.width === nitroTargetWidth && r.nitroSprite.alpha > 0 && !r.nitroDisableFade) {
            if (r.nitroSprite.alpha === 1) {
                r.nitroStartFadeStamp = Date.now() - 1
            }
            r.nitroSprite.alpha = Math.max(0, 1 - ((Date.now() - r.nitroStartFadeStamp) / 1e3))
        }
    }
    if (r.completeStamp !== null && r.sprite.x === r.toX && r.nitroSprite.alpha === 0) {
        racingMiniMap.ticker.remove(animateRacerTicker, this)
    }
}

/* Handle adding in players on the mini map. */
server.on("joined", (e) => {
    if(scrollPage){window.scrollTo(0, document.body.scrollHeight);}
    const {
        lane,
        userID
    } = e

    let color = config.colors.opponentBot
    if (userID === currentUserID) {
        color = config.colors.me
    } else if (!e.robot) {
        color = config.colors.opponentPlayer
    } else if (e.profile.specialRobot === "wampus") {
        color = config.colors.opponentWampus
    }

    if (racers[lane]) {
        racers[lane].ghostSprite.tint = color
        racers[lane].sprite.tint = color
        racers[lane].sprite.x = 0 - RACER_WIDTH + PADDING
        racers[lane].lastUpdated = Date.now()
        racers[lane].fromX = racers[lane].sprite.x
        racers[lane].toX = PADDING
        racers[lane].sprite.renderable = true
        return
    }

    const r = PIXI.Sprite.from(PIXI.Texture.WHITE)
    r.x = 0 - RACER_WIDTH + PADDING
    r.y = PADDING + (lane > 0 ? 1 : 0) + (lane * (racingMiniMap.renderer.height / 5))
    r.tint = color
    r.width = RACER_WIDTH
    r.height = 16 - (lane > 0 ? 1 : 0)

    const n = PIXI.Sprite.from(PIXI.Texture.WHITE)
    n.y = r.y + ((16 - (lane > 0 ? 1 : 0)) / 2) - 1
    n.renderable = false
    n.tint = config.colors.nitro
    n.width = 1
    n.height = 2

    racers[lane] = {
        lane,
        sprite: r,
        userID: userID,
        ghostSprite: null,
        nitroSprite: n,
        lastUpdated: Date.now(),
        fromX: r.x,
        toX: PADDING,
        skipped: 0,
        nitroStartFadeStamp: null,
        nitroFromX: null,
        nitroToX: null,
        nitroDisableFade: false,
        moveMS: 250,
        completeStamp: null,
    }

    if (config.moveDestination.enabled) {
        const g = PIXI.Sprite.from(PIXI.Texture.WHITE)
        g.x = PADDING
        g.y = PADDING + (lane > 0 ? 1 : 0) + (lane * (racingMiniMap.renderer.height / 5))
        g.tint = color
        g.alpha = config.moveDestination.alpha
        g.width = RACER_WIDTH
        g.height = 16 - (lane > 0 ? 1 : 0)
        g.renderable = false

        racers[lane].ghostSprite = g
        racingMiniMap.stage.addChild(g)
    }

    racingMiniMap.stage.addChild(n)
    racingMiniMap.stage.addChild(r)

    racingMiniMap.ticker.add(animateRacerTicker, racers[lane])
})

/* Handle any players leaving the race track. */
server.on("left", (e) => {
    const lane = racers.findIndex((r) => r?.userID === e)
    if (racers[lane]) {
        racers[lane].sprite.renderable = false
        racers[lane].ghostSprite.renderable = false
        racers[lane].nitroSprite.renderable = false
    }
})

/* Handle race map progress position updates. */
server.on("update", (e) => {
    if(scrollPage){window.scrollTo(0, document.body.scrollHeight);}
    let moveFinishMS = 100

    const payloadUpdateRacers = e.racers.slice().sort((a, b) => {
        if (a.progress.completeStamp === b.progress.completeStamp) {
            return 0
        }
        if (a.progress.completeStamp === null) {
            return 1
        }
        return a.progress.completeStamp > 0 && b.progress.completeStamp > 0 && a.progress.completeStamp > b.progress.completeStamp ? 1 : -1
    })

    for (let i = 0; i < payloadUpdateRacers.length; i++) {
        const r = payloadUpdateRacers[i],
            {
                completeStamp,
                skipped
            } = r.progress,
            racerObj = racers[r.lane]
        if (!racerObj || racerObj.completeStamp > 0 || (r.userID === currentUserID && completeStamp <= 0 && config.trackLocally)) {
            continue
        }

        if (r.disqualified) {
            racingMiniMap.ticker.remove(animateRacerTicker, racerObj)
            racingMiniMap.stage.removeChild(racerObj.sprite, racerObj.nitroSprite)
            if (racerObj.ghostSprite) {
                racingMiniMap.stage.removeChild(racerObj.ghostSprite)
            }
            racerObj.sprite.destroy()
            racerObj.ghostSprite.destroy()
            racerObj.nitroSprite.destroy()

            racers[r.lane] = null
            continue
        }

        racerObj.lastUpdated = Date.now()
        racerObj.fromX = racerObj.sprite.x

        if (racerObj.completeStamp === null && completeStamp > 0) {
            racerObj.completeStamp = completeStamp
            racerObj.toX = racingMiniMap.renderer.width - RACER_WIDTH - PADDING
            racerObj.moveMS = moveFinishMS

            if (racerObj.nitroDisableFade) {
                racerObj.nitroToX = racingMiniMap.renderer.width - RACER_WIDTH - PADDING
                racerObj.nitroDisableFade = false
            }
        } else {
            racerObj.moveMS = 1e3
            racerObj.toX = r.progress.percentageFinished * (racingMiniMap.renderer.width - RACER_WIDTH - CROSSING_LINE_WIDTH - PADDING - 1)
            racerObj.sprite.x = racerObj.fromX
        }

        if (racerObj.ghostSprite) {
            racerObj.ghostSprite.x = racerObj.toX
            racerObj.ghostSprite.renderable = true
        }

        if (skipped !== racerObj.skipped) {
            if (racerObj.skipped === 0) {
                racerObj.nitroFromX = racerObj.fromX
                racerObj.nitroSprite.x = racerObj.fromX
                racerObj.nitroSprite.renderable = true
            }
            racerObj.skipped = skipped // because infinite nitros exist? :/
            racerObj.nitroToX = racerObj.toX
            racerObj.nitroSprite.alpha = 1
            if (racerObj.completeStamp !== null) {
                racerObj.nitroToX = racingMiniMap.renderer.width - RACER_WIDTH - PADDING
            }
        }

        if (completeStamp > 0 && i + 1 < payloadUpdateRacers.length) {
            const nextRacer = payloadUpdateRacers[i + 1],
                nextRacerObj = racers[nextRacer?.lane]
            if (nextRacerObj && nextRacerObj.completeStamp === null && nextRacer.progress.completeStamp > 0 && nextRacer.progress.completeStamp > completeStamp) {
                moveFinishMS += 100
            }
        }
    }
})

if (config.trackLocally) {
    let lessonLength = 0
    server.on("status", (e) => {
        if (e.status === "countdown") {
            lessonLength = e.lessonLength
        }
    })

    const originalSendPlayerUpdate = server.sendPlayerUpdate
    server.sendPlayerUpdate = (data) => {
        originalSendPlayerUpdate(data)
        const racerObj = racers.find((r) => r?.userID === currentUserID)
        if (!racerObj) {
            return
        }

        const percentageFinished = (data.t / (lessonLength || 1))
        racerObj.lastUpdated = Date.now()
        racerObj.fromX = racerObj.sprite.x
        racerObj.moveMS = 100
        racerObj.toX = percentageFinished * (racingMiniMap.renderer.width - RACER_WIDTH - CROSSING_LINE_WIDTH - PADDING - 1)
        racerObj.sprite.x = racerObj.fromX

        if (racerObj.ghostSprite) {
            racerObj.ghostSprite.x = racerObj.toX
            racerObj.ghostSprite.renderable = true
        }

        if (data.s) {
            if (racerObj.skipped === 0) {
                racerObj.nitroFromX = racerObj.fromX
                racerObj.nitroSprite.x = racerObj.fromX
                racerObj.nitroSprite.renderable = true
            }
            racerObj.skipped = data.s // because infinite nitros exist? but I'm not going to test that! :/
            racerObj.nitroToX = racerObj.toX
            racerObj.nitroSprite.alpha = 1
            racerObj.nitroDisableFade = percentageFinished === 1

            if (racerObj.completeStamp !== null) {
                racerObj.nitroToX = racingMiniMap.renderer.width - RACER_WIDTH - PADDING
            }
        }
    }
}

/////////////
//  Final  //
/////////////

if (ENABLE_MINI_MAP) {
    container.append(racingMiniMap.view)
    raceContainer.after(container)
}