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.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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();
        }
    });
})();