Torn Target List Enhanced

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

// ==UserScript==
// @name         Torn Target List Enhanced
// @namespace    xentac
// @version      1.1.0
// @description  Enhances Torn's Target list with hospital timers, travel status details, and sorting options
// @author       xentac (optimized version)
// @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: 15000, // Refresh player list every 15 seconds
    statusCheckInterval: 20000, // Check player statuses every 20 seconds
    hospitalWarningThreshold: 60, // 1 minute
    enableSorting: true,
    maxConcurrentRequests: 5, // Limit concurrent API requests
    requestDelay: 500, // Delay between API requests in ms
    cacheTime: 30000, // Cache player data for 30 seconds
    batchSize: 10, // Number of players to check in each batch
    playerCheckCycle: 120000, // Full cycle time to check all players (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;
    }
    .playerlist_status_changed {
      animation: flash-status 1s;
    }
    @keyframes flash-status {
      0%, 100% { background-color: transparent; }
      50% { background-color: rgba(255, 255, 0, 0.3); }
    }
  `);

  // State
  let apiKey = localStorage.getItem(CONFIG.storageKey) ?? CONFIG.defaultKey;
  const hospitalTimers = new Map();
  const playerCache = new Map();
  const requestQueue = [];
  let processingQueue = false;
  let refreshInterval = null;
  let statusCheckInterval = null;
  let playerStatusMap = new Map(); // Track player statuses for change detection
  let pendingUpdates = new Set(); // Track player IDs with pending updates

  // Track player check status
  const playerCheckTracker = {
    lastFullCycleTime: 0,
    playerLastChecked: new Map(),
    currentIndex: 0,

    // Mark a player as checked
    markChecked(playerId) {
      this.playerLastChecked.set(playerId, Date.now());
    },

    // Get time since last check
    getTimeSinceLastCheck(playerId) {
      const lastChecked = this.playerLastChecked.get(playerId) || 0;
      return Date.now() - lastChecked;
    },

    // Reset the cycle if needed
    resetCycleIfNeeded() {
      const now = Date.now();
      if (now - this.lastFullCycleTime > CONFIG.playerCheckCycle) {
        this.lastFullCycleTime = now;
        this.currentIndex = 0;
      }
    }
  };

  // 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 in parallel with Promise.all
      await Promise.all(batch.map(async (request, index) => {
        // Add delay between requests
        if (index > 0) {
          await new Promise(resolve => setTimeout(resolve, CONFIG.requestDelay));
        }

        try {
          const data = await fetchPlayerData(request.playerId);
          request.resolve(data);
        } catch (error) {
          request.reject(error);
        }
      }));
    } 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, forceFresh = false) {
    return new Promise((resolve, reject) => {
      // Check cache first (unless forceFresh is true)
      if (!forceFresh) {
        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(`API Error: ${data.error.error}`);
        return null;
      }

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

      return data;
    } catch (error) {
      console.error("Error fetching player data:", error);
      return null;
    }
  }

  // Check if player status has changed
  function hasStatusChanged(playerId, newStatus) {
    if (!playerStatusMap.has(playerId)) return true;

    const oldStatus = playerStatusMap.get(playerId);

    // Check if state has changed
    if (oldStatus.state !== newStatus.state) return true;

    // If in hospital, check if time has changed significantly
    if (newStatus.state === 'Hospital' && oldStatus.state === 'Hospital') {
      // If hospital time changed by more than 5 seconds, consider it changed
      return Math.abs(oldStatus.until - newStatus.until) > 5;
    }

    return false;
  }

  // Update player status in our tracking map
  function updatePlayerStatus(playerId, status) {
    playerStatusMap.set(playerId, {
      state: status.state,
      until: status.until,
      description: status.description
    });
  }

  // Update player row with status information
  async function updatePlayerRow(playerRow, forceFresh = false) {
    try {
      // 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;

      // Skip if already being updated
      if (pendingUpdates.has(playerId)) return;
      pendingUpdates.add(playerId);

      try {
        // Skip if already processed recently and not forcing fresh data
        if (!forceFresh) {
          const lastUpdate = parseInt(playerRow.getAttribute('data-last-update') || '0');
          const timeSinceUpdate = Date.now() - lastUpdate;

          // If updated in the last 15 seconds and not in hospital, skip
          if (timeSinceUpdate < 15000 &&
              playerRow.getAttribute('data-status') !== 'Hospital') {
            return;
          }

          // If updated in the last 5 seconds even if in hospital, skip
          if (timeSinceUpdate < 5000) {
            return;
          }
        }

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

        // Check if status has changed
        const statusChanged = hasStatusChanged(playerId, playerData.status);

        // Update our status tracking
        updatePlayerStatus(playerId, playerData.status);

        // 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');

        // If status changed, add flash effect
        if (statusChanged && playerRow.classList.contains('playerlist_processed')) {
          playerRow.classList.remove('playerlist_status_changed');
          // Force reflow
          void playerRow.offsetWidth;
          playerRow.classList.add('playerlist_status_changed');
        }

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

            // Update the status span to show "Hospital" if it doesn't already
            if (!statusSpan.classList.contains('user-red-status')) {
              statusSpan.className = '';
              statusSpan.classList.add('user-red-status');
            }

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

              if (timeRemaining <= 0) {
                // Don't automatically change to Okay - we'll let the status check handle this
                statusSpan.textContent = "Checking...";
                playerRow.classList.remove('playerlist_highlight');
                clearInterval(hospitalTimers.get(playerId));
                hospitalTimers.delete(playerId);

                // Force a fresh check after hospital timer expires
                setTimeout(() => {
                  const row = document.querySelector(`li[data-player-id="${playerId}"]`);
                  if (row) updatePlayerRow(row, true);
                }, 2000);

                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);
            playerRow.classList.remove('playerlist_traveling', 'playerlist_highlight');

            // Update the status span
            if (!statusSpan.classList.contains('user-red-status')) {
              statusSpan.className = '';
              statusSpan.classList.add('user-red-status');
              statusSpan.textContent = "Jail";
            }
            break;

          case 'Traveling':
            statusSpan.textContent = processTravelStatus(status.description);
            playerRow.classList.add('playerlist_traveling');
            playerRow.classList.remove('playerlist_highlight');
// Update the status span
            if (!statusSpan.classList.contains('user-blue-status')) {
              statusSpan.className = '';
              statusSpan.classList.add('user-blue-status');
            }
            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.classList.remove('playerlist_highlight');

            // Update the status span
            if (!statusSpan.classList.contains('user-blue-status')) {
              statusSpan.className = '';
              statusSpan.classList.add('user-blue-status');
            }

            playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Abroad);
            break;

          case 'Offline':
            playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Offline);
            playerRow.classList.remove('playerlist_traveling', 'playerlist_highlight');

            // Update the status span
            statusSpan.className = '';
            statusSpan.textContent = "Offline";
            break;

          case 'Online':
            playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Online);
            playerRow.classList.remove('playerlist_traveling', 'playerlist_highlight');

            // Update the status span
            statusSpan.className = '';
            statusSpan.textContent = "Online";
            break;

          default:
            playerRow.setAttribute('data-sort-priority', STATUS_PRIORITIES.Default);
            playerRow.classList.remove('playerlist_traveling', 'playerlist_highlight');

            // Update the status span
            statusSpan.className = '';
            statusSpan.textContent = status.state || "Unknown";
            break;
        }

        // If status changed and we're in a list, resort the list
        if (statusChanged) {
          const playerList = playerRow.closest('ul');
          if (playerList) {
            sortPlayerList(playerList, 'status');
          }
        }
      } finally {
        // Always remove from pending updates
        pendingUpdates.delete(playerId);
      }
    } catch (error) {
      console.error("Error updating player row:", error);
      pendingUpdates.delete(playerId);
    }
  }

  // Sort player list (optimized with memoization)
  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;

      // Create a map for memoizing sort values to avoid repeated DOM access
      const sortValueMap = new Map();

      const getSortValue = (row, type) => {
        const key = `${row.getAttribute('data-player-id')}-${type}`;
        if (sortValueMap.has(key)) return sortValueMap.get(key);

        let value;
        switch (type) {
          case 'priority':
            value = parseInt(row.getAttribute('data-sort-priority') || '10');
            break;
          case 'until':
            value = parseInt(row.getAttribute('data-until') || '0');
            break;
          case 'lastAction':
            value = parseInt(row.getAttribute('data-last-action') || '0');
            break;
          case 'level':
            value = parseInt(row.querySelector('.level___z78dn')?.textContent || '0');
            break;
        }

        sortValueMap.set(key, value);
        return value;
      };

      // Sort with optimized comparator
      rows.sort((a, b) => {
        switch (sortBy) {
          case 'status':
            // Sort by status priority first
            const priorityDiff = getSortValue(a, 'priority') - getSortValue(b, 'priority');
            if (priorityDiff !== 0) return priorityDiff;

            // Then by time remaining (for hospital/jail)
            return getSortValue(a, 'until') - getSortValue(b, 'until');

          case 'lastAction':
            return getSortValue(b, 'lastAction') - getSortValue(a, 'lastAction');

          case 'level':
            return getSortValue(b, 'level') - getSortValue(a, 'level');

          default:
            return 0;
        }
      });

      // Use DocumentFragment for efficient DOM manipulation
      const fragment = document.createDocumentFragment();
      rows.forEach(row => fragment.appendChild(row));
      playerList.appendChild(fragment);
    } catch (error) {
      console.error("Error sorting player list:", error);
    }
  }

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

    try {
      // Use a more efficient selector to get only unprocessed rows
      return Array.from(playerList.querySelectorAll('li.tableRow___UgA6S:not(.playerlist_processed)'));
    } catch (error) {
      console.error("Error finding new player rows:", error);
      return [];
    }
  }

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

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

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

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

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

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

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

    } catch (error) {
      console.error("Error processing player list:", error);
    }
  }

  // Check for status changes in visible players
  async function checkPlayerStatusChanges() {
    try {
      // Reset cycle if needed
      playerCheckTracker.resetCycleIfNeeded();

      // Get all visible player rows
      const allPlayerRows = document.querySelectorAll('li.tableRow___UgA6S.playerlist_processed');
      if (allPlayerRows.length === 0) return;

      const allRows = Array.from(allPlayerRows);

      // Determine which players to check in this batch
      let rowsToCheck = [];

      // 1. First priority: Hospital patients
      const hospitalRows = allRows.filter(row =>
        row.getAttribute('data-status') === 'Hospital'
      );

      // Add hospital patients to check list
      rowsToCheck = [...hospitalRows];

      // 2. Second priority: Players in the current cycle segment
      if (rowsToCheck.length < CONFIG.batchSize) {
        const remainingSlots = CONFIG.batchSize - rowsToCheck.length;

        // Calculate how many players to check per interval to cover all in one cycle
        const playersPerInterval = Math.max(1, Math.ceil(allRows.length /
          (CONFIG.playerCheckCycle / CONFIG.statusCheckInterval)));

        // Get non-hospital rows that haven't been checked yet
        const otherRows = allRows.filter(row =>
          !hospitalRows.includes(row)
        );

        if (otherRows.length > 0) {
          // Get the next batch of players in sequence
          for (let i = 0; i < remainingSlots && i < playersPerInterval; i++) {
            const index = (playerCheckTracker.currentIndex + i) % otherRows.length;
            if (!rowsToCheck.includes(otherRows[index])) {
              rowsToCheck.push(otherRows[index]);
            }
          }

          // Update the current index for next time
          playerCheckTracker.currentIndex =
            (playerCheckTracker.currentIndex + playersPerInterval) % otherRows.length;
        }
      }

      // Check each player in the batch with parallel processing
      await Promise.all(rowsToCheck.map(async (row, index) => {
        // Add small staggered delay to prevent API rate limiting
        if (index > 0) {
          await new Promise(resolve => setTimeout(resolve, 200 * index));
        }

        const playerId = row.getAttribute('data-player-id');
        if (!playerId) return;

        await updatePlayerRow(row, true); // Force fresh data
        playerCheckTracker.markChecked(playerId);
      }));

    } catch (error) {
      console.error("Error checking player status changes:", 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 status check interval
    if (statusCheckInterval) {
      clearInterval(statusCheckInterval);
      statusCheckInterval = null;
    }

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

  // Set up mutation observer with debouncing
  let debounceTimer = null;
  const observer = new MutationObserver(mutations => {
    // Debounce to prevent excessive processing
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      // Check for player list changes
      let tableWrapperFound = false;
      let playerRowsAdded = false;

      for (const mutation of mutations) {
        // Check for added nodes
        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
          // Check for player rows added to existing lists
          const playerList = mutation.target.closest('ul');
          if (playerList) {
            for (const node of mutation.addedNodes) {
              if (node.nodeType === Node.ELEMENT_NODE &&
                  node.classList?.contains('tableRow___UgA6S')) {
                playerRowsAdded = true;
                break;
              }
            }
          }

          // Check for new table wrappers
          for (const node of mutation.addedNodes) {
            if (node.nodeType === Node.ELEMENT_NODE) {
              const tableWrapper = node.classList?.contains('tableWrapper___Imc7p')
                ? node
                : node.querySelector('.tableWrapper___Imc7p');

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

      // Process player lists if rows were added
      if (playerRowsAdded) {
        const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p');
        tableWrappers.forEach(wrapper => {
          processPlayerList(wrapper);
        });
      }
    }, 200); // 200ms debounce
  });

  // Start observing with error handling
  try {
    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  } catch (error) {
    console.error("Error starting observer:", error);
  }

  // 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("Error in refresh interval:", error);
    }
  }, CONFIG.refreshInterval);

  // Set up status check interval
  statusCheckInterval = setInterval(() => {
    try {
      // Only run if there are player lists visible
      const tableWrappers = document.querySelectorAll('.tableWrapper___Imc7p');
      if (tableWrappers.length > 0) {
        checkPlayerStatusChanges();
      }
    } catch (error) {
      console.error("Error in status check interval:", error);
    }
  }, CONFIG.statusCheckInterval);

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

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址