Torn: Find Good Race

Filters and sorts races based on entry requirements and availability

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

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

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

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

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