Torn: Find Good Race

Filters and sorts races based on entry requirements and availability

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn: Find Good Race
// @namespace    torn.com
// @version      0.11.1
// @description  Filters and sorts races based on entry requirements and availability
// @license      MIT
// @author       YoYo
// @match        https://www.torn.com/page.php?sid=racing*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const BUTTON_ID = 'find-good-race-btn';
  const SORT_BUTTON_ID = 'race-sort-toggle-btn';
  const SORT_STORAGE_KEY = 'torn-race-sort-mode'; // 'timeAsc' | 'driversDesc'
  const SORT_MODES = ['timeAsc', 'driversDesc'];

  // persistent sort mode
  function getSortMode() {
    const saved = localStorage.getItem(SORT_STORAGE_KEY);
    return SORT_MODES.includes(saved) ? saved : 'timeAsc';
  }
  function setSortMode(mode) {
    localStorage.setItem(SORT_STORAGE_KEY, mode);
  }
  function cycleSortMode() {
    const i = SORT_MODES.indexOf(getSortMode());
    const next = SORT_MODES[(i + 1) % SORT_MODES.length];
    setSortMode(next);
  }
  function modeLabel(mode = getSortMode()) {
    return mode === 'driversDesc' ? 'Sorting: Drivers ↓' : 'Sorting: Start Time ↑';
  }

  let lastSortable = [];   // cache of filtered races for quick re-sorting
  let lastUl = null;       // cache of list element for re-render

  const waitForTargets = setInterval(() => {
    const raceList = document.querySelector('#racingAdditionalContainer > div.custom-events-wrap > div.cont-black.bottom-round > ul');
    const controlDiv = document.querySelector('.cont-black.bottom-round .btn-wrap');
    if (raceList && controlDiv && !document.getElementById(BUTTON_ID)) {
      clearInterval(waitForTargets);
      injectFilterAndSortButtons(controlDiv, raceList);
    }
  }, 300);

  function injectFilterAndSortButtons(targetContainer, raceListUl) {
    // --- Find Good Race button (original) ---
    const btn = document.createElement('button');
    btn.id = BUTTON_ID;
    btn.textContent = 'Find Good Race';
    btn.style.cssText = baseBtnCss();

    // --- Sort toggle button ---
    const sortBtn = document.createElement('button');
    sortBtn.id = SORT_BUTTON_ID;
    sortBtn.textContent = 'Sort';
    sortBtn.style.cssText = baseBtnCss() + 'margin-left: 8px;';
    sortBtn.title = modeLabel();

    sortBtn.addEventListener('click', () => {
      cycleSortMode();
      sortBtn.title = modeLabel();
      if (lastSortable.length && lastUl) {
        sortAndRender(lastUl, lastSortable, getSortMode());
      }
    });

    btn.addEventListener('click', () => {
      const ul = raceListUl;
      lastUl = ul;
      const allLis = Array.from(ul.querySelectorAll(':scope > li'));

      const allowedCars = ['any car', 'any class a car'];

      // Build sortable records while filtering/hiding the rest
      const sortableLis = allLis.map(li => {
        // ❌ Password protected
        if (li.querySelector('.event-info .password.protected')) {
          li.style.display = 'none';
          return null;
        }

        // ❌ Car restriction
        const carLi = li.querySelector('.event-info .car');
        const carText = carLi?.textContent?.toLowerCase() || '';
        const isAllowed = allowedCars.some(allowed => carText.includes(allowed));
        if (!isAllowed) {
          li.style.display = 'none';
          return null;
        }

        // ❌ Full or low-capacity; also capture current drivers for sorting
        const driverLi = li.querySelector('.body-container .drivers');
        const match = driverLi?.textContent?.match(/(\d+)\s*\/\s*(\d+)/);
        let current = NaN, max = NaN;
        if (match) {
          current = parseInt(match[1], 10);
          max = parseInt(match[2], 10);
          if (current >= max || max < 50) {
            li.style.display = 'none';
            return null;
          }
        }

        // ✅ Parse time
        const timeEl = li.querySelector('.body-container .startTime');
        if (!timeEl) {
          li.style.display = 'none';
          return null;
        }
        const rawText = (timeEl.textContent || '').trim();

        // show visible; keep metrics for sorting
        li.style.display = '';
        return {
          li,
          drivers: Number.isFinite(current) ? current : -1,
          timeText: rawText,
          seconds: parseTimeToSeconds(rawText)
        };
      }).filter(Boolean);

      lastSortable = sortableLis;

      // Apply current sort mode
      sortAndRender(ul, sortableLis, getSortMode());
      console.log(`✅ Done. ${sortableLis.length} races shown. (${modeLabel()})`);
    });

    // Insert both buttons after the "START A CUSTOM RACE" button row
    targetContainer.parentElement.appendChild(btn);
    targetContainer.parentElement.appendChild(sortBtn);
  }

  function baseBtnCss() {
    return `
      margin-top: 10px;
      height: 32px;
      padding: 0 12px;
      font-size: 14px;
      font-family: "Fjalla One", Arial, serif;
      text-transform: uppercase;
      border-radius: 5px;
      cursor: pointer;
      color: #EEE;
      background: linear-gradient(180deg, #111111 0%, #555555 25%, #333333 60%, #333333 78%, #111111 100%);
      border: 1px solid #111;
    `;
  }

  function sortAndRender(ul, items, mode) {
    if (!ul || !items?.length) return;

    if (mode === 'driversDesc') {
      items.sort((a, b) => {
        // more drivers first; tie-breaker earliest start time
        const d = (b.drivers ?? -1) - (a.drivers ?? -1);
        if (d !== 0) return d;
        return (a.seconds ?? Number.POSITIVE_INFINITY) - (b.seconds ?? Number.POSITIVE_INFINITY);
      });
      console.log('🔃 Sorting good races by number of drivers (desc)...');
    } else {
      // 'timeAsc'
      items.sort((a, b) => {
        // earliest start first; tie-breaker more drivers
        const t = (a.seconds ?? Number.POSITIVE_INFINITY) - (b.seconds ?? Number.POSITIVE_INFINITY);
        if (t !== 0) return t;
        return (b.drivers ?? -1) - (a.drivers ?? -1);
      });
      console.log('🔃 Sorting good races by start time (asc)...');
    }

    // Re-append in sorted order
    const frag = document.createDocumentFragment();
    for (const obj of items) frag.appendChild(obj.li);
    ul.appendChild(frag);
  }

  function parseTimeToSeconds(text) {
    if (!text) return Number.POSITIVE_INFINITY;
    const lower = text.toLowerCase();
    if (lower.includes('waiting')) return 999999;

    // Supports patterns like: "1h 23m", "45m", "2h"
    let total = 0;
    const hrMatch = text.match(/(\d+)\s*h/);
    const minMatch = text.match(/(\d+)\s*m/);
    if (hrMatch) total += parseInt(hrMatch[1], 10) * 3600;
    if (minMatch) total += parseInt(minMatch[1], 10) * 60;
    if (total === 0) return Number.POSITIVE_INFINITY;
    return total;
  }
})();