Nitro Type XP Tracker

Tracks and estimates hourly XP rate in Nitro Type races

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Nitro Type XP Tracker
// @version      3.8
// @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
// @require https://update.greasyfork.org/scripts/501960/1418069/findReact.js
// @require https://update.greasyfork.org/scripts/501961/1418070/CreateLogger.js
// @require https://update.greasyfork.org/scripts/501962/1418071/drawXpPieChart.js
// @license      MIT
// @namespace https://greasyfork.org/users/1331131-tensorflow-dvorak
// ==/UserScript==

/* globals Dexie, findReact, createLogger, drawXpPieChart */

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;
let drawerOpen = true;

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.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 = "0px";
    xpInfoContainer.style.width = "300px";
    xpInfoContainer.style.transition = "right 0.3s ease-in-out";
    xpInfoContainer.innerHTML = `
        <div id="xp-drawer-tab" style="position: absolute; top: 0; left: -40px; width: 40px; height: 100%; background-color: rgba(34, 34, 34, 0.9); border-radius: 10px 0 0 10px; cursor: pointer;"></div>
        <h3 style="margin-top: 0; font-size: 18px; text-align: center;">XP Meter</h3>
        <table style="width: 100%; margin-top: 10px;">
          <tr>
            <td>Total XP Earned:</td>
            <td id='total-xp-earned'>0</td>
          </tr>
          <tr>
            <td>Estimated Hourly XP:</td>
            <td id='hourly-xp-rate'>0</td>
          </tr>
          <tr>
            <td>Average XP per Race:</td>
            <td id='avg-xp-per-race'>0</td>
          </tr>
          <tr>
            <td>Races in Last Hour:</td>
            <td id='races-last-hour'>0</td>
          </tr>
          <tr>
            <td>Estimated Hourly Races:</td>
            <td id='hourly-races-rate'>0</td>
          </tr>
        </table>
        <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> <!-- Updated width and height -->
    `;
    document.body.appendChild(xpInfoContainer);

    // Add event listener for drawer tab
    document.getElementById("xp-drawer-tab").addEventListener("click", toggleDrawer);

    // Add event listener for reset button
    document.getElementById("reset-xp-tracker").addEventListener("click", resetXpTracker);

    // Draw initial pie chart
    drawXpPieChart();
  }
}

function toggleDrawer() {
  const xpInfoContainer = document.getElementById("xp-info-container");
  if (xpInfoContainer.style.right === "0px") {
    xpInfoContainer.style.right = "-320px";
    drawerOpen = false;
  } else {
    xpInfoContainer.style.right = "0px";
    drawerOpen = true;
  }
}

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; // Default value if XP element is not found
}

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; // in minutes
    const xpPerMinute = totalXp / totalDurationInMinutes;
    const projectedHourlyXpRate = xpPerMinute * 60; // Projected XP for 60 minutes

    const totalRaceTime = recentRaces.reduce((acc, race, index, array) => {
      if (index === 0) return acc;
      return acc + (array[index].timestamp - array[index - 1].timestamp);
    }, 0);

    const avgRaceTime = totalRaceTime / (racesCount - 1) / 1000; // in seconds
    const avgXpPerRace = totalXp / racesCount; // Average XP per race
    const estimatedHourlyRaces = 60 / (avgRaceTime / 60); // Estimated races per hour

    document.getElementById('hourly-xp-rate').textContent = formatNumber(Math.round(projectedHourlyXpRate));
    document.getElementById('avg-xp-per-race').textContent = formatNumber(avgXpPerRace.toFixed(2));
    document.getElementById('hourly-races-rate').textContent = formatNumber(Math.round(estimatedHourlyRaces));

    // 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('avg-xp-per-race').textContent = "0";
    document.getElementById('hourly-races-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";
  document.getElementById('avg-xp-per-race').textContent = "0";
  document.getElementById('hourly-races-rate').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");

          // Update XP info at the end of the race
          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();
});

// Utility function to format timestamp
function formatTimestamp(timestamp) {
  const date = new Date(timestamp);
  return date.toString();
}

// Utility function to format numbers with commas
function formatNumber(num) {
  return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}