Torn Player List Enhanced

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

Mint 2025.03.11.. Lásd a legutóbbi verzió

// ==UserScript==
// @name         Torn Player 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);
})();