Nitro Type XP Tracker

Tracks and estimates hourly XP rate in Nitro Type races

Ekde 2024/07/15. Vidu La ĝisdata versio.

// ==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, ",");
}