Stats & Minimap & New Auto Reload & Alt. WPM / Countdown
// ==UserScript== // @name Nitro Type - DPH configuration // @version 0.2.3 // @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', 'reloadOnStats', reloadOnStats); addCheckbox('Enable FAST RELOAD', 'greedyStatsReload', greedyStatsReload); addNumberInput('FAST RELOAD - Check Interval', 'greedyStatsReloadInt', greedyStatsReloadInt); 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); }