// ==UserScript==
// @name Nitro Type XP Tracker
// @version 3.5
// @description Tracks and estimates hourly XP rate in Nitro Type races
// @author TensorFlow - Dvorak
// @match *://*.nitrotype.com/race
// @match *://*.nitrotype.com/race/*
// @grant none
// @require https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.1/dexie.min.js
// @namespace https://greasyfork.org/users/1331131-tensorflow-dvorak
// @license MIT
// ==/UserScript==
/* globals Dexie */
const findReact = (dom, traverseUp = 0) => {
const key = Object.keys(dom).find((key) => key.startsWith("__reactFiber$"));
const domFiber = dom[key];
if (!domFiber) return null;
const getCompFiber = (fiber) => {
let parentFiber = fiber?.return;
while (parentFiber && typeof parentFiber.type === "string") {
parentFiber = parentFiber.return;
}
return parentFiber;
};
let compFiber = getCompFiber(domFiber);
for (let i = 0; traverseUp && compFiber && i < traverseUp; i++) {
compFiber = getCompFiber(compFiber);
}
return compFiber?.stateNode || null;
};
const createLogger = (namespace) => {
const logPrefix = (prefix = "") => {
const formatMessage = `%c[${namespace}]${prefix ? `%c[${prefix}]` : ""}`;
let args = [
console,
`${formatMessage}%c`,
"background-color: #4285f4; color: #fff; font-weight: bold",
];
if (prefix) {
args = args.concat(
"background-color: #4f505e; color: #fff; font-weight: bold"
);
}
return args.concat("color: unset");
};
const bindLog = (logFn, prefix) =>
Function.prototype.bind.apply(logFn, logPrefix(prefix));
return {
info: (prefix) => bindLog(console.info, prefix),
warn: (prefix) => bindLog(console.warn, prefix),
error: (prefix) => bindLog(console.error, prefix),
log: (prefix) => bindLog(console.log, prefix),
debug: (prefix) => bindLog(console.debug, prefix),
};
};
const logging = createLogger("Nitro Type XP Tracker");
// Config storage
const db = new Dexie("XPTracker");
db.version(31).stores({
races:
"++id, timestamp, xp, placement, accuracy, wampus, friends, goldBonus, speed, other",
totalXp: "key, value",
session: "key, value",
});
db.open().catch(function (e) {
logging.error("Init")("Failed to open up the config database", e);
});
// Initialize variables for XP tracking
let xpAtStartOfRace = 0;
let totalXpEarned = 0;
let raceStartTime = 0;
let firstRaceStartTime = null;
const xpCategories = {
placement: 0,
accuracy: 0,
wampus: 0,
friends: 0,
goldBonus: 0,
speed: 0,
other: 0,
};
const cumulativeXpCategories = {
placement: 0,
accuracy: 0,
wampus: 0,
friends: 0,
goldBonus: 0,
speed: 0,
other: 0,
};
function createXpInfoUI() {
let xpInfoContainer = document.getElementById("xp-info-container");
if (!xpInfoContainer) {
xpInfoContainer = document.createElement("div");
xpInfoContainer.id = "xp-info-container";
xpInfoContainer.style.zIndex = "1000";
xpInfoContainer.style.backgroundColor = "rgba(34, 34, 34, 0.9)";
xpInfoContainer.style.color = "#fff";
xpInfoContainer.style.padding = "20px";
xpInfoContainer.style.borderRadius = "10px";
xpInfoContainer.style.fontFamily = "'Roboto', sans-serif";
xpInfoContainer.style.fontSize = "16px";
xpInfoContainer.style.boxShadow = "0 6px 12px rgba(0, 0, 0, 0.15)";
xpInfoContainer.style.position = "fixed";
xpInfoContainer.style.top = "20px";
xpInfoContainer.style.right = "20px";
xpInfoContainer.style.width = "300px"; // Decreased width
xpInfoContainer.innerHTML = `
<h3 style="margin-top: 0; font-size: 18px; text-align: center;">XP Meter</h3>
<p>Total XP Earned: <span id='total-xp-earned'>0</span></p>
<p>Estimated Hourly XP: <span id='hourly-xp-rate'>0</span></p>
<p>Races Completed in Last Hour: <span id='races-last-hour'>0</span></p>
<button id="reset-xp-tracker" style="margin-top: 10px; padding: 8px 15px; width: 100%; background-color: #ff4d4d; border: none; color: #fff; border-radius: 5px; cursor: pointer;">Reset</button>
<canvas id="xpPieChart" style="margin-top: 20px;" width="300" height="300"></canvas> <!-- Decreased width and height -->
`;
document.body.appendChild(xpInfoContainer);
// Add event listener for reset button
document
.getElementById("reset-xp-tracker")
.addEventListener("click", resetXpTracker);
// Draw initial pie chart
drawXpPieChart();
}
}
function drawXpPieChart() {
const canvas = document.getElementById("xpPieChart");
const ctx = canvas.getContext("2d");
const data = [
{
label: "Placement",
value: xpCategories.placement,
cumulative: cumulativeXpCategories.placement,
color: "#ff6384",
},
{
label: "Accuracy",
value: xpCategories.accuracy,
cumulative: cumulativeXpCategories.accuracy,
color: "#36a2eb",
},
{
label: "Wampus",
value: xpCategories.wampus,
cumulative: cumulativeXpCategories.wampus,
color: "#cc65fe",
},
{
label: "Friends",
value: xpCategories.friends,
cumulative: cumulativeXpCategories.friends,
color: "#4caf50",
},
{
label: "Gold Bonus",
value: xpCategories.goldBonus,
cumulative: cumulativeXpCategories.goldBonus,
color: "#ffeb3b",
},
{
label: "Speed",
value: xpCategories.speed,
cumulative: cumulativeXpCategories.speed,
color: "#f44336",
},
{
label: "Other",
value: xpCategories.other,
cumulative: cumulativeXpCategories.other,
color: "#ffce56",
},
].filter((category) => category.value > 0); // Filter out categories with value 0
const total = data.reduce((acc, category) => acc + category.value, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
let startAngle = 0;
data.forEach((category) => {
const sliceAngle = (category.value / total) * 2 * Math.PI;
ctx.beginPath();
ctx.moveTo(150, 150);
ctx.arc(150, 150, 150, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = category.color;
ctx.fill();
// Draw labels centered within the slice and as far away from the center as possible
const midAngle = startAngle + sliceAngle / 2;
const labelX = 150 + (150 - 20) * Math.cos(midAngle);
const labelY = 150 + (150 - 20) * Math.sin(midAngle);
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.font = "bold 12px Arial";
ctx.fillText(
`${category.label} (${formatNumber(category.cumulative)})`,
labelX,
labelY
);
startAngle += sliceAngle;
});
}
async function loadSessionData() {
logging.info("LoadSessionData")("Loading session data...");
const totalXpResult = await db.totalXp.get("totalXpEarned");
if (totalXpResult) {
totalXpEarned = totalXpResult.value;
document.getElementById("total-xp-earned").textContent =
formatNumber(totalXpEarned);
} else {
totalXpEarned = 0;
}
logging.info("Total XP Earned")(totalXpEarned);
const firstRaceStartTimeResult = await db.session.get("firstRaceStartTime");
if (firstRaceStartTimeResult) {
firstRaceStartTime = firstRaceStartTimeResult.value;
} else {
firstRaceStartTime = Date.now();
await db.session.put({
key: "firstRaceStartTime",
value: firstRaceStartTime,
});
}
logging.info("First Race Start Time")(
new Date(firstRaceStartTime).toLocaleString()
);
updateHourlyXpRate();
}
async function updateXpInfo() {
const raceEndTime = Date.now();
const currentXp = getCurrentXp();
const xpEarned = currentXp - xpAtStartOfRace;
totalXpEarned += xpEarned;
logging.info("XP Earned This Race")(xpEarned);
logging.info("Total XP Earned")(totalXpEarned);
// Save race data
const reactFiberNode = getReactFiberNode();
const categorizedXp = categorizeXp(reactFiberNode.state.rewards);
await db.races.add({
timestamp: raceEndTime,
xp: xpEarned,
placement: categorizedXp.placement,
accuracy: categorizedXp.accuracy,
wampus: categorizedXp.wampus,
friends: categorizedXp.friends,
goldBonus: categorizedXp.goldBonus,
speed: categorizedXp.speed,
other: categorizedXp.other,
});
await db.totalXp.put({ key: "totalXpEarned", value: totalXpEarned });
// Update the XP categories
Object.keys(categorizedXp).forEach((category) => {
xpCategories[category] += categorizedXp[category];
});
// Update cumulative XP categories for the last hour
const currentTime = Date.now();
const oneHourAgo = currentTime - 60 * 60 * 1000;
const recentRaces = await db.races
.where("timestamp")
.above(oneHourAgo)
.toArray();
cumulativeXpCategories.placement = recentRaces.reduce(
(acc, race) => acc + race.placement,
0
);
cumulativeXpCategories.accuracy = recentRaces.reduce(
(acc, race) => acc + race.accuracy,
0
);
cumulativeXpCategories.wampus = recentRaces.reduce(
(acc, race) => acc + race.wampus,
0
);
cumulativeXpCategories.friends = recentRaces.reduce(
(acc, race) => acc + race.friends,
0
);
cumulativeXpCategories.goldBonus = recentRaces.reduce(
(acc, race) => acc + race.goldBonus,
0
);
cumulativeXpCategories.speed = recentRaces.reduce(
(acc, race) => acc + race.speed,
0
);
cumulativeXpCategories.other = recentRaces.reduce(
(acc, race) => acc + race.other,
0
);
// Draw updated pie chart
drawXpPieChart();
// Update the hourly XP rate
updateHourlyXpRate();
// Prepare for the next race
raceStartTime = Date.now();
xpAtStartOfRace = getCurrentXp();
}
function getReactFiberNode() {
const xpElements = document.getElementsByClassName(
"raceResults-reward-xp tar tss"
);
if (xpElements.length > 0) {
const lastXpElement = xpElements[xpElements.length - 1];
const reactFiberNode = findReact(lastXpElement);
if (reactFiberNode) {
return reactFiberNode;
}
}
return null;
}
function getCurrentXp() {
const xpElements = document.getElementsByClassName(
"raceResults-reward-xp tar tss"
);
if (xpElements.length > 0) {
const lastXpElement = xpElements[xpElements.length - 1];
const reactFiberNode = findReact(lastXpElement);
if (reactFiberNode) {
logging.debug("React Fiber XP Rewards")(reactFiberNode.state.rewards);
const totalXp = reactFiberNode.state.rewards.reduce(
(acc, reward) => acc + reward.experience,
0
);
return totalXp;
}
}
return 0;
}
function categorizeXp(rewards) {
const categories = {
placement: 0,
accuracy: 0,
wampus: 0,
friends: 0,
goldBonus: 0,
speed: 0,
other: 0,
};
rewards.forEach((reward) => {
const xp = reward.experience;
if (reward.label.includes("Place")) {
categories.placement += xp;
} else if (reward.label.includes("Accuracy")) {
categories.accuracy += xp;
} else if (reward.label.includes("Wampus")) {
categories.wampus += xp;
} else if (reward.label.includes("Friends")) {
categories.friends += xp;
} else if (reward.label.includes("Gold Bonus")) {
categories.goldBonus += xp;
} else if (reward.label.includes("Speed")) {
categories.speed += xp;
} else {
categories.other += xp;
}
});
return categories;
}
async function updateHourlyXpRate() {
const currentTime = Date.now();
const oneHourAgo = currentTime - 60 * 60 * 1000;
const recentRaces = await db.races
.where("timestamp")
.above(oneHourAgo)
.toArray();
const racesCount = recentRaces.length;
if (racesCount > 0) {
const totalPlacementXp = recentRaces.reduce(
(acc, race) => acc + race.placement,
0
);
const totalAccuracyXp = recentRaces.reduce(
(acc, race) => acc + race.accuracy,
0
);
const totalWampusXp = recentRaces.reduce(
(acc, race) => acc + race.wampus,
0
);
const totalFriendsXp = recentRaces.reduce(
(acc, race) => acc + race.friends,
0
);
const totalGoldBonusXp = recentRaces.reduce(
(acc, race) => acc + race.goldBonus,
0
);
const totalSpeedXp = recentRaces.reduce((acc, race) => acc + race.speed, 0);
const totalOtherXp = recentRaces.reduce((acc, race) => acc + race.other, 0);
const totalXp =
totalPlacementXp +
totalAccuracyXp +
totalWampusXp +
totalFriendsXp +
totalGoldBonusXp +
totalSpeedXp +
totalOtherXp;
const firstRecentRaceTime = recentRaces[0].timestamp;
const totalDurationInMinutes =
(currentTime - firstRecentRaceTime) / 1000 / 60;
const xpPerMinute = totalXp / totalDurationInMinutes;
const projectedHourlyXpRate = xpPerMinute * 60;
document.getElementById("hourly-xp-rate").textContent = formatNumber(
Math.round(projectedHourlyXpRate)
);
// Update the XP categories
xpCategories.placement = totalPlacementXp / racesCount;
xpCategories.accuracy = totalAccuracyXp / racesCount;
xpCategories.wampus = totalWampusXp / racesCount;
xpCategories.friends = totalFriendsXp / racesCount;
xpCategories.goldBonus = totalGoldBonusXp / racesCount;
xpCategories.speed = totalSpeedXp / racesCount;
xpCategories.other = totalOtherXp / racesCount;
// Update cumulative XP categories for the last hour
cumulativeXpCategories.placement = totalPlacementXp;
cumulativeXpCategories.accuracy = totalAccuracyXp;
cumulativeXpCategories.wampus = totalWampusXp;
cumulativeXpCategories.friends = totalFriendsXp;
cumulativeXpCategories.goldBonus = totalGoldBonusXp;
cumulativeXpCategories.speed = totalSpeedXp;
cumulativeXpCategories.other = totalOtherXp;
// Draw updated pie chart
drawXpPieChart();
} else {
document.getElementById("hourly-xp-rate").textContent = "0";
}
document.getElementById("races-last-hour").textContent =
formatNumber(racesCount);
}
async function resetXpTracker() {
await db.races.clear();
await db.totalXp.clear();
await db.session.clear();
totalXpEarned = 0;
xpCategories.placement = 0;
xpCategories.accuracy = 0;
xpCategories.wampus = 0;
xpCategories.friends = 0;
xpCategories.goldBonus = 0;
xpCategories.speed = 0;
xpCategories.other = 0;
cumulativeXpCategories.placement = 0;
cumulativeXpCategories.accuracy = 0;
cumulativeXpCategories.wampus = 0;
cumulativeXpCategories.friends = 0;
cumulativeXpCategories.goldBonus = 0;
cumulativeXpCategories.speed = 0;
cumulativeXpCategories.other = 0;
firstRaceStartTime = Date.now();
document.getElementById("total-xp-earned").textContent = "0";
document.getElementById("hourly-xp-rate").textContent = "0";
document.getElementById("races-last-hour").textContent = "0";
// Draw updated pie chart
drawXpPieChart();
await db.session.put({
key: "firstRaceStartTime",
value: firstRaceStartTime,
});
logging.info("Reset")("XP Tracker has been reset");
}
function initializeXpTracker() {
createXpInfoUI();
const raceContainer = document.getElementById("raceContainer");
if (raceContainer) {
const resultObserver = new MutationObserver(([mutation], observer) => {
for (const node of mutation.addedNodes) {
if (node.classList?.contains("race-results")) {
logging.info("Update")("Race Results received");
updateXpInfo();
observer.observe(raceContainer, { childList: true, subtree: true });
break;
}
}
});
resultObserver.observe(raceContainer, { childList: true, subtree: true });
} else {
logging.error("Init")("Race container not found, retrying...");
setTimeout(initializeXpTracker, 1000);
}
}
window.addEventListener("load", async () => {
createXpInfoUI();
await loadSessionData();
initializeXpTracker();
// Start a new race session
raceStartTime = Date.now();
xpAtStartOfRace = getCurrentXp();
});
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toString();
}
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}