Greasy Fork 还支持 简体中文。

Torn: Find Good Race

Filters and sorts races based on entry requirements and availability

// ==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;
  }
})();