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