Nitro Type - DPH configuration

Stats & Minimap & New Auto Reload & Alt. WPM / Countdown

当前为 2024-10-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.2.2
// @description  Stats & Minimap & New Auto Reload & Alt. WPM / Countdown
// @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/interact.js/1.10.27/interact.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/pixi.js/6.5.4/browser/pixi.min.js
// @license      MIT
// ==/UserScript==

/* global Dexie moment NTGLOBALS PIXI interact */

    const enableStats = GM_getValue('enableStats', true);
    //// GENERAL VISUAL OPTIONS ////
    const hideTrack = GM_getValue('hideTrack', true);
    const hideNotifications = GM_getValue('hideNotifications', true);
    const scrollPage = GM_getValue('scrollPage', false);
    const ENABLE_MINI_MAP = GM_getValue('ENABLE_MINI_MAP', true);
    const ENABLE_ALT_WPM_COUNTER = GM_getValue('ENABLE_ALT_WPM_COUNTER', true);

    ////// AUTO RELOAD OPTIONS /////
    const greedyStatsReload = GM_getValue('greedyStatsReload', true);
    const greedyStatsReloadInt = GM_getValue('greedyStatsReloadInt', 50);

    const reloadOnStats = GM_getValue('reloadOnStats', true);

    //// BETTER STATS OPTIONS /////
    const RACES_OUTSIDE_CURRENT_TEAM = GM_getValue('RACES_OUTSIDE_CURRENT_TEAM', 0);
    const RACES_BEFORE_THIS_SEASON = GM_getValue('RACES_BEFORE_THIS_SEASON', 0);

    const SEASON_RACES_EXTRA = GM_getValue('SEASON_RACES_EXTRA', 0);
    const TEAM_RACES_BUGGED = GM_getValue('TEAM_RACES_BUGGED', 0);

    const config = {
        ///// ALT WPM COUNTER CONFIG //////
        targetWPM: GM_getValue('targetWPM', 79.5),
        indicateWPMWithin: GM_getValue('indicateWPMWithin', 2),
        timerRefreshIntervalMS: GM_getValue('timerRefreshIntervalMS', 25),
        dif: GM_getValue('dif', 0.8),

        raceLatencyMS: 140,

        ///// CUSTOM MINIMAP CONFIG ////// (hardcoded)
        colors: {
            me: 0xFF69B4,
            opponentPlayer: 0x00FFFF,
            opponentBot: 0xbbbbbb,
            opponentWampus: 0xFFA500,
            nitro: 0xef9e18,
            raceLane: 0x555555,
            startLine: 0x929292,
            finishLine: 0x929292
        },
        trackLocally: true,
        moveDestination: {
            enabled: true,
            alpha: 0.3,
        }
    };

    // Create UI elements
    const createUI = () => {
        const container = document.createElement('div');
        container.style.position = 'fixed';
        container.style.bottom = '50px';
        container.style.left = '10px';
        container.style.background = 'rgba(20, 20, 20, 0.9)'; // Darker background
        container.style.color = 'cyan';
        container.style.padding = '10px';
        container.style.borderRadius = '8px'; // Slightly larger border radius for a modern look
        container.style.zIndex = '9999';
        container.style.display = 'none';
        container.style.width = '300px';
        container.style.maxHeight = '400px';
        container.style.overflowY = 'scroll';

        // Apply custom scrollbar styles
        const style = document.createElement('style');
        style.textContent = `
  ::-webkit-scrollbar {
    width: 8px; /* Scrollbar width */
  }

  ::-webkit-scrollbar-track {
    background: rgba(50, 50, 50, 0.6); /* Scrollbar track */
    border-radius: 5px; /* Rounded corners for the track */
  }

  ::-webkit-scrollbar-thumb {
    background-color: #00cccc; /* Scrollbar color */
    border-radius: 5px; /* Rounded corners for the thumb */
    border: 2px solid rgba(20, 20, 20, 0.9); /* Border for thumb to match container background */
  }

  ::-webkit-scrollbar-thumb:hover {
    background-color: #00e6e6; /* Lighter color on hover for a modern touch */
  }
`;
        document.head.appendChild(style);

        const title = document.createElement('h3');
        title.textContent = 'Configuration';
        title.style.margin = '0';
        title.style.color = '#ff007f';
        container.appendChild(title);
        const saveButton = document.createElement('button');
        saveButton.textContent = 'Save and Reload';
        saveButton.style.marginTop = '10px';
        saveButton.style.background = 'cyan';
        saveButton.style.color = 'black';
        saveButton.style.border = 'none';
        saveButton.style.padding = '5px';
        saveButton.style.cursor = 'pointer';
        saveButton.onclick = () => location.reload();
        container.appendChild(saveButton);

        const addHeader = (labelText) => {
            const label = document.createElement('label');
            label.style.display = 'block';
            label.style.marginTop = '10px';
            label.style.color = '#ff007f';
            label.appendChild(document.createTextNode(' ' + labelText));
            container.appendChild(label);
        };
        const addCheckbox = (labelText, variableName, defaultValue) => {
            const label = document.createElement('label');
            label.style.display = 'block';
            label.style.marginTop = '10px';
            label.style.color = 'cyan';

            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = GM_getValue(variableName, defaultValue);
            checkbox.onchange = () => GM_setValue(variableName, checkbox.checked);

            label.appendChild(checkbox);
            label.appendChild(document.createTextNode(' ' + labelText));
            container.appendChild(label);
        };

        const addNumberInput = (labelText, variableName, defaultValue) => {
            const label = document.createElement('label');
            label.style.display = 'block';
            label.style.marginTop = '10px';
            label.style.color = '#009c9a';
            label.style.fontSize = "14px";
            const input = document.createElement('input');
            input.type = 'number';
            input.value = GM_getValue(variableName, defaultValue);
            input.style.width = '100%';
            input.style.background = 'rgba(0, 0, 0, 0.6)';
            input.style.color = 'cyan';
            input.style.border = 'none';
            input.style.padding = '5px';
            input.style.marginTop = '5px';
            input.onchange = () => GM_setValue(variableName, parseFloat(input.value));

            label.appendChild(document.createTextNode(labelText));
            label.appendChild(input);
            container.appendChild(label);
        };

        // Add options to the UI
        addHeader("General options");
        addCheckbox('Hide Track', 'hideTrack', hideTrack);
        addCheckbox('Hide Notifications', 'hideNotifications', hideNotifications);
        addCheckbox('Enable Mini Map', 'ENABLE_MINI_MAP', ENABLE_MINI_MAP);
        addCheckbox('Auto Scroll Page', 'scrollPage', scrollPage);
        addHeader("Auto reload options");
        addCheckbox('Enable Auto Reload', 'greedyStatsReload', greedyStatsReload);
        addNumberInput('Auto Reload - Check Interval', 'greedyStatsReloadInt', greedyStatsReloadInt);
        addCheckbox('(Extra) Slow Auto Reload', 'reloadOnStats', reloadOnStats);
        addHeader("Stats options");
        addCheckbox('Enable Stats', 'enableStats', enableStats);
        addNumberInput('Races Outside Current Team', 'RACES_OUTSIDE_CURRENT_TEAM', RACES_OUTSIDE_CURRENT_TEAM);
        addNumberInput('Races Before This Season', 'RACES_BEFORE_THIS_SEASON', RACES_BEFORE_THIS_SEASON);
        addNumberInput('Bugged season count (0 if no)', 'SEASON_RACES_EXTRA', SEASON_RACES_EXTRA);
        addNumberInput('Bugged team count (0 if no)', 'TEAM_RACES_BUGGED', TEAM_RACES_BUGGED);
        addHeader("Alt. WPM options");
        addCheckbox('Enable Alt. WPM / Countdown', 'ENABLE_ALT_WPM_COUNTER', ENABLE_ALT_WPM_COUNTER);
        addNumberInput('Target WPM (1 = No Sandbagging)', 'targetWPM', config.targetWPM);
        addNumberInput('Alt. WPM: Yellow when +X WPM', 'indicateWPMWithin', config.indicateWPMWithin);
        addNumberInput('Alt. WPM: Refresh int.', 'timerRefreshIntervalMS', config.timerRefreshIntervalMS);
        addNumberInput('Alt. WPM: +X WPM Delay', 'dif', config.dif);

        document.body.appendChild(container);

        const configureButton = document.createElement('button');
        configureButton.textContent = 'Configure';
        configureButton.style.position = 'fixed';
        configureButton.style.bottom = '10px';
        configureButton.style.left = '10px';
        configureButton.style.background = 'rgba(0, 0, 0, 0.8)';
        configureButton.style.color = 'cyan';
        configureButton.style.border = 'none';
        configureButton.style.padding = '5px';
        configureButton.style.cursor = 'pointer';
        configureButton.style.zIndex = '9999';
        configureButton.onclick = () => {
            container.style.display = container.style.display === 'none' ? 'block' : 'none';
        };
        document.body.appendChild(configureButton);
    };

    createUI();


