NitroType Tier Test and Duel Logger

Input exact player names. When logging is ON, the script waits until each tracked player's race row (exact match) shows a finish time ("secs") then logs a merged summary with sequential numbering. It computes cumulative averages (tiers from rounded averages) and updates a cumulative score (e.g., "1-0" if player A wins). The UI shows total races & current score, allows deletion, and exports formatted results. Data persists.

  1. // ==UserScript==
  2. // @name NitroType Tier Test and Duel Logger
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.15.1
  5. // @description Input exact player names. When logging is ON, the script waits until each tracked player's race row (exact match) shows a finish time ("secs") then logs a merged summary with sequential numbering. It computes cumulative averages (tiers from rounded averages) and updates a cumulative score (e.g., "1-0" if player A wins). The UI shows total races & current score, allows deletion, and exports formatted results. Data persists.
  6. // @license MIT
  7. // @match *://www.nitrotype.com/*
  8. // @grant none
  9. // ==/UserScript==
  10.  
  11. (function() {
  12. 'use strict';
  13.  
  14. // --- Global Variables ---
  15. let selectedPlayers = JSON.parse(localStorage.getItem("nitroSelectedPlayers") || "[]");
  16. const RESULTS_KEY = "nitroTierTests";
  17. const LOGGING_STATE_KEY = "nitroLoggingActive";
  18. const CUM_STATS_KEY = "nitroCumulativeStats"; // For cumulative stats (sums & counts)
  19. const SCORE_KEY = "nitroScore"; // For cumulative score
  20. let observer = null;
  21. let isLogging = false;
  22.  
  23. // ========================
  24. // Tier Calculation Helper (with rounding)
  25. // ========================
  26. function getTier(avgWpm) {
  27. let rounded = Math.round(avgWpm);
  28. if (rounded >= 175) return "HT1";
  29. else if (rounded >= 160) return "LT1";
  30. else if (rounded >= 145) return "HT2";
  31. else if (rounded >= 130) return "LT2";
  32. else if (rounded >= 115) return "HT3";
  33. else if (rounded >= 100) return "LT3";
  34. else if (rounded >= 85) return "HT4";
  35. else if (rounded >= 70) return "LT4";
  36. else if (rounded >= 50) return "HT5";
  37. else return "LT5";
  38. }
  39.  
  40. // ========================
  41. // Cumulative Score & Stats Helpers
  42. // ========================
  43. function getCumulativeScore() {
  44. return JSON.parse(localStorage.getItem(SCORE_KEY) || '{"A":0,"B":0}');
  45. }
  46. function saveCumulativeScore(scoreObj) {
  47. localStorage.setItem(SCORE_KEY, JSON.stringify(scoreObj));
  48. }
  49. function getCumulativeStats() {
  50. return JSON.parse(localStorage.getItem(CUM_STATS_KEY) || "{}");
  51. }
  52. function saveCumulativeStats(stats) {
  53. localStorage.setItem(CUM_STATS_KEY, JSON.stringify(stats));
  54. }
  55. // Updates cumulative stats by adding the data from a new race's entries.
  56. function updateCumulativeStats(newRaceEntries) {
  57. let stats = getCumulativeStats();
  58. newRaceEntries.forEach(entry => {
  59. const name = entry.username;
  60. const wpm = parseFloat(entry.wpm);
  61. const acc = parseFloat(entry.accuracy);
  62. if (!stats[name]) {
  63. stats[name] = { sumWpm: 0, sumAcc: 0, count: 0 };
  64. }
  65. stats[name].sumWpm += isNaN(wpm) ? 0 : wpm;
  66. stats[name].sumAcc += isNaN(acc) ? 0 : acc;
  67. stats[name].count += 1;
  68. });
  69. saveCumulativeStats(stats);
  70. }
  71. function getCumulativeAverages() {
  72. let stats = getCumulativeStats();
  73. let averages = {};
  74. selectedPlayers.forEach(name => {
  75. if (stats[name] && stats[name].count > 0) {
  76. averages[name] = {
  77. avgWpm: (stats[name].sumWpm / stats[name].count).toFixed(2),
  78. avgAcc: (stats[name].sumAcc / stats[name].count).toFixed(2)
  79. };
  80. } else {
  81. averages[name] = { avgWpm: "N/A", avgAcc: "N/A" };
  82. }
  83. });
  84. return averages;
  85. }
  86.  
  87. // ========================
  88. // Storage Helpers for Race Results
  89. // ========================
  90. function getStoredResults() {
  91. return JSON.parse(localStorage.getItem(RESULTS_KEY) || "[]");
  92. }
  93. function saveStoredResults(results) {
  94. localStorage.setItem(RESULTS_KEY, JSON.stringify(results));
  95. }
  96.  
  97. // ========================
  98. // Helper: Extract Exact Player Name from a Row
  99. // ========================
  100. function getPlayerNameFromRow(row) {
  101. const container = row.querySelector('.raceResults-playerName .player-name--container');
  102. if (!container) return "";
  103. const span = container.querySelector('span.type-ellip');
  104. if (span) return span.textContent.trim();
  105. const anchor = container.querySelector('a');
  106. if (anchor) return anchor.textContent.replace(/[\[\]]/g, '').trim();
  107. return container.textContent.trim();
  108. }
  109.  
  110. // ========================
  111. // UI Creation
  112. // ========================
  113. function createUI() {
  114. if (document.getElementById('nt-tier-test-logger-ui')) return;
  115.  
  116. const container = document.createElement('div');
  117. container.id = 'nt-tier-test-logger-ui';
  118. container.style.position = 'fixed';
  119. container.style.top = '10px';
  120. container.style.right = '10px';
  121. container.style.zIndex = '10000';
  122. container.style.backgroundColor = '#fff';
  123. container.style.border = '1px solid #ccc';
  124. container.style.padding = '10px';
  125. container.style.fontFamily = 'Arial, sans-serif';
  126. container.style.fontSize = '14px';
  127. container.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
  128. container.style.maxWidth = '250px';
  129. container.style.maxHeight = '80vh';
  130. container.style.overflowY = 'auto';
  131.  
  132. // The header now includes race count and current score.
  133. container.innerHTML = `
  134. <div style="margin-bottom: 8px; font-weight: bold;">Tier Test Logger</div>
  135. <!-- Player Management Section -->
  136. <div>
  137. <input type="text" id="playerNameInput" placeholder="Enter exact player name" style="width: 150px; padding: 5px;" />
  138. <button id="addPlayerButton" style="padding: 5px;">Add</button>
  139. <button id="clearPlayersBtn" style="padding: 5px;">Clear Players</button>
  140. <div id="playersList" style="margin-top: 8px; border: 1px solid #eee; padding: 5px; max-height: 100px; overflow-y: auto;"></div>
  141. </div>
  142. <hr style="margin: 8px 0;">
  143. <!-- Logging Controls Section -->
  144. <div>
  145. <button id="toggleLoggingBtn" style="padding: 5px;">Logging: OFF</button>
  146. <button id="saveResultsBtn" style="padding: 5px;">Save Results</button>
  147. <button id="clearResultsBtn" style="padding: 5px;">Clear Results</button>
  148. </div>
  149. <hr style="margin: 8px 0;">
  150. <!-- Race Results Header -->
  151. <div style="font-weight: bold; margin-bottom: 5px;">
  152. Race Results (<span id="raceCount">0</span> Total) | Current Score: <span id="currentScore">0-0</span>
  153. </div>
  154. <!-- Race Results Display Section -->
  155. <div id="raceResultsUI" style="border: 1px solid #eee; padding: 5px; max-height: 200px; overflow-y: auto;"></div>
  156. `;
  157. document.body.appendChild(container);
  158.  
  159. document.getElementById('addPlayerButton').addEventListener('click', () => {
  160. const input = document.getElementById('playerNameInput');
  161. let player = input.value.trim();
  162. if (player && !selectedPlayers.includes(player)) {
  163. selectedPlayers.push(player);
  164. localStorage.setItem("nitroSelectedPlayers", JSON.stringify(selectedPlayers));
  165. updatePlayersList();
  166. }
  167. input.value = '';
  168. });
  169. document.getElementById('clearPlayersBtn').addEventListener('click', () => {
  170. selectedPlayers = [];
  171. localStorage.setItem("nitroSelectedPlayers", JSON.stringify(selectedPlayers));
  172. updatePlayersList();
  173. });
  174. document.getElementById('toggleLoggingBtn').addEventListener('click', toggleLogging);
  175. document.getElementById('saveResultsBtn').addEventListener('click', showResultsInNewTab);
  176. document.getElementById('clearResultsBtn').addEventListener('click', clearResults);
  177.  
  178. updatePlayersList();
  179. updateRaceResultsUI();
  180. }
  181.  
  182. function updatePlayersList() {
  183. const listDiv = document.getElementById('playersList');
  184. listDiv.innerHTML = '';
  185. if (selectedPlayers.length === 0) {
  186. listDiv.textContent = 'No players added.';
  187. return;
  188. }
  189. const ul = document.createElement('ul');
  190. selectedPlayers.forEach((player, index) => {
  191. const li = document.createElement('li');
  192. li.style.fontSize = '12px';
  193. li.textContent = player + " ";
  194. const removeBtn = document.createElement('button');
  195. removeBtn.textContent = 'Remove';
  196. removeBtn.style.fontSize = '10px';
  197. removeBtn.style.marginLeft = '5px';
  198. removeBtn.addEventListener('click', () => {
  199. selectedPlayers.splice(index, 1);
  200. localStorage.setItem("nitroSelectedPlayers", JSON.stringify(selectedPlayers));
  201. updatePlayersList();
  202. });
  203. li.appendChild(removeBtn);
  204. ul.appendChild(li);
  205. });
  206. listDiv.appendChild(ul);
  207. }
  208.  
  209. function updateRaceResultsUI() {
  210. const container = document.getElementById('raceResultsUI');
  211. const storedResults = getStoredResults();
  212. container.innerHTML = '';
  213. // Update race count and current score in the header.
  214. document.getElementById('raceCount').textContent = storedResults.length;
  215. let cumulativeScore = getCumulativeScore();
  216. document.getElementById('currentScore').textContent = `${cumulativeScore.A || 0}-${cumulativeScore.B || 0}`;
  217. if (storedResults.length === 0) {
  218. container.textContent = 'No results yet.';
  219. return;
  220. }
  221. const ul = document.createElement('ul');
  222. storedResults.forEach((result, index) => {
  223. const li = document.createElement('li');
  224. li.style.fontSize = '12px';
  225. li.style.marginBottom = '3px';
  226. li.innerHTML = `Race ${result.raceNumber}: ${result.details}. Cumulative Averages: ${result.cumAverages}. Score: ${result.score} `;
  227. const delBtn = document.createElement('button');
  228. delBtn.textContent = "Delete";
  229. delBtn.style.fontSize = '10px';
  230. delBtn.style.marginLeft = '5px';
  231. delBtn.style.backgroundColor = "red";
  232. delBtn.style.color = "white";
  233. delBtn.style.border = "none";
  234. delBtn.style.padding = "2px 5px";
  235. delBtn.addEventListener('click', () => deleteRace(index));
  236. li.appendChild(delBtn);
  237. ul.appendChild(li);
  238. });
  239. container.appendChild(ul);
  240. }
  241.  
  242. // ========================
  243. // Recalculate Cumulative Stats and Score
  244. // ========================
  245. function recalcCumulative() {
  246. let results = getStoredResults();
  247. let newStats = {}; // Rebuild cumulative stats from remaining races
  248. let newScore = { A: 0, B: 0 }; // Recalculate cumulative score
  249.  
  250. results.forEach(race => {
  251. if (race.rawEntries && race.rawEntries.length > 0) {
  252. // Rebuild cumulative stats for each player in this race
  253. race.rawEntries.forEach(entry => {
  254. const username = entry.username;
  255. const wpm = parseFloat(entry.wpm);
  256. const acc = parseFloat(entry.accuracy);
  257. if (!newStats[username]) {
  258. newStats[username] = { sumWpm: 0, sumAcc: 0, count: 0 };
  259. }
  260. newStats[username].sumWpm += isNaN(wpm) ? 0 : wpm;
  261. newStats[username].sumAcc += isNaN(acc) ? 0 : acc;
  262. newStats[username].count += 1;
  263. });
  264. // Update score by checking which player won this race
  265. let winningEntry = race.rawEntries.find(entry => entry.winnerIndicator);
  266. if (winningEntry) {
  267. if (selectedPlayers[0] && winningEntry.username === selectedPlayers[0].trim()) {
  268. newScore.A += 1;
  269. } else if (selectedPlayers[1] && winningEntry.username === selectedPlayers[1].trim()) {
  270. newScore.B += 1;
  271. }
  272. }
  273. }
  274. });
  275. saveCumulativeStats(newStats);
  276. saveCumulativeScore(newScore);
  277. }
  278.  
  279. // ========================
  280. // Delete Race Function (Updated)
  281. // ========================
  282. function deleteRace(index) {
  283. let results = getStoredResults();
  284. if (index < 0 || index >= results.length) return;
  285. results.splice(index, 1);
  286. // Reassign race numbers after deletion
  287. results.forEach((res, i) => { res.raceNumber = i + 1; });
  288. saveStoredResults(results);
  289.  
  290. // Recalculate cumulative stats and score based on the remaining races
  291. recalcCumulative();
  292.  
  293. updateRaceResultsUI();
  294. console.log(`Deleted race #${index + 1} and recalculated cumulative stats and score.`);
  295. }
  296.  
  297. // ========================
  298. // Process Race Results
  299. // ========================
  300. function processRaceResults() {
  301. const raceContainer = document.querySelector('.gridTable.gridTable--raceResults');
  302. if (!raceContainer) return;
  303. if (raceContainer.dataset.processed === "true") return;
  304. const rows = Array.from(raceContainer.querySelectorAll('.gridTable-row'));
  305. let trackedRows = rows.filter(row => {
  306. const name = getPlayerNameFromRow(row);
  307. if (!selectedPlayers.includes(name)) return false;
  308. const statsContainer = row.querySelector('.split.split--flag.split--reverse .list.list--inline');
  309. if (!statsContainer) return false;
  310. const listItems = statsContainer.querySelectorAll('.list-item');
  311. return listItems.length >= 3 && listItems[2].textContent.includes("secs");
  312. });
  313. let expectedCount = rows.filter(row => selectedPlayers.includes(getPlayerNameFromRow(row))).length;
  314. if (expectedCount > 1 && trackedRows.length < expectedCount) return;
  315. raceContainer.dataset.processed = "true";
  316. let resultsArray = getStoredResults();
  317. let raceNumber = resultsArray.length > 0 ? (resultsArray[resultsArray.length - 1].raceNumber + 1) : 1;
  318. let newEntries = trackedRows.map(row => {
  319. const username = getPlayerNameFromRow(row);
  320. const statsContainer = row.querySelector('.split.split--flag.split--reverse .list.list--inline');
  321. const listItems = statsContainer.querySelectorAll('.list-item');
  322. const wpm = listItems[0].textContent.trim().split(" ")[0];
  323. const acc = listItems[1].textContent.trim().toLowerCase().includes("n/a") ? "N/A" : listItems[1].textContent.trim().split("%")[0];
  324. return {
  325. username: username,
  326. wpm: parseFloat(wpm),
  327. accuracy: parseFloat(acc),
  328. winnerIndicator: ""
  329. };
  330. });
  331. newEntries.sort((a, b) => selectedPlayers.indexOf(a.username) - selectedPlayers.indexOf(b.username));
  332. let winningEntry = null;
  333. if (newEntries.length >= 2) {
  334. let maxWpm = -Infinity;
  335. newEntries.forEach(entry => {
  336. if (entry.wpm > maxWpm) {
  337. maxWpm = entry.wpm;
  338. winningEntry = entry;
  339. }
  340. });
  341. if (winningEntry) {
  342. if (selectedPlayers[0] && winningEntry.username === selectedPlayers[0].trim()) {
  343. winningEntry.winnerIndicator = "A";
  344. } else if (selectedPlayers[1] && winningEntry.username === selectedPlayers[1].trim()) {
  345. winningEntry.winnerIndicator = "B";
  346. }
  347. }
  348. }
  349. updateCumulativeStats(newEntries);
  350. const cumAverages = getCumulativeAverages();
  351. let cumAvgStr = selectedPlayers.map(name => {
  352. if (cumAverages[name] && cumAverages[name].avgWpm !== "N/A") {
  353. let avgWpm = parseFloat(cumAverages[name].avgWpm);
  354. let tier = getTier(avgWpm);
  355. return `${name}: ${cumAverages[name].avgWpm} WPM, ${cumAverages[name].avgAcc}% Acc, Tier: ${tier}`;
  356. } else {
  357. return `${name}: N/A`;
  358. }
  359. }).join("; ");
  360. let cumulativeScore = getCumulativeScore();
  361. if (newEntries.length >= 2 && winningEntry) {
  362. if (winningEntry.username === selectedPlayers[0].trim()) {
  363. cumulativeScore.A = (cumulativeScore.A || 0) + 1;
  364. } else if (selectedPlayers[1] && winningEntry.username === selectedPlayers[1].trim()) {
  365. cumulativeScore.B = (cumulativeScore.B || 0) + 1;
  366. }
  367. saveCumulativeScore(cumulativeScore);
  368. }
  369. let scoreStr = `${cumulativeScore.A || 0}-${cumulativeScore.B || 0}`;
  370. let detailsStr = newEntries.map(entry => {
  371. let s = `${entry.username}: ${entry.wpm} WPM, ${entry.accuracy}%`;
  372. if (entry.winnerIndicator) {
  373. s += ` (Winner: ${entry.winnerIndicator})`;
  374. }
  375. return s;
  376. }).join("; ");
  377. let summary = {
  378. raceNumber: raceNumber,
  379. details: detailsStr,
  380. cumAverages: cumAvgStr,
  381. score: scoreStr,
  382. rawEntries: newEntries
  383. };
  384. resultsArray.push(summary);
  385. saveStoredResults(resultsArray);
  386. updateRaceResultsUI();
  387. console.log(`Processed Race #${raceNumber}:`, summary);
  388. }
  389.  
  390. // ========================
  391. // Logging Control Functions
  392. // ========================
  393. function startLogging() {
  394. processRaceResults();
  395. observer = new MutationObserver(mutationsList => {
  396. processRaceResults();
  397. });
  398. observer.observe(document.body, { childList: true, subtree: true });
  399. isLogging = true;
  400. document.getElementById('toggleLoggingBtn').textContent = "Logging: ON";
  401. localStorage.setItem(LOGGING_STATE_KEY, "true");
  402. console.log("Logging turned ON (results persist across pages).");
  403. }
  404. function stopLogging() {
  405. if (observer) {
  406. observer.disconnect();
  407. observer = null;
  408. }
  409. isLogging = false;
  410. document.getElementById('toggleLoggingBtn').textContent = "Logging: OFF";
  411. localStorage.setItem(LOGGING_STATE_KEY, "false");
  412. console.log("Logging turned OFF (stored results are preserved).");
  413. }
  414. function toggleLogging() {
  415. if (isLogging) {
  416. stopLogging();
  417. } else {
  418. startLogging();
  419. }
  420. }
  421.  
  422. // ========================
  423. // Save & Clear Functions
  424. // ========================
  425. function showResultsInNewTab() {
  426. let results = getStoredResults();
  427. if (results.length === 0) {
  428. alert("No results to show.");
  429. return;
  430. }
  431. let ntUsernames = selectedPlayers.join(" / ");
  432. let prevRanks = selectedPlayers.map(() => "N/A").join(" / ");
  433. let cumAvgs = getCumulativeAverages();
  434. let ranksAttained = selectedPlayers.map(name => {
  435. if (cumAvgs[name] && cumAvgs[name].avgWpm !== "N/A") {
  436. return getTier(parseFloat(cumAvgs[name].avgWpm));
  437. } else {
  438. return "N/A";
  439. }
  440. }).join(" / ");
  441. let avScores = selectedPlayers.map(name => {
  442. if (cumAvgs[name] && cumAvgs[name].avgWpm !== "N/A") {
  443. return cumAvgs[name].avgWpm;
  444. } else {
  445. return "N/A";
  446. }
  447. }).join(" vs ");
  448. let lastRace = results[results.length - 1];
  449. let winnerName = "N/A";
  450. if (lastRace.rawEntries) {
  451. for (let entry of lastRace.rawEntries) {
  452. if (entry.winnerIndicator) {
  453. winnerName = entry.username;
  454. break;
  455. }
  456. }
  457. }
  458. let resultLine = `${winnerName} wins ${lastRace.score}`;
  459. let scoresSection = results.map(race => {
  460. if (race.rawEntries && race.rawEntries.length >= 2) {
  461. let first = race.rawEntries[0];
  462. let second = race.rawEntries[1];
  463. let winIndicator = "";
  464. for (let entry of race.rawEntries) {
  465. if (entry.winnerIndicator) {
  466. winIndicator = entry.winnerIndicator;
  467. break;
  468. }
  469. }
  470. return `${first.wpm} (${first.accuracy}%) vs ${second.wpm} (${second.accuracy}%) ${winIndicator}`;
  471. } else if (race.rawEntries && race.rawEntries.length === 1) {
  472. let only = race.rawEntries[0];
  473. return `${only.wpm} (${only.accuracy}%)`;
  474. } else {
  475. return race.details;
  476. }
  477. }).join('\n');
  478. let finalOutput = `NT usernames: ${ntUsernames}
  479. Previous Ranks: ${prevRanks}
  480. Ranks Attained: ${ranksAttained}
  481. Av Scores: ${avScores}
  482. Result: ${resultLine}
  483.  
  484. Scores:
  485. ${scoresSection}`;
  486. let newTab = window.open('', '_blank');
  487. if (!newTab) {
  488. alert("Could not open new tab. Please allow pop-ups for NitroType.");
  489. return;
  490. }
  491. newTab.document.write('<html><head><title>Race Results</title></head><body>');
  492. newTab.document.write('<pre style="white-space: pre-wrap;">' + finalOutput + '</pre>');
  493. newTab.document.write('</body></html>');
  494. newTab.document.close();
  495. }
  496. function clearResults() {
  497. saveStoredResults([]);
  498. updateRaceResultsUI();
  499. saveCumulativeStats({});
  500. saveCumulativeScore({A: 0, B: 0});
  501. console.log("Cleared stored race results, cumulative stats, and cumulative score.");
  502. }
  503.  
  504. // ========================
  505. // Initialization
  506. // ========================
  507. window.addEventListener('load', () => {
  508. createUI();
  509. if (localStorage.getItem(LOGGING_STATE_KEY) === "true") {
  510. startLogging();
  511. }
  512. });
  513. })();