Nitro Type - DPH configuration

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

目前為 2024-10-24 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Nitro Type - DPH configuration
// @version      0.2.1
// @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);
}




QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址