🏠 Home 

Nitro Type - DPH configuration

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


Install this script?
// ==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);
}