Torn Target List Enhanced

Enhances Torn's Target list with hospital timers, travel status details, and sorting options

当前为 2025-03-11 提交的版本,查看 最新版本

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Torn Target List Enhanced
// @namespace    xentac
// @version      1.0.0
// @description  Enhances Torn's Target list with hospital timers, travel status details, and sorting options
// @author       xentac
// @license      MIT
// @match        https://www.torn.com/page.php?sid=list&type=targets*
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==
(function() {
  'use strict';

  // Configuration
  const CONFIG = {
    storageKey: 'xentac-torn_playerlist_enhanced-apikey',
    defaultKey: '###PDA-APIKEY###',
    refreshInterval: 5000,// Increased to 30 seconds to reduce API calls
    hospitalWarningThreshold: 60, // 5 minutes
    enableSorting: true,
    maxConcurrentRequests: 3, // Limit concurrent API requests
    requestDelay: 250,//Delay between API requests in ms
    cacheTime: 120000 // Cache player data for 2 minutes
  };

  // Country abbreviations lookup
  const COUNTRY_ABBREVIATIONS = {
    'South Africa': 'SA',
    'Cayman Islands': 'CI',
    'United Kingdom': 'UK',
    'Argentina': 'Arg',
    'Switzerland': 'Switz',
    'Mexico': 'Mex',
    'Canada': 'Can',
    'Hawaii': 'HI',
    'Japan': 'JP',
    'China': 'CN',
    'UAE': 'UAE',
    'United Arab Emirates': 'UAE'
  };

  // Status sort priorities (lower = higher priority)
  const STATUS_PRIORITIES = {
    'Hospital': 1,
    'Jail': 2,
    'Returning': 3,
    'Abroad': 4,
    'Traveling': 5,
    'Offline': 6,
    'Online': 7,
    'Default': 10
  };

  // Styles
  GM_addStyle(`
    .playerlist_highlight {
      background-color: rgba(255, 165, 0, 0.3) !important;
    }
    .playerlist_traveling .status___o6u8R span {
      color: #F287FF !important;
    }
    .playerlist_hospital_timer {
      font-weight: bold;
    }
    .playerlist_sort_controls {
      display: flex;
      justify-content: flex-end;
      margin: 5px 0;
      gap: 5px;
      padding: 5px;
    }
  `);

  // State
  let apiKey = localStorage.getItem(CONFIG.storageKey) ?? CONFIG.defaultKey;
  const hospitalTimers = new Map();
  const playerCache = new Map();
  const requestQueue = [];
  let processingQueue = false;
  let observerActive = true;
  let refreshInterval = null;
  let processedWrappers = new Set();
  let knownPlayerIds = new Set(); // Track known player IDs

  // Register menu command for API key
  try {
    GM_registerMenuCommand("Set API Key", () => promptForApiKey());
  } catch (error) {
    console.log("Menu command registration failed, likely running in Torn PDA");
  }

  // API key management
  function promptForApiKey() {
    const userInput = prompt(
      "Please enter a PUBLIC API Key for basic player information:",
      apiKey === CONFIG.defaultKey ? "" : apiKey
    );

    if (userInput && userInput.length === 16) {
      apiKey = userInput;
      localStorage.setItem(CONFIG.storageKey, userInput);
      return true;
    } else if (userInput !== null) {
      alert("Invalid API key. Please enter a 16-character key.");
    }
    return false;
  }

  // Check if API key is valid
  function validateApiKey() {
    if (apiKey === CONFIG.defaultKey || apiKey.length !== 16) {
      return promptForApiKey();
    }
    return true;
  }

  // Format time remaining
  function formatTimeRemaining(seconds) {
    if (seconds <= 0) return "Okay";

    const h = Math.floor(seconds / 3600);
    const m = Math.floor((seconds % 3600) / 60);
    const s = Math.floor(seconds % 60);

    return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
  }

  // Process travel status text
  function processTravelStatus(description) {
    if (!description) return "Unknown";

    // Replace country names with abbreviations
    let result = description;
    for (const [full, abbr] of Object.entries(COUNTRY_ABBREVIATIONS)) {
      result = result.replace(full, abbr);
    }

    // Format based on travel direction
    if (result.includes("Traveling to ")) {
      return "► " + result.split("Traveling to ")[1];
    } else if (result.includes("In ")) {
      return result.split("In ")[1];
    } else if (result.includes("Returning")) {
      return "◄ " + result.split("Returning to Torn from ")[1];
    } else if (result.includes("Traveling")) {
      return "Traveling";
    }

    return result;
  }

  // Process API request queue
  async function processRequestQueue() {
    if (processingQueue || requestQueue.length === 0) return;

    processingQueue = true;

    try {
      // Process up to maxConcurrentRequests at a time
      const batch = requestQueue.splice(0, CONFIG.maxConcurrentRequests);

      // Execute requests with delay between them
      for (let i = 0; i < batch.length; i++) {
        const request = batch[i];

        try {
          const data = await fetchPlayerData(request.playerId);
          request.resolve(data);
        } catch (error) {
          request.reject(error);
        }

        // Add delay between requests
        if (i < batch.length - 1) {
          await new Promise(resolve => setTimeout(resolve, CONFIG.requestDelay));
        }
      }
    } finally {
      processingQueue = false;

      // Continue processing if there are more requests
      if (requestQueue.length > 0) {
        setTimeout(processRequestQueue, CONFIG.requestDelay);
      }
    }
  }

  // Queue player data request
  function queuePlayerDataRequest(playerId) {
    return new Promise((resolve, reject) => {
      // Check cache first
      const cachedData = playerCache.get(playerId);
      if (cachedData && (Date.now() - cachedData.timestamp < CONFIG.cacheTime)) {
        resolve(cachedData.data);
        return;
      }

      // Add to queue
      requestQueue.push({ playerId, resolve, reject });

      // Start processing queue if not already processing
      if (!processingQueue) {
        processRequestQueue();
      }
    });
  }

  // Fetch player data from API
  async function fetchPlayerData(playerId) {
    if (!validateApiKey()) return null;

    try {
      const response = await fetch(`https://api.torn.com/user/${playerId}?selections=profile,basic&key=${apiKey}`);
      const data = await response.json();

      if (data.error) {
        console.error(`[Torn Player List] API Error: ${data.error.error}`);
        return null;
      }

      // Cache the result
      playerCache.set(playerId, {
        data: data,
        timestamp: Date.now()
      });

      return data;
    } catch (error) {
      console.error("[Torn Player List] Error fetching player data:", error);
      return null;
    }
  }

  // Update player row with status information
  async function updatePlayerRow(playerRow) {
    try {
      // Skip if already processed and not due for refresh
      if (playerRow.classList.contains('playerlist_processed')) {
        const lastUpdate = parseInt(playerRow.getAttribute('data-last-update') || '0');
        if (Date.now() - lastUpdate < CONFIG.refreshInterval / 2) return;
      }

      // Extract player ID from the row
      const playerLink = playerRow.querySelector('a[href^="/profiles.php"]');
      if (!playerLink) return;

      const playerId = playerLink.href.split('XID=')[1];
      if (!playerId) return;

      // Add to known player IDs
      knownPlayerIds.add(playerId);

      // Get player data
      const playerData = await queuePlayerDataRequest(playerId);
      if (!playerData) return;

      // Mark as processed
      playerRow.classList.add('playerlist_processed');
      playerRow.setAttribute('data-last-update', Date.now().toString());

      // Update status cell
      const statusCell = playerRow.querySelector('.status___o6u8R');
      if (!statusCell) return;

      const statusSpan = statusCell.querySelector('span');
      if (!statusSpan) return;

      // Set data attributes for sorting
      playerRow.setAttribute('data-player-id', playerId);
      playerRow.setAttribute('data-last-action', playerData.last_action?.timestamp || '0');

      // Clear any existing timer
      if (hospitalTimers.has(playerId)) {
        clearInterval(hospitalTimers.get(playerId));
        hospitalTimers.delete(playerId);
      }

      // Process based on status
      const status = playerData.status || { state: 'Unknown' };
      playerRow.setAttribute('data-status', status.state);
      playerRow.setAttribute('data-until', status.until || '0');

      switch (status.state) {
        case 'Hospital':
          playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Hospital);

          // Create hospital timer
          const timerFunction = () => {
            const timeRemaining = Math.round(status.until - Date.now() / 1000);

            if (timeRemaining <= 0) {
              statusSpan.textContent = 'Okay';
              playerRow.classList.remove('playerlist_highlight');
              clearInterval(hospitalTimers.get(playerId));
              hospitalTimers.delete(playerId);
              return;
            }

            statusSpan.textContent = formatTimeRemaining(timeRemaining);
            statusSpan.classList.add('playerlist_hospital_timer');

            // Highlight if close to release
            if (timeRemaining < CONFIG.hospitalWarningThreshold) {
              playerRow.classList.add('playerlist_highlight');
            } else {
              playerRow.classList.remove('playerlist_highlight');
            }
          };

          // Run immediately and then set interval
          timerFunction();
          hospitalTimers.set(playerId, setInterval(timerFunction, 1000));
          break;

        case 'Jail':
          playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Jail);
          break;

        case 'Traveling':
          statusSpan.textContent = processTravelStatus(status.description);
          playerRow.classList.add('playerlist_traveling');

          if (status.description && status.description.includes('Returning')) {
            playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Returning);
          } else {
            playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Traveling);
          }
          break;

        case 'Abroad':
          statusSpan.textContent = processTravelStatus(status.description);
          playerRow.classList.add('playerlist_traveling');
          playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Abroad);
          break;

        case 'Offline':
          playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Offline);
          break;

        case 'Online':
          playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Online);
          break;

        default:
          playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Default);
          break;
      }
    } catch (error) {
      console.error("[Torn Player List] Error updating player row:", error);
    }
  }

  // Sort player list
  function sortPlayerList(playerList, sortBy = 'status') {
    if (!CONFIG.enableSorting || !playerList) return;

    try {
      const rows = Array.from(playerList.querySelectorAll('li.tableRow___UgA6S'));
      if (rows.length === 0) return;

      rows.sort((a, b) => {
        switch (sortBy) {
          case 'status':
            // Sort by status priority first
            const priorityDiff =
              (parseInt(a.getAttribute('data-sort-priority') || '10')) -
              (parseInt(b.getAttribute('data-sort-priority') || '10'));

            if (priorityDiff !== 0) return priorityDiff;

            // Then by time remaining (for hospital/jail)
            return parseInt(a.getAttribute('data-until') || '0') -
                   parseInt(b.getAttribute('data-until') || '0');

          case 'lastAction':
            return parseInt(b.getAttribute('data-last-action') || '0') -
                   parseInt(a.getAttribute('data-last-action') || '0');

          case 'level':
            const levelA = parseInt(a.querySelector('.level___z78dn')?.textContent || '0');
            const levelB = parseInt(b.querySelector('.level___z78dn')?.textContent || '0');
            return levelB - levelA;

          default:
            return 0;
        }
      });

      // Reappend in sorted order
      rows.forEach(row => playerList.appendChild(row));
    } catch (error) {
      console.error("[Torn Player List] Error sorting player list:", error);
    }
  }

  // Add sort controls
  function addSortControls(tableWrapper) {
    // Check if controls already exist
    if (tableWrapper.querySelector('.playerlist_sort_controls')) return;

    try {
      const controlsDiv = document.createElement('div');
      controlsDiv.className = 'playerlist_sort_controls';

      const sortOptions = [
        { id: 'status', label: 'Sort by Status' },
        { id: 'lastAction', label: 'Sort by Last Action' },
        { id: 'level', label: 'Sort by Level' }
      ];

      sortOptions.forEach(option => {
        const button = document.createElement('button');
        button.className = 'playerlist_sort_button torn-btn';
        button.textContent = option.label;
        button.dataset.sortBy = option.id;

        if (option.id === 'status') {
          button.classList.add('active');
        }

          button.addEventListener('click', () => {
          // Update active button
          controlsDiv.querySelectorAll('.playerlist_sort_button').forEach(btn => {
            btn.classList.remove('active');
          });
          button.classList.add('active');

          // Sort the list
          const playerList = tableWrapper.querySelector('ul');
          if (playerList) {
            sortPlayerList(playerList, option.id);
          }
        });

        controlsDiv.appendChild(button);
      });

      // Insert at the beginning of the table wrapper
      if (tableWrapper.firstChild) {
        tableWrapper.insertBefore(controlsDiv, tableWrapper.firstChild);
      } else {
        tableWrapper.appendChild(controlsDiv);
      }
    } catch (error) {
      console.error("[Torn Player List] Error adding sort controls:", error);
    }
  }

  // Find new player rows in a list
  function findNewPlayerRows(playerList) {
    if (!playerList) return [];

    try {
      const allRows = playerList.querySelectorAll('li.tableRow___UgA6S');
      const newRows = [];

      for (const row of allRows) {
        // Skip already processed rows
        if (row.classList.contains('playerlist_processed')) continue;

        // Check if this is a new player
        const playerLink = row.querySelector('a[href^="/profiles.php"]');
        if (!playerLink) continue;

        const playerId = playerLink.href.split('XID=')[1];
        if (!playerId) continue;

        newRows.push(row);
      }

      return newRows;
    } catch (error) {
      console.error("[Torn Player List] Error finding new player rows:", error);
      return [];
    }
  }

  // Process player list with throttling
  async function processPlayerList(tableWrapper) {
    if (!tableWrapper) return;

    try {
      // Add sort controls
      addSortControls(tableWrapper);

      // Get player list
      const playerList = tableWrapper.querySelector('ul');
      if (!playerList) return;

      // Find new player rows
      const newPlayerRows = findNewPlayerRows(playerList);
      if (newPlayerRows.length === 0) return;

      console.log(`[Torn Player List] Processing ${newPlayerRows.length} new player rows`);

      // Process rows in batches to avoid freezing the page
      const batchSize = 5;
      for (let i = 0; i < newPlayerRows.length; i += batchSize) {
        const batch = newPlayerRows.slice(i, i + batchSize);

        // Process batch
        await Promise.all(batch.map(row => updatePlayerRow(row)));

        // Small delay between batches
        if (i + batchSize < newPlayerRows.length) {
          await new Promise(resolve => setTimeout(resolve, 300));
        }
      }

      // Sort the list
      sortPlayerList(playerList, 'status');

    } catch (error) {
      console.error("[Torn Player List] Error processing player list:", error);
    }
  }

  // Check for removed players and clean up their timers
  function cleanupRemovedPlayers(tableWrapper) {
    if (!tableWrapper) return;

    try {
      const playerList = tableWrapper.querySelector('ul');
      if (!playerList) return;

      const currentPlayerIds = new Set();

      // Get all current player IDs
      const playerRows = playerList.querySelectorAll('li.tableRow___UgA6S');
      for (const row of playerRows) {
        const playerLink = row.querySelector('a[href^="/profiles.php"]');
        if (!playerLink) continue;

        const playerId = playerLink.href.split('XID=')[1];
        if (playerId) {
          currentPlayerIds.add(playerId);
        }
      }

      // Clean up timers for removed players
      hospitalTimers.forEach((timer, playerId) => {
        if (!currentPlayerIds.has(playerId)) {
          clearInterval(timer);
          hospitalTimers.delete(playerId);
        }
      });
    } catch (error) {
      console.error("[Torn Player List] Error cleaning up removed players:", error);
    }
  }

  // Clean up resources
  function cleanUp() {
    // Clear all hospital timers
    hospitalTimers.forEach((timer) => {
      clearInterval(timer);
    });
    hospitalTimers.clear();

    // Clear refresh interval
    if (refreshInterval) {
      clearInterval(refreshInterval);
      refreshInterval = null;
    }

    // Clear observer
    if (observer) {
      observer.disconnect();
    }
  }

  // Watch for specific changes to player list
  function watchForPlayerListChanges(mutations) {
    for (const mutation of mutations) {
      // Check if rows were added to a player list
      if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
        const playerList = mutation.target.closest('ul');
        const tableWrapper = mutation.target.closest('.tableWrapper___Imc7p');

        if (playerList && tableWrapper) {
          // Check if any of the added nodes are player rows
          let hasNewPlayerRows = false;

          for (const node of mutation.addedNodes) {
            if (node.nodeType === Node.ELEMENT_NODE &&
                node.classList?.contains('tableRow___UgA6S')) {
              hasNewPlayerRows = true;
              break;
            }
          }

          if (hasNewPlayerRows) {
            console.log("[Torn Player List] New player rows detected");
            processPlayerList(tableWrapper);
          }
        }
      }
    }
  }

  // Set up mutation observer with debouncing
  let debounceTimer = null;
  const observer = new MutationObserver(mutations => {
    if (!observerActive) return;

    // Debounce to prevent excessive processing
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      // First, check specifically for new player rows
      watchForPlayerListChanges(mutations);

      // Then do general processing for any new table wrappers
      let tableWrapperFound = false;

      for (const mutation of mutations) {
        // Check for added nodes that might be table wrappers
        for (const node of mutation.addedNodes) {
          if (node.nodeType === Node.ELEMENT_NODE) {
            // Check if this is a player list table
            const tableWrapper = node.classList?.contains('tableWrapper___Imc7p')
              ? node
              : node.querySelector('.tableWrapper___Imc7p');

            if (tableWrapper) {
              tableWrapperFound = true;
              processPlayerList(tableWrapper);
            }
          }
        }
      }

      // Clean up any removed players
      const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p');
      tableWrappers.forEach(wrapper => {
        cleanupRemovedPlayers(wrapper);
      });

    }, 300); // 300ms debounce
  });

  // Start observing with error handling
  try {
    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  } catch (error) {
    console.error("[Torn Player List] Error starting observer:", error);
    observerActive = false;
  }

  // Set up periodic refresh with error handling
  refreshInterval = setInterval(() => {
    try {
      const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p');
      if (tableWrappers.length === 0) return;

      // Process all wrappers to check for new players
      tableWrappers.forEach(wrapper => {
        processPlayerList(wrapper);
      });
    } catch (error) {
      console.error("[Torn Player List] Error in refresh interval:", error);
    }
  }, CONFIG.refreshInterval);

  // Initial run with delay
  setTimeout(() => {
    try {
      const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p');
      tableWrappers.forEach(wrapper => {
        processPlayerList(wrapper);
      });
    } catch (error) {
      console.error("[Torn Player List] Error in initial run:", error);
    }
  }, 2000); // 2 second delay to ensure the page has loaded

  // Clean up on page unload
  window.addEventListener('beforeunload', cleanUp);
})();