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.

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         NitroType Tier Test and Duel Logger
// @namespace    http://tampermonkey.net/
// @version      1.15.1
// @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.
// @license      MIT
// @match        *://www.nitrotype.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // --- Global Variables ---
    let selectedPlayers = JSON.parse(localStorage.getItem("nitroSelectedPlayers") || "[]");
    const RESULTS_KEY = "nitroTierTests";
    const LOGGING_STATE_KEY = "nitroLoggingActive";
    const CUM_STATS_KEY = "nitroCumulativeStats";  // For cumulative stats (sums & counts)
    const SCORE_KEY = "nitroScore";                // For cumulative score
    let observer = null;
    let isLogging = false;

    // ========================
    // Tier Calculation Helper (with rounding)
    // ========================
    function getTier(avgWpm) {
        let rounded = Math.round(avgWpm);
        if (rounded >= 175) return "HT1";
        else if (rounded >= 160) return "LT1";
        else if (rounded >= 145) return "HT2";
        else if (rounded >= 130) return "LT2";
        else if (rounded >= 115) return "HT3";
        else if (rounded >= 100) return "LT3";
        else if (rounded >= 85)  return "HT4";
        else if (rounded >= 70)  return "LT4";
        else if (rounded >= 50)  return "HT5";
        else return "LT5";
    }

    // ========================
    // Cumulative Score & Stats Helpers
    // ========================
    function getCumulativeScore() {
        return JSON.parse(localStorage.getItem(SCORE_KEY) || '{"A":0,"B":0}');
    }
    function saveCumulativeScore(scoreObj) {
        localStorage.setItem(SCORE_KEY, JSON.stringify(scoreObj));
    }
    function getCumulativeStats() {
        return JSON.parse(localStorage.getItem(CUM_STATS_KEY) || "{}");
    }
    function saveCumulativeStats(stats) {
        localStorage.setItem(CUM_STATS_KEY, JSON.stringify(stats));
    }
    // Updates cumulative stats by adding the data from a new race's entries.
    function updateCumulativeStats(newRaceEntries) {
        let stats = getCumulativeStats();
        newRaceEntries.forEach(entry => {
            const name = entry.username;
            const wpm = parseFloat(entry.wpm);
            const acc = parseFloat(entry.accuracy);
            if (!stats[name]) {
                stats[name] = { sumWpm: 0, sumAcc: 0, count: 0 };
            }
            stats[name].sumWpm += isNaN(wpm) ? 0 : wpm;
            stats[name].sumAcc += isNaN(acc) ? 0 : acc;
            stats[name].count += 1;
        });
        saveCumulativeStats(stats);
    }
    function getCumulativeAverages() {
        let stats = getCumulativeStats();
        let averages = {};
        selectedPlayers.forEach(name => {
            if (stats[name] && stats[name].count > 0) {
                averages[name] = {
                    avgWpm: (stats[name].sumWpm / stats[name].count).toFixed(2),
                    avgAcc: (stats[name].sumAcc / stats[name].count).toFixed(2)
                };
            } else {
                averages[name] = { avgWpm: "N/A", avgAcc: "N/A" };
            }
        });
        return averages;
    }

    // ========================
    // Storage Helpers for Race Results
    // ========================
    function getStoredResults() {
        return JSON.parse(localStorage.getItem(RESULTS_KEY) || "[]");
    }
    function saveStoredResults(results) {
        localStorage.setItem(RESULTS_KEY, JSON.stringify(results));
    }

    // ========================
    // Helper: Extract Exact Player Name from a Row
    // ========================
    function getPlayerNameFromRow(row) {
        const container = row.querySelector('.raceResults-playerName .player-name--container');
        if (!container) return "";
        const span = container.querySelector('span.type-ellip');
        if (span) return span.textContent.trim();
        const anchor = container.querySelector('a');
        if (anchor) return anchor.textContent.replace(/[\[\]]/g, '').trim();
        return container.textContent.trim();
    }

    // ========================
    // UI Creation
    // ========================
    function createUI() {
        if (document.getElementById('nt-tier-test-logger-ui')) return;

        const container = document.createElement('div');
        container.id = 'nt-tier-test-logger-ui';
        container.style.position = 'fixed';
        container.style.top = '10px';
        container.style.right = '10px';
        container.style.zIndex = '10000';
        container.style.backgroundColor = '#fff';
        container.style.border = '1px solid #ccc';
        container.style.padding = '10px';
        container.style.fontFamily = 'Arial, sans-serif';
        container.style.fontSize = '14px';
        container.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
        container.style.maxWidth = '250px';
        container.style.maxHeight = '80vh';
        container.style.overflowY = 'auto';

        // The header now includes race count and current score.
        container.innerHTML = `
            <div style="margin-bottom: 8px; font-weight: bold;">Tier Test Logger</div>
            <!-- Player Management Section -->
            <div>
              <input type="text" id="playerNameInput" placeholder="Enter exact player name" style="width: 150px; padding: 5px;" />
              <button id="addPlayerButton" style="padding: 5px;">Add</button>
              <button id="clearPlayersBtn" style="padding: 5px;">Clear Players</button>
              <div id="playersList" style="margin-top: 8px; border: 1px solid #eee; padding: 5px; max-height: 100px; overflow-y: auto;"></div>
            </div>
            <hr style="margin: 8px 0;">
            <!-- Logging Controls Section -->
            <div>
              <button id="toggleLoggingBtn" style="padding: 5px;">Logging: OFF</button>
              <button id="saveResultsBtn" style="padding: 5px;">Save Results</button>
              <button id="clearResultsBtn" style="padding: 5px;">Clear Results</button>
            </div>
            <hr style="margin: 8px 0;">
            <!-- Race Results Header -->
            <div style="font-weight: bold; margin-bottom: 5px;">
              Race Results (<span id="raceCount">0</span> Total) | Current Score: <span id="currentScore">0-0</span>
            </div>
            <!-- Race Results Display Section -->
            <div id="raceResultsUI" style="border: 1px solid #eee; padding: 5px; max-height: 200px; overflow-y: auto;"></div>
        `;
        document.body.appendChild(container);

        document.getElementById('addPlayerButton').addEventListener('click', () => {
            const input = document.getElementById('playerNameInput');
            let player = input.value.trim();
            if (player && !selectedPlayers.includes(player)) {
                selectedPlayers.push(player);
                localStorage.setItem("nitroSelectedPlayers", JSON.stringify(selectedPlayers));
                updatePlayersList();
            }
            input.value = '';
        });
        document.getElementById('clearPlayersBtn').addEventListener('click', () => {
            selectedPlayers = [];
            localStorage.setItem("nitroSelectedPlayers", JSON.stringify(selectedPlayers));
            updatePlayersList();
        });
        document.getElementById('toggleLoggingBtn').addEventListener('click', toggleLogging);
        document.getElementById('saveResultsBtn').addEventListener('click', showResultsInNewTab);
        document.getElementById('clearResultsBtn').addEventListener('click', clearResults);

        updatePlayersList();
        updateRaceResultsUI();
    }

    function updatePlayersList() {
        const listDiv = document.getElementById('playersList');
        listDiv.innerHTML = '';
        if (selectedPlayers.length === 0) {
            listDiv.textContent = 'No players added.';
            return;
        }
        const ul = document.createElement('ul');
        selectedPlayers.forEach((player, index) => {
            const li = document.createElement('li');
            li.style.fontSize = '12px';
            li.textContent = player + " ";
            const removeBtn = document.createElement('button');
            removeBtn.textContent = 'Remove';
            removeBtn.style.fontSize = '10px';
            removeBtn.style.marginLeft = '5px';
            removeBtn.addEventListener('click', () => {
                selectedPlayers.splice(index, 1);
                localStorage.setItem("nitroSelectedPlayers", JSON.stringify(selectedPlayers));
                updatePlayersList();
            });
            li.appendChild(removeBtn);
            ul.appendChild(li);
        });
        listDiv.appendChild(ul);
    }

    function updateRaceResultsUI() {
        const container = document.getElementById('raceResultsUI');
        const storedResults = getStoredResults();
        container.innerHTML = '';
        // Update race count and current score in the header.
        document.getElementById('raceCount').textContent = storedResults.length;
        let cumulativeScore = getCumulativeScore();
        document.getElementById('currentScore').textContent = `${cumulativeScore.A || 0}-${cumulativeScore.B || 0}`;
        if (storedResults.length === 0) {
            container.textContent = 'No results yet.';
            return;
        }
        const ul = document.createElement('ul');
        storedResults.forEach((result, index) => {
            const li = document.createElement('li');
            li.style.fontSize = '12px';
            li.style.marginBottom = '3px';
            li.innerHTML = `Race ${result.raceNumber}: ${result.details}. Cumulative Averages: ${result.cumAverages}. Score: ${result.score} `;
            const delBtn = document.createElement('button');
            delBtn.textContent = "Delete";
            delBtn.style.fontSize = '10px';
            delBtn.style.marginLeft = '5px';
            delBtn.style.backgroundColor = "red";
            delBtn.style.color = "white";
            delBtn.style.border = "none";
            delBtn.style.padding = "2px 5px";
            delBtn.addEventListener('click', () => deleteRace(index));
            li.appendChild(delBtn);
            ul.appendChild(li);
        });
        container.appendChild(ul);
    }

    // ========================
    // Recalculate Cumulative Stats and Score
    // ========================
    function recalcCumulative() {
        let results = getStoredResults();
        let newStats = {};           // Rebuild cumulative stats from remaining races
        let newScore = { A: 0, B: 0 };  // Recalculate cumulative score

        results.forEach(race => {
            if (race.rawEntries && race.rawEntries.length > 0) {
                // Rebuild cumulative stats for each player in this race
                race.rawEntries.forEach(entry => {
                    const username = entry.username;
                    const wpm = parseFloat(entry.wpm);
                    const acc = parseFloat(entry.accuracy);
                    if (!newStats[username]) {
                        newStats[username] = { sumWpm: 0, sumAcc: 0, count: 0 };
                    }
                    newStats[username].sumWpm += isNaN(wpm) ? 0 : wpm;
                    newStats[username].sumAcc += isNaN(acc) ? 0 : acc;
                    newStats[username].count += 1;
                });
                // Update score by checking which player won this race
                let winningEntry = race.rawEntries.find(entry => entry.winnerIndicator);
                if (winningEntry) {
                    if (selectedPlayers[0] && winningEntry.username === selectedPlayers[0].trim()) {
                        newScore.A += 1;
                    } else if (selectedPlayers[1] && winningEntry.username === selectedPlayers[1].trim()) {
                        newScore.B += 1;
                    }
                }
            }
        });
        saveCumulativeStats(newStats);
        saveCumulativeScore(newScore);
    }

    // ========================
    // Delete Race Function (Updated)
    // ========================
    function deleteRace(index) {
        let results = getStoredResults();
        if (index < 0 || index >= results.length) return;
        results.splice(index, 1);
        // Reassign race numbers after deletion
        results.forEach((res, i) => { res.raceNumber = i + 1; });
        saveStoredResults(results);

        // Recalculate cumulative stats and score based on the remaining races
        recalcCumulative();

        updateRaceResultsUI();
        console.log(`Deleted race #${index + 1} and recalculated cumulative stats and score.`);
    }

    // ========================
    // Process Race Results
    // ========================
    function processRaceResults() {
        const raceContainer = document.querySelector('.gridTable.gridTable--raceResults');
        if (!raceContainer) return;
        if (raceContainer.dataset.processed === "true") return;
        const rows = Array.from(raceContainer.querySelectorAll('.gridTable-row'));
        let trackedRows = rows.filter(row => {
            const name = getPlayerNameFromRow(row);
            if (!selectedPlayers.includes(name)) return false;
            const statsContainer = row.querySelector('.split.split--flag.split--reverse .list.list--inline');
            if (!statsContainer) return false;
            const listItems = statsContainer.querySelectorAll('.list-item');
            return listItems.length >= 3 && listItems[2].textContent.includes("secs");
        });
        let expectedCount = rows.filter(row => selectedPlayers.includes(getPlayerNameFromRow(row))).length;
        if (expectedCount > 1 && trackedRows.length < expectedCount) return;
        raceContainer.dataset.processed = "true";
        let resultsArray = getStoredResults();
        let raceNumber = resultsArray.length > 0 ? (resultsArray[resultsArray.length - 1].raceNumber + 1) : 1;
        let newEntries = trackedRows.map(row => {
            const username = getPlayerNameFromRow(row);
            const statsContainer = row.querySelector('.split.split--flag.split--reverse .list.list--inline');
            const listItems = statsContainer.querySelectorAll('.list-item');
            const wpm = listItems[0].textContent.trim().split(" ")[0];
            const acc = listItems[1].textContent.trim().toLowerCase().includes("n/a") ? "N/A" : listItems[1].textContent.trim().split("%")[0];
            return {
                username: username,
                wpm: parseFloat(wpm),
                accuracy: parseFloat(acc),
                winnerIndicator: ""
            };
        });
        newEntries.sort((a, b) => selectedPlayers.indexOf(a.username) - selectedPlayers.indexOf(b.username));
        let winningEntry = null;
        if (newEntries.length >= 2) {
            let maxWpm = -Infinity;
            newEntries.forEach(entry => {
                if (entry.wpm > maxWpm) {
                    maxWpm = entry.wpm;
                    winningEntry = entry;
                }
            });
            if (winningEntry) {
                if (selectedPlayers[0] && winningEntry.username === selectedPlayers[0].trim()) {
                    winningEntry.winnerIndicator = "A";
                } else if (selectedPlayers[1] && winningEntry.username === selectedPlayers[1].trim()) {
                    winningEntry.winnerIndicator = "B";
                }
            }
        }
        updateCumulativeStats(newEntries);
        const cumAverages = getCumulativeAverages();
        let cumAvgStr = selectedPlayers.map(name => {
            if (cumAverages[name] && cumAverages[name].avgWpm !== "N/A") {
                let avgWpm = parseFloat(cumAverages[name].avgWpm);
                let tier = getTier(avgWpm);
                return `${name}: ${cumAverages[name].avgWpm} WPM, ${cumAverages[name].avgAcc}% Acc, Tier: ${tier}`;
            } else {
                return `${name}: N/A`;
            }
        }).join("; ");
        let cumulativeScore = getCumulativeScore();
        if (newEntries.length >= 2 && winningEntry) {
            if (winningEntry.username === selectedPlayers[0].trim()) {
                cumulativeScore.A = (cumulativeScore.A || 0) + 1;
            } else if (selectedPlayers[1] && winningEntry.username === selectedPlayers[1].trim()) {
                cumulativeScore.B = (cumulativeScore.B || 0) + 1;
            }
            saveCumulativeScore(cumulativeScore);
        }
        let scoreStr = `${cumulativeScore.A || 0}-${cumulativeScore.B || 0}`;
        let detailsStr = newEntries.map(entry => {
            let s = `${entry.username}: ${entry.wpm} WPM, ${entry.accuracy}%`;
            if (entry.winnerIndicator) {
                s += ` (Winner: ${entry.winnerIndicator})`;
            }
            return s;
        }).join("; ");
        let summary = {
            raceNumber: raceNumber,
            details: detailsStr,
            cumAverages: cumAvgStr,
            score: scoreStr,
            rawEntries: newEntries
        };
        resultsArray.push(summary);
        saveStoredResults(resultsArray);
        updateRaceResultsUI();
        console.log(`Processed Race #${raceNumber}:`, summary);
    }

    // ========================
    // Logging Control Functions
    // ========================
    function startLogging() {
        processRaceResults();
        observer = new MutationObserver(mutationsList => {
            processRaceResults();
        });
        observer.observe(document.body, { childList: true, subtree: true });
        isLogging = true;
        document.getElementById('toggleLoggingBtn').textContent = "Logging: ON";
        localStorage.setItem(LOGGING_STATE_KEY, "true");
        console.log("Logging turned ON (results persist across pages).");
    }
    function stopLogging() {
        if (observer) {
            observer.disconnect();
            observer = null;
        }
        isLogging = false;
        document.getElementById('toggleLoggingBtn').textContent = "Logging: OFF";
        localStorage.setItem(LOGGING_STATE_KEY, "false");
        console.log("Logging turned OFF (stored results are preserved).");
    }
    function toggleLogging() {
        if (isLogging) {
            stopLogging();
        } else {
            startLogging();
        }
    }

    // ========================
    // Save & Clear Functions
    // ========================
    function showResultsInNewTab() {
        let results = getStoredResults();
        if (results.length === 0) {
            alert("No results to show.");
            return;
        }
        let ntUsernames = selectedPlayers.join(" / ");
        let prevRanks = selectedPlayers.map(() => "N/A").join(" / ");
        let cumAvgs = getCumulativeAverages();
        let ranksAttained = selectedPlayers.map(name => {
            if (cumAvgs[name] && cumAvgs[name].avgWpm !== "N/A") {
                return getTier(parseFloat(cumAvgs[name].avgWpm));
            } else {
                return "N/A";
            }
        }).join(" / ");
        let avScores = selectedPlayers.map(name => {
            if (cumAvgs[name] && cumAvgs[name].avgWpm !== "N/A") {
                return cumAvgs[name].avgWpm;
            } else {
                return "N/A";
            }
        }).join(" vs ");
        let lastRace = results[results.length - 1];
        let winnerName = "N/A";
        if (lastRace.rawEntries) {
            for (let entry of lastRace.rawEntries) {
                if (entry.winnerIndicator) {
                    winnerName = entry.username;
                    break;
                }
            }
        }
        let resultLine = `${winnerName} wins ${lastRace.score}`;
        let scoresSection = results.map(race => {
            if (race.rawEntries && race.rawEntries.length >= 2) {
                let first = race.rawEntries[0];
                let second = race.rawEntries[1];
                let winIndicator = "";
                for (let entry of race.rawEntries) {
                    if (entry.winnerIndicator) {
                        winIndicator = entry.winnerIndicator;
                        break;
                    }
                }
                return `${first.wpm} (${first.accuracy}%) vs ${second.wpm} (${second.accuracy}%) — ${winIndicator}`;
            } else if (race.rawEntries && race.rawEntries.length === 1) {
                let only = race.rawEntries[0];
                return `${only.wpm} (${only.accuracy}%)`;
            } else {
                return race.details;
            }
        }).join('\n');
        let finalOutput = `NT usernames: ${ntUsernames}
Previous Ranks: ${prevRanks}
Ranks Attained: ${ranksAttained}
Av Scores: ${avScores}
Result: ${resultLine}

Scores:
${scoresSection}`;
        let newTab = window.open('', '_blank');
        if (!newTab) {
            alert("Could not open new tab. Please allow pop-ups for NitroType.");
            return;
        }
        newTab.document.write('<html><head><title>Race Results</title></head><body>');
        newTab.document.write('<pre style="white-space: pre-wrap;">' + finalOutput + '</pre>');
        newTab.document.write('</body></html>');
        newTab.document.close();
    }
    function clearResults() {
        saveStoredResults([]);
        updateRaceResultsUI();
        saveCumulativeStats({});
        saveCumulativeScore({A: 0, B: 0});
        console.log("Cleared stored race results, cumulative stats, and cumulative score.");
    }

    // ========================
    // Initialization
    // ========================
    window.addEventListener('load', () => {
        createUI();
        if (localStorage.getItem(LOGGING_STATE_KEY) === "true") {
            startLogging();
        }
    });
})();