Torn Custom Race Evaluator

Scores and sorts custom races by RS potential, colors join button

// ==UserScript==
// @name         Torn Custom Race Evaluator
// @namespace    underko.torn.scripts.racing
// @version      1.1
// @author       underko[3362751]
// @description  Scores and sorts custom races by RS potential, colors join button
// @match        https://www.torn.com/loader.php?sid=racing*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const trackStats = {
        "Uptown":       { avgLapSec:  72, lapMiles: 2.25, tem: 0.862 },
        "Withdrawal":   { avgLapSec: 112, lapMiles: 3.40, tem: 0.825 },
        "Underdog":     { avgLapSec:  85, lapMiles: 1.73, tem: 0.593 },
        "Parkland":     { avgLapSec: 150, lapMiles: 3.43, tem: 0.608 },
        "Docks":        { avgLapSec: 160, lapMiles: 3.81, tem: 0.737 },
        "Commerce":     { avgLapSec:  50, lapMiles: 1.09, tem: 0.476 },
        "Two Islands":  { avgLapSec:  99, lapMiles: 2.71, tem: 0.799 },
        "Industrial":   { avgLapSec:  77, lapMiles: 1.35, tem: 0.451 },
        "Vector":       { avgLapSec:  61, lapMiles: 1.16, tem: 0.430 },
        "Mudpit":       { avgLapSec:  36, lapMiles: 1.06, tem: 0.510 },
        "Hammerhead":   { avgLapSec:  55, lapMiles: 1.16, tem: 0.482 },
        "Sewage":       { avgLapSec:  94, lapMiles: 1.50, tem: 0.000 }, // min
        "Meltdown":     { avgLapSec:  57, lapMiles: 1.20, tem: 0.499 },
        "Speedway":     { avgLapSec:  27, lapMiles: 0.90, tem: 1.000 }, // max
        "Stone Park":   { avgLapSec:  72, lapMiles: 2.08, tem: 0.695 },
        "Convict":      { avgLapSec:  60, lapMiles: 1.64, tem: 0.622 },
    };

    function calculateRaceScore(track, laps, participants, maxParticipants, waitMinutes) {
        const stats = trackStats[track];
        if (!stats) return { score: 0 };

        const avgLapTimeMin = stats.avgLapSec / 60;
        const totalRaceTimeMin = laps * avgLapTimeMin;

        // 1. Track Efficiency Multiplier (TEM)
        // Higher distance per second = better training yield.
        // Weight: 40%
        const tem = trackStats[track].tem;

        // 2. Lap Efficiency Multiplier (LEM)
        // Ideal is 80 laps, penalize deviation.
        // Weight: 20%
        const lapScore = 1 - Math.abs(laps - 80) / 80;

        // 3. Participant Count Multiplier (PCM)
        // Max value 1 at 100 players; 0.5 at 50 players, etc.
        // Weight: 100%
        const participantScore = Math.min(1, participants / 100);

        // 4. Wait Time Multiplier (WTM)
        // Wait time reduces score but is less punishing for good races.
        // Considers total race length.
        const waitRatio = waitMinutes / totalRaceTimeMin;
        const waitMultiplier = 1 - Math.min(1, waitRatio);

        // Final RS Score
        const baseScore = 0.4 * tem + 0.2 * lapScore + 1.0 * participantScore;
        const rawScore = 5 * baseScore * waitMultiplier;
        const finalScore = participants == maxParticipants ? 0 : Math.max(0, Math.min(5, rawScore));

        return {
            score: finalScore,
            tem: tem.toFixed(3),
            lem: lapScore.toFixed(3),
            pcm: participantScore.toFixed(3),
            wm: waitMultiplier.toFixed(3)
        };
    }

    function parseTrackName(nameRaw) {
        const knownTracks = Object.keys(trackStats);
        for (const track of knownTracks) {
            if (nameRaw.toLowerCase().includes(track.toLowerCase())) return track;
        }
        return "Unknown";
    }

    function parseWaitTime(str) {
        if (!str || str.toLowerCase().includes("waiting")) return 0;
        let mins = 0;
        const hMatch = str.match(/(\d+)\s*h/);
        const mMatch = str.match(/(\d+)\s*m/);
        if (hMatch) mins += parseInt(hMatch[1], 10) * 60;
        if (mMatch) mins += parseInt(mMatch[1], 10);
        return mins;
    }

    function parseLaps(str) {
        const match = str.match(/(\d+)\s*laps/i);
        return match ? parseInt(match[1], 10) : 80;
    }

    function waitForElement(selector, callback) {
        const processed = new WeakSet();

        const observer = new MutationObserver(() => {
            document.querySelectorAll(selector).forEach(el => {
                if (!processed.has(el)) {
                    processed.add(el);
                    callback(el);
                }
            });
        });

        observer.observe(document.body, { childList: true, subtree: true });

        document.querySelectorAll(selector).forEach(el => {
            if (!processed.has(el)) {
                processed.add(el);
                callback(el);
            }
        });
    }

    waitForElement('a[href*="createCustomRace"]', () => {
        const raceBlocks = Array.from(document.querySelectorAll('.events-list>li'));
        const raceData = [];

        raceBlocks.forEach(block => {
            const trackAndLapEl = block.querySelector('.track');
            const driversEl = block.querySelector('.drivers');
            const timeEl = block.querySelector('.startTime');
            const joinLi = block.querySelector('li.join');

            if (!trackAndLapEl || !driversEl || !timeEl || !joinLi) return;

            const trackAndLap = trackAndLapEl.innerText.trim();
            const track = parseTrackName(trackAndLap.split('(')[0].trim());
            const waitMinutes = parseWaitTime(timeEl.textContent.trim());
            const participants = parseInt(driversEl.textContent.replace(/\D+/g, ' ').trim().split(" ")[0].trim()) || 0;
            const maxParticipants = parseInt(driversEl.textContent.replace(/\D+/g, ' ').trim().split(" ")[1].trim()) || 0;
            const laps = parseLaps(trackAndLap || '');
            const raceScore = calculateRaceScore(track, laps, participants, maxParticipants, waitMinutes);

            const rawScore = raceScore.score;
            const tem = raceScore.tem;
            const lem = raceScore.lem;
            const pcm = raceScore.pcm;
            const wm = raceScore.wm;

            raceData.push({ block, track, laps, participants, waitMinutes, rawScore, joinLi, tem, lem, pcm, wm });
        });

        // Normalize scores to 0–5 range
        const scores = raceData.map(r => r.rawScore);
        const minScore = Math.min(...scores);
        const maxScore = Math.max(...scores);
        const scoreRange = maxScore - minScore || 1;

        raceData.forEach(r => {
            const normScore = ((r.rawScore - minScore) / scoreRange) * 5;
            const hue = (normScore / 5) * 120;
            const color = `hsl(${hue}, 100%, 50%)`;

            r.joinLi.style.background = color;
            r.joinLi.title = `RS Score: ${normScore.toFixed(2)} (${r.track}, ${r.laps} laps, ${r.waitMinutes} wait, ${r.participants} drivers)<br/>` +
                             `Debug: TEM: ${r.tem}, LEM: ${r.lem}, PCM: ${r.pcm}, WM: ${r.wm}`;
        });

        // Sort races by normalized score descending
        const container = document.querySelector('.events-list');
        if (container) {
            raceData.sort((a, b) => b.rawScore - a.rawScore);
            raceData.forEach(r => container.appendChild(r.block));
        }
    });
})();