/** 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
}

var my_race_started = false;
const TEAM_RACES_DIF = RACES_OUTSIDE_CURRENT_TEAM - TEAM_RACES_BUGGED;
const CURRENT_SEASON_DIF = RACES_BEFORE_THIS_SEASON - SEASON_RACES_EXTRA;

if(hideTrack){
    const trackel = document.querySelector('.racev3-track')
    trackel.style.opacity = '0';
    trackel.style.marginTop = '-400px';
}
if (hideNotifications) {
    const style = document.createElement('style');
    style.textContent = `
        .growls {
            display: none !important; /* or visibility: hidden; */
        }
    `;
    document.head.appendChild(style);
}
/** 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
}

       raceContainer.addEventListener('click', (event) => {
        document.querySelector('.race-hiddenInput').click();
    });
//////////////////
//  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) {
        var currentTime = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user).lastConsecRace;
//document.querySelector('.race-hiddenInput').click()
        function checkendgreedy(lasttime) {
            if(document.querySelector('.modal--raceError')){
                clearInterval(intervalId);
                location.reload();
                return;
            }
            // 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) {
                        if (my_race_started) {
                            location.reload()
                        } else {
                            document.querySelector('.race-hiddenInput').click()
                            currentTime = newtime;
                            intervalId = setInterval(checkendgreedy, greedyStatsReloadInt, currentTime);
                        }
                    }
                })
            }
        }
        var 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 = (chars / duration) * 12;
        let accuracy = ((chars - errors) * 100) / chars;
        accuracy = accuracy.toFixed(2);

        return `${speed.toFixed(2)} 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 getAverageWPM(data) {
        let races = data.lastRaces.split('|');
        let totalSpeed = 0;

        races.forEach(race => {
            let [chars, duration, errors] = race.split(',').map(Number);
            let speed = (chars / duration) * 12;
            totalSpeed += speed;
        });

        let averageSpeed = totalSpeed / races.length;
        return averageSpeed.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);
                avgSpeed.textContent = getAverageWPM(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)
    if(enableStats){
        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) => {
    //console.log(my_race_started);
    my_race_started = true;
    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)
}

//alt wpm thingy

/** Get Nitro Word Length. */
const nitroWordLength = (words, i) => {
    let wordLength = words[i].length + 1
    if (i > 0 && i + 1 < words.length) {
        wordLength++
    }
    return wordLength
}

/** Get Player Avg using lastRaces data. */
const getPlayerAvg = (prefix, raceObj, lastRaces) => {
    const raceLogs = (lastRaces || raceObj.props.user.lastRaces)
        .split("|")
        .map((r) => {
            const data = r.split(","),
                typed = parseInt(data[0], 10),
                time = parseFloat(data[1]),
                errs = parseInt(data[2])
            if (isNaN(typed) || isNaN(time) || isNaN(errs)) {
                return false
            }
            return {
                time,
                acc: 1 - errs / typed,
                wpm: typed / 5 / (time / 60),
            }
        })
        .filter((r) => r !== false)

    const avgSpeed = raceLogs.reduce((prev, current) => prev + current.wpm, 0.0) / Math.max(raceLogs.length, 1)

    logging.info(prefix)("Avg Speed", avgSpeed)
    console.table(raceLogs, ["time", "acc", "wpm"])

    return avgSpeed
}

///////////////
//  Backend  //
///////////////

if (config.targetWPM <= 0) {
    logging.error("Init")("Invalid target WPM value")
    return
}

let raceTimeLatency = null

/** Styles for the following components. */
const styleNew = document.createElement("style")
styleNew.appendChild(
    document.createTextNode(`
/* Some Overrides */
.race-results {
    z-index: 6;
}

/* Sandbagging Tool */
.nt-evil-sandbagging-root {
    position: absolute;
    top: 0px;
    left: 0px;
    z-index: 5;
    color: #eee;
    touch-action: none;
}
.nt-evil-sandbagging-metric-value {
    font-weight: 600;
    font-family: "Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
}
.nt-evil-sandbagging-metric-suffix {
    color: #aaa;
}
.nt-evil-sandbagging-live {
    padding: 5px;
    border-radius: 8px;
    color: #FF69B4;
    background-color: rgb(0, 0, 0, 0.5);
    text-align: center;
}
.nt-evil-sandbagging-live span.live-wpm-inactive {
    opacity: 1;
}
.nt-evil-sandbagging-live > span:not(.live-wpm-inactive) .nt-evil-sandbagging-metric-value {
    color: #ffe275;
}
.nt-evil-sandbagging-best-live-wpm {
    font-size: 10px;
}
.nt-evil-sandbagging-section {
    padding: 5px;
    border-top: 1px solid rgba(255, 255, 255, 0.15);
    font-size: 10px;
    text-align: center;
}
.nt-evil-sandbagging-stats {
    background-color: rgba(20, 20, 20, 0.95);
}
.nt-evil-sandbagging-results {
    border-bottom-left-radius: 8px;
    border-bottom-right-radius: 8px;
    background-color: rgba(55, 55, 55, 0.95);
}`)
)
document.head.appendChild(styleNew);

/** Manages and displays the race timer. */
const RaceTimer = ((config) => {
    // Restore widget settings
    let widgetSettings = null
    try {
        const data = localStorage.getItem("nt_sandbagging_tool")
        if (typeof data === "string") {
            widgetSettings = JSON.parse(data)
        }
    } catch {
        widgetSettings = null
    }
    if (widgetSettings === null) {
        widgetSettings = { x: 384, y: 285 }
    }

    // Setup Widget
    const root = document.createElement("div")
    root.classList.add("nt-evil-sandbagging-root", "has-live-wpm")
    root.dataset.x = widgetSettings.x
    root.dataset.y = widgetSettings.y
    root.style.transform = `translate(${parseFloat(root.dataset.x) || 0}px, ${parseFloat(root.dataset.y) || 0}px)`
    root.innerHTML = `
        <div class="nt-evil-sandbagging-live">
            <span class="nt-evil-sandbagging-current-live-wpm live-wpm-inactive">
                <small class="nt-evil-sandbagging-metric-suffix">Prepare for your race!</small><span class="nt-evil-sandbagging-live-wpm nt-evil-sandbagging-metric-value"></span>
            </span>
            <span class="nt-evil-sandbagging-best-live-wpm live-wpm-inactive">
                (<span class="nt-evil-sandbagging-metric-value">0.00</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small>)
            </span>
        </div>
        <div class="nt-evil-sandbagging-section nt-evil-sandbagging-stats">
            Timer: <span class="nt-evil-sandbagging-live-time nt-evil-sandbagging-metric-value">0.00</span> / <span class="nt-evil-sandbagging-target-time nt-evil-sandbagging-metric-value">0.00</span> <small class="nt-evil-sandbagging-metric-suffix">sec</small> |
            Target: <span class="nt-evil-sandbagging-metric-value">${config.targetWPM}</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small> |
            Avg: <span class="nt-evil-sandbagging-current-avg-wpm nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small>
        </div>
        <div class="nt-evil-sandbagging-section nt-evil-sandbagging-results">
            Time: <span class="nt-evil-sandbagging-result-time nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">secs</small> |
            Speed: <span class="nt-evil-sandbagging-result-wpm nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small> |
            Avg: <span class="nt-evil-sandbagging-new-avg-wpm nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">WPM</small> |
            Latency: <span class="nt-evil-sandbagging-latency nt-evil-sandbagging-metric-value">?</span> <small class="nt-evil-sandbagging-metric-suffix">ms</small>
        </div>`

    const liveContainerNode = root.querySelector(".nt-evil-sandbagging-live"),
          liveCurrentWPMContainerNode = liveContainerNode.querySelector(".nt-evil-sandbagging-current-live-wpm"),
        liveWPMValueNode = liveCurrentWPMContainerNode.querySelector(".nt-evil-sandbagging-live-wpm"),
        liveBestWPMContainerNode = liveContainerNode.querySelector(".nt-evil-sandbagging-best-live-wpm"),
        liveBestWPMValueNode = liveBestWPMContainerNode.querySelector(".nt-evil-sandbagging-metric-value"),
        statContainerNode = root.querySelector(".nt-evil-sandbagging-stats"),
        liveTimeNode = statContainerNode.querySelector(".nt-evil-sandbagging-live-time"),
        targetTimeNode = statContainerNode.querySelector(".nt-evil-sandbagging-target-time"),
        currentAvgWPMNode = statContainerNode.querySelector(".nt-evil-sandbagging-current-avg-wpm"),
        resultContainerNode = root.querySelector(".nt-evil-sandbagging-results"),
        resultTimeNode = resultContainerNode.querySelector(".nt-evil-sandbagging-result-time"),
        resultWPMNode = resultContainerNode.querySelector(".nt-evil-sandbagging-result-wpm"),
        resultNewAvgWPMNode = resultContainerNode.querySelector(".nt-evil-sandbagging-new-avg-wpm"),
        resultLatencyNode = resultContainerNode.querySelector(".nt-evil-sandbagging-latency")

    resultContainerNode.remove()

statContainerNode.style.display = 'none';
    liveBestWPMContainerNode.style.display = 'none';
resultContainerNode.style.display = 'none';

    let timer = null,
        targetWPM = config.targetWPM || 79.49,
        startTime = null,
        finishTime = null,
        skipLength = null,
        bestSkipLength = null,
        lessonLength = null,
        onTargetTimeUpdate = null,
        onTimeUpdate = null

    /** Updates the race timer metrics. */
    const refreshCurrentTime = () => {
        if (startTime === null) {
            logging.warn("Update")("Invalid last time, unable to update current timer")
            return
        }
        if (finishTime !== null) {
            return
        }

        let diff = Date.now() - startTime
        if (onTimeUpdate) {
            onTimeUpdate(diff)
        }
        liveTimeNode.textContent = (diff / 1e3).toFixed(2);

        diff /= 6e4;
        const suffixwpm = document.querySelector(".nt-evil-sandbagging-metric-suffix");
        const currentWPM = (lessonLength - skipLength) / 5 / diff,
              bestWPM = (lessonLength - bestSkipLength) / 5 / diff
        if (currentWPM < (config.targetWPM+20)){
            liveWPMValueNode.textContent = (currentWPM-config.dif).toFixed(1);
            suffixwpm.style.display = 'block';
        }
        else {

            suffixwpm.style.display = 'none';
            liveWPMValueNode.textContent = "Just type...!"
        }
        liveBestWPMValueNode.textContent = bestWPM.toFixed(2)

        if (currentWPM - targetWPM <= config.indicateWPMWithin) {
            liveCurrentWPMContainerNode.classList.remove("live-wpm-inactive")
        }
        if (bestWPM - targetWPM <= config.indicateWPMWithin) {
            liveBestWPMContainerNode.classList.remove("live-wpm-inactive")
        }
        timer = setTimeout(refreshCurrentTime, config.timerRefreshIntervalMS)
    }

    /** Toggle whether to show best wpm counter or not (the small text). */
    const toggleBestLiveWPM = (show) => {
        if (show) {
            liveContainerNode.append(liveBestWPMContainerNode)
        } else {
            liveBestWPMContainerNode.remove()
        }
    }

    /** Save widget settings. */
    const saveSettings = () => {
        localStorage.setItem("nt_sandbagging_tool", JSON.stringify(widgetSettings))
    }
    saveSettings()

    /** Setup draggable widget. */
    interact(root).draggable({
        modifiers: [
            interact.modifiers.restrictRect({
                //restriction: "parent",
                endOnly: true,
            }),
        ],
        listeners: {
            move: (event) => {
                const target = event.target,
                    x = (parseFloat(target.dataset.x) || 0) + event.dx,
                    y = (parseFloat(target.dataset.y) || 0) + event.dy

                target.style.transform = "translate(" + x + "px, " + y + "px)"

                target.dataset.x = x
                target.dataset.y = y

                widgetSettings.x = x
                widgetSettings.y = y

                saveSettings()
            },
        },
    })

    return {
        root,
        setTargetWPM: (wpm) => {
            targetWPM = wpm
        },
        setLessonLength: (l) => {
            lessonLength = l
        },
        getLessonLength: () => lessonLength,
        setSkipLength: (l) => {
            skipLength = l
            toggleBestLiveWPM(false)
            if (skipLength !== bestSkipLength) {
                const newTime = ((lessonLength - skipLength) / 5 / targetWPM) * 60
                if (onTargetTimeUpdate) {
                    onTargetTimeUpdate(newTime * 1e3)
                }
                targetTimeNode.textContent = newTime.toFixed(2)
            }
        },
        setBestSkipLength: (l) => {
            bestSkipLength = l
            const newTime = ((lessonLength - bestSkipLength) / 5 / targetWPM) * 60
            if (onTargetTimeUpdate) {
                onTargetTimeUpdate(newTime * 1e3)
            }
            targetTimeNode.textContent = newTime.toFixed(2)
        },
        start: (t) => {
            if (timer) {
                clearTimeout(timer)
            }
            //startTime = t
            startTime = Date.now();
            refreshCurrentTime()
        },
        stop: () => {
            if (timer) {
                finishTime = Date.now()
                clearTimeout(timer)
            }
        },
        setCurrentAvgSpeed: (wpm) => {
            currentAvgWPMNode.textContent = wpm.toFixed(2)
        },
        reportFinishResults: (speed, avgSpeed, actualStartTime, actualFinishTime) => {
            const latency = actualFinishTime - finishTime,
                output = (latency / 1e3).toFixed(2)

            resultTimeNode.textContent = ((actualFinishTime - actualStartTime) / 1e3).toFixed(2)
            resultWPMNode.textContent = speed.toFixed(2)
            liveWPMValueNode.textContent = speed.toFixed(2)
            resultNewAvgWPMNode.textContent = avgSpeed.toFixed(2)
            resultLatencyNode.textContent = latency
            toggleBestLiveWPM(false)

            root.append(resultContainerNode)

            logging.info("Finish")(`Race Finish acknowledgement latency: ${output} secs (${latency}ms)`)
            return output
        },
        setOnTargetTimeUpdate: (c) => {
            onTargetTimeUpdate = c
        },
        setOnTimeUpdate: (c) => {
            onTimeUpdate = c
        },
    }
})(config)

window.NTRaceTimer = RaceTimer

/** Track Racing League for analysis. */
server.on("setup", (e) => {
    if (e.scores && e.scores.length === 2) {
        const [from, to] = e.scores
        logging.info("Init")("Racing League", JSON.stringify({ from, to, trackLeader: e.trackLeader }))
        RaceTimer.setCurrentAvgSpeed(getPlayerAvg("Init", raceObj))
    }
})
var countdownTimer = -1;
/** Track whether to start the timer and manage target goals. */
server.on("status", (e) => {
    if (e.status === "countdown") {
        const wpmtextnode = document.querySelector(".nt-evil-sandbagging-live-wpm");
        const wpmsuffix = document.querySelector(".nt-evil-sandbagging-metric-suffix");
        if (countdownTimer !== -1) {
            return
        }
        var lastCountdown = 400;
        wpmsuffix.textContent = "Race starts in... ";
        countdownTimer = setInterval(() => {
             wpmtextnode.textContent = (lastCountdown/100).toFixed(2);
            lastCountdown--;
           }, 10)

        RaceTimer.setLessonLength(e.lessonLength)

        const words = e.lesson.split(" ")

        let mostLetters = null,
            nitroWordCount = 0
        words.forEach((_, i) => {
            let wordLength = nitroWordLength(words, i)
            if (mostLetters === null || mostLetters < wordLength) {
                mostLetters = wordLength
            }
        })
        RaceTimer.setBestSkipLength(mostLetters)
    } else if (e.status === "racing") {
        const wpmsuffix = document.querySelector(".nt-evil-sandbagging-metric-suffix");
        wpmsuffix.textContent = "Possible WPM: ";
        clearInterval(countdownTimer);
        RaceTimer.start(e.startStamp - config.raceLatencyMS)

        const originalSendPlayerUpdate = server.sendPlayerUpdate
        server.sendPlayerUpdate = (data) => {
            originalSendPlayerUpdate(data)
            if (data.t >= RaceTimer.getLessonLength()) {
                RaceTimer.stop()
            }
            if (typeof data.s === "number") {
                RaceTimer.setSkipLength(data.s)
            }
        }
    }
})

/** Track Race Finish exact time. */
server.on("update", (e) => {
    const me = e?.racers?.find((r) => r.userID === currentUserID)
    if (raceTimeLatency === null && me.progress.completeStamp > 0 && me.rewards) {
        const { typed, skipped, startStamp, completeStamp } = me.progress

        raceTimeLatency = RaceTimer.reportFinishResults(
            (typed - skipped) / 5 / ((completeStamp - startStamp) / 6e4),
            getPlayerAvg("Finish", raceObj, me.rewards.current.lastRaces),
            startStamp,
            completeStamp
        )
    }
})

/////////////
//  Final  //
/////////////
if (ENABLE_ALT_WPM_COUNTER){
    raceContainer.append(RaceTimer.root);
}