Torn Revive Status Indicator

Shows revive status icons for faction members in Torn

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Torn Revive Status Indicator
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Shows revive status icons for faction members in Torn
// @author       You
// @match        https://www.torn.com/factions.php*
// @match        https://www.torn.com/profiles.php?XID=*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      api.torn.com
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // Configuration - MINIMAL API CALLS
    const CACHE_DURATION = 2 * 60 * 1000; // 2 minutes cache
    const FACTION_ID = null; // Set to specific faction ID, or null to auto-detect
    const MIN_REFRESH_INTERVAL = 1 * 60 * 1000; // Minimum 2 minutes between API calls
    const PAGE_CHANGE_DELAY = 3000; // 3 seconds delay after page navigation
    const TOAST_DURATION = 2000; // 1.5 seconds for toast (shorter)

    // Get API key from storage or prompt
    let API_KEY = GM_getValue("torn_revive_api_key", null);

    // Track last API call time per faction
    let lastApiCallTime = GM_getValue("last_api_call_time", {}) || {};

    // Register menu command to set API key
    GM_registerMenuCommand("Set Torn API Key", () => {
        let userInput = prompt(
            "Enter Public API Key:",
            API_KEY || ""
        );
        if (userInput !== null) {
            API_KEY = userInput;
            GM_setValue("torn_revive_api_key", API_KEY);
            showToast("API key saved", "success", true);
        }
    });

    // Cache storage
    let reviveDataCache = GM_getValue("revive_data_cache", {
        data: {},
        timestamp: 0
    });

    // SVG icons for revive status
    const reviveIcons = {
        revivable: `
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" style="fill: #4CAF50;">
                <circle cx="8" cy="8" r="7" stroke="#2E7D32" stroke-width="1"/>
                <path d="M5 8L7 10L11 6" stroke="#2E7D32" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
            </svg>
        `,
        notRevivable: `
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" style="fill: #F44336;">
                <circle cx="8" cy="8" r="7" stroke="#C62828" stroke-width="1"/>
                <path d="M5 5L11 11M5 11L11 5" stroke="#C62828" stroke-width="2" stroke-linecap="round"/>
            </svg>
        `,
        unknown: `
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" style="fill: #9E9E9E;">
                <circle cx="8" cy="8" r="7" stroke="#757575" stroke-width="1"/>
                <text x="8" y="12" text-anchor="middle" font-family="Arial" font-size="10" font-weight="bold" fill="#757575">?</text>
            </svg>
        `
    };

    // Toast notification system - only for important messages
    function showToast(message, type = "info", important = false) {
        // Don't show toast for non-important messages
        if (!important) {
            console.log(`[Revive Status] ${message}`);
            return;
        }

        const colors = {
            success: "#4CAF50",
            error: "#F44336",
            info: "#2196F3",
            warning: "#FF9800"
        };

        // Remove existing toasts
        document.querySelectorAll('.revive-toast').forEach(toast => toast.remove());

        const toast = document.createElement("div");
        toast.className = "revive-toast";
        toast.textContent = message;
        toast.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            background: ${colors[type] || colors.info};
            color: white;
            padding: 10px 16px;
            border-radius: 4px;
            z-index: 10000;
            font-family: Arial, sans-serif;
            font-size: 13px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.2);
            animation: reviveToastSlideIn 0.3s ease-out;
        `;

        document.body.appendChild(toast);

        // Auto-remove after short duration
        setTimeout(() => {
            if (toast.parentNode) {
                toast.style.animation = "reviveToastFadeOut 0.3s ease-out";
                setTimeout(() => toast.remove(), 300);
            }
        }, TOAST_DURATION);
    }

    // Check if cache is valid
    function isCacheValid() {
        return Date.now() - reviveDataCache.timestamp < CACHE_DURATION;
    }

    // Check if we should make API call (strict rate limiting)
    function shouldMakeApiCall(factionId) {
        const now = Date.now();
        const lastCall = lastApiCallTime[factionId] || 0;

        // Don't make API call if we made one very recently
        if (now - lastCall < MIN_REFRESH_INTERVAL) {
            console.log(`[Revive Status] Skipping API call - too soon (${Math.round((now - lastCall)/1000)}s ago)`);
            return false;
        }

        // If we have valid cache, don't make API call
        if (isCacheValid() && reviveDataCache.data[factionId]) {
            const cacheAge = Math.round((now - reviveDataCache.timestamp) / 60000);
            console.log(`[Revive Status] Using cache (${cacheAge} min old)`);
            return false;
        }

        return true;
    }

    // Fetch faction data from Torn API with strict rate limiting
    async function fetchFactionData(factionId) {
        if (!API_KEY) {
            showToast("Set API key in Tampermonkey menu", "error", true);
            return null;
        }

        // Check if we should make API call
        if (!shouldMakeApiCall(factionId)) {
            // Return cached data if available
            return reviveDataCache.data[factionId] || null;
        }

        try {
            const url = `https://api.torn.com/v2/faction/${factionId}/members?striptags=true&key=${API_KEY}`;

            console.log(`[Revive Status] Making API call for faction ${factionId}`);

            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    onload: resolve,
                    onerror: reject,
                    timeout: 10000 // 10 second timeout
                });
            });

            // Update last call time and save
            lastApiCallTime[factionId] = Date.now();
            GM_setValue("last_api_call_time", lastApiCallTime);

            if (response.status === 200) {
                const data = JSON.parse(response.responseText);

                // Check for API errors
                if (data.error) {
                    console.error(`[Revive Status] API Error: ${data.error.error}`);
                    // Don't show toast for API errors unless it's important
                    return null;
                }

                // Create a map of member IDs to revive status
                const reviveMap = {};
                if (data.members && Array.isArray(data.members)) {
                    data.members.forEach(member => {
                        reviveMap[member.id] = {
                            is_revivable: member.is_revivable,
                            name: member.name,
                            position: member.position
                        };
                    });
                }

                // Update cache and save
                reviveDataCache.data[factionId] = reviveMap;
                reviveDataCache.timestamp = Date.now();
                GM_setValue("revive_data_cache", reviveDataCache);

                console.log(`[Revive Status] Updated cache for ${Object.keys(reviveMap).length} members`);
                return reviveMap;
            } else {
                console.error(`[Revive Status] API request failed: ${response.status}`);
                return null;
            }
        } catch (error) {
            console.error("[Revive Status] Error fetching faction data:", error);
            return null;
        }
    }

    // Get faction ID from current page
    function getFactionId() {
        // If FACTION_ID is set in config, use it
        if (FACTION_ID) return FACTION_ID;

        // Try to extract from URL
        const urlParams = new URLSearchParams(window.location.search);
        const step = urlParams.get("step");

        if (step === "profile") {
            return urlParams.get("ID");
        }

        // Try to find faction ID in the page
        const factionLink = document.querySelector('a[href*="factions.php?step=profile&ID="]');
        if (factionLink) {
            const match = factionLink.href.match(/ID=(\d+)/);
            if (match) return match[1];
        }

        return null;
    }

    // Add revive icon to member row (silently)
    function addReviveIcon(row, memberId, reviveStatus) {
        // Remove existing revive icon if present
        const existingIcon = row.querySelector('.revive-status-icon');
        if (existingIcon) existingIcon.remove();

        // Create icon container
        const iconContainer = document.createElement('div');
        iconContainer.className = 'revive-status-icon';
        iconContainer.style.cssText = `
            display: inline-flex;
            align-items: center;
            margin-left: 8px;
            cursor: help;
        `;

        // Set icon based on revive status
        let icon, tooltip;
        if (reviveStatus === true) {
            icon = reviveIcons.revivable;
            tooltip = "Revivable";
        } else if (reviveStatus === false) {
            icon = reviveIcons.notRevivable;
            tooltip = "Not Revivable";
        } else {
            icon = reviveIcons.unknown;
            tooltip = "Status unknown";
        }

        iconContainer.innerHTML = icon;
        iconContainer.title = tooltip;

        // Add to the row - try different positions
        const statusCell = row.querySelector('.status');
        const daysCell = row.querySelector('.days');
        const positionCell = row.querySelector('.position');

        if (statusCell) {
            statusCell.appendChild(iconContainer);
        } else if (daysCell) {
            daysCell.appendChild(iconContainer);
        } else if (positionCell) {
            positionCell.appendChild(iconContainer);
        } else {
            // Add to the end of the row
            row.appendChild(iconContainer);
        }

        // Add data attribute for filtering
        row.setAttribute('data-revivable', reviveStatus);
    }

    // Wait for and integrate into the Member Filter section
    function waitForMemberFilter() {
        return new Promise((resolve) => {
            // Check if already exists
            const existingFilter = document.querySelector('#memberFilter .revive__section-class');
            if (existingFilter) {
                resolve(existingFilter);
                return;
            }

            // Set up observer to wait for member filter
            const observer = new MutationObserver((mutations, obs) => {
                const memberFilter = document.querySelector('#memberFilter');
                if (memberFilter) {
                    obs.disconnect();
                    resolve(memberFilter);
                }
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });

            // Timeout after 10 seconds
            setTimeout(() => {
                observer.disconnect();
                resolve(null);
            }, 10000);
        });
    }

    // Add revive filter section to Member Filter
    async function addReviveFilterToMemberFilter() {
        const memberFilter = await waitForMemberFilter();
        if (!memberFilter) {
            console.log("[Revive Status] Member Filter not found, using fallback");
            addFallbackFilter();
            return;
        }

        // Check if revive section already exists
        if (memberFilter.querySelector('.revive__section-class')) {
            console.log("[Revive Status] Revive filter already exists");
            return;
        }

        // Find the content area
        const content = memberFilter.querySelector('.content');
        if (!content) {
            console.log("[Revive Status] Could not find content area in Member Filter");
            addFallbackFilter();
            return;
        }

        // Create revive filter section
        const reviveSection = document.createElement('div');
        reviveSection.className = 'revive__section-class';
        reviveSection.innerHTML = `
            <strong>Revive Status</strong>
            <div class="tt-checkbox-list-wrapper tt-checkbox-list-column">
                <div class="tt-checkbox-wrapper">
                    <label>
                        <input type="checkbox" id="revivable-only" class="revive-filter-checkbox">
                        Hide non-revivable
                    </label>
                </div>
            </div>
            <div class="revive-refresh-wrapper" style="margin-top: 8px;">
                <button class="revive-refresh-btn torn-btn btn-small">
                    <i class="fas fa-sync-alt" style="margin-right: 4px;"></i> Refresh Revive Status
                </button>
                <div class="revive-cache-info" style="font-size: 11px; color: #666; margin-top: 4px;"></div>
            </div>
        `;

        // Insert before the last section or at the end
        const lastSection = content.querySelector('.levelFilter__section-class') ||
                           content.querySelector('.status__section-class') ||
                           content.lastElementChild;

        if (lastSection) {
            content.insertBefore(reviveSection, lastSection.nextSibling);
        } else {
            content.appendChild(reviveSection);
        }

        // Get checkbox and add event listener
        const checkbox = reviveSection.querySelector('.revive-filter-checkbox');
        if (checkbox) {
            checkbox.checked = GM_getValue("hide_non_revivable", false);

            checkbox.addEventListener('change', function() {
                const hideNonRevivable = this.checked;
                GM_setValue("hide_non_revivable", hideNonRevivable);
                filterNonRevivableMembers(hideNonRevivable);

                // Show brief toast
                showToast(hideNonRevivable ? "Hiding non-revivable members" : "Showing all members", "info", true);
            });
        }

        // Get refresh button and add event listener
        const refreshBtn = reviveSection.querySelector('.revive-refresh-btn');
        if (refreshBtn) {
            refreshBtn.addEventListener('click', async () => {
                await handleRefreshClick();
            });
        }

        // Update cache info
        updateCacheInfo(reviveSection);

        console.log("[Revive Status] Revive filter integrated into Member Filter");

        // Apply saved filter preference
        const hideNonRevivable = GM_getValue("hide_non_revivable", false);
        if (hideNonRevivable) {
            filterNonRevivableMembers(true);
        }
    }

    // Fallback if Member Filter not found
    function addFallbackFilter() {
        // Remove any existing fallback
        document.querySelectorAll('.revive-filter-fallback').forEach(el => el.remove());

        // Create fallback container
        const fallback = document.createElement('div');
        fallback.className = 'revive-filter-fallback tt-container rounding compact mt10';
        fallback.style.cssText = `
            margin-top: 10px;
            padding: 10px;
            background: #f5f5f5;
            border: 1px solid #ddd;
        `;

        fallback.innerHTML = `
            <strong style="display: block; margin-bottom: 8px;">Revive Status Filter</strong>
            <div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
                <label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
                    <input type="checkbox" id="revivable-only-fallback" class="revive-filter-checkbox">
                    Hide non-revivable
                </label>
                <button class="revive-refresh-btn torn-btn btn-small" style="margin-left: auto;">
                    <i class="fas fa-sync-alt" style="margin-right: 4px;"></i> Refresh
                </button>
            </div>
        `;

        // Insert after the member list or at appropriate location
        const membersList = document.querySelector('.members-list, .f-war-list');
        if (membersList) {
            membersList.parentNode.insertBefore(fallback, membersList.nextSibling);
        } else {
            document.body.appendChild(fallback);
        }

        // Get checkbox and add event listener
        const checkbox = fallback.querySelector('.revive-filter-checkbox');
        if (checkbox) {
            checkbox.checked = GM_getValue("hide_non_revivable", false);

            checkbox.addEventListener('change', function() {
                const hideNonRevivable = this.checked;
                GM_setValue("hide_non_revivable", hideNonRevivable);
                filterNonRevivableMembers(hideNonRevivable);

                showToast(hideNonRevivable ? "Hiding non-revivable members" : "Showing all members", "info", true);
            });
        }

        // Get refresh button and add event listener
        const refreshBtn = fallback.querySelector('.revive-refresh-btn');
        if (refreshBtn) {
            refreshBtn.addEventListener('click', async () => {
                await handleRefreshClick();
            });
        }

        console.log("[Revive Status] Added fallback revive filter");
    }

    // Update cache information display
    function updateCacheInfo(container) {
        const cacheInfo = container.querySelector('.revive-cache-info');
        if (!cacheInfo) return;

        const factionId = getFactionId();
        if (!factionId || !reviveDataCache.data[factionId]) {
            cacheInfo.textContent = "No cache data";
            return;
        }

        const now = Date.now();
        const cacheAge = Math.round((now - reviveDataCache.timestamp) / 1000);
        const memberCount = Object.keys(reviveDataCache.data[factionId]).length;

        if (cacheAge < 60) {
            cacheInfo.textContent = `Cached ${cacheAge}s ago (${memberCount} members)`;
        } else if (cacheAge < 3600) {
            const minutes = Math.floor(cacheAge / 60);
            cacheInfo.textContent = `Cached ${minutes}m ago (${memberCount} members)`;
        } else {
            const hours = Math.floor(cacheAge / 3600);
            cacheInfo.textContent = `Cached ${hours}h ago (${memberCount} members)`;
        }
    }

    // Handle refresh button click
    async function handleRefreshClick() {
        const factionId = getFactionId();
        const now = Date.now();
        const lastCall = lastApiCallTime[factionId] || 0;

        if (now - lastCall < MIN_REFRESH_INTERVAL) {
            const secondsLeft = Math.ceil((MIN_REFRESH_INTERVAL - (now - lastCall)) / 1000);
            showToast(`Please wait ${secondsLeft} seconds before refreshing again`, "warning", true);
            return;
        }

        // Clear only this faction's cache
        if (reviveDataCache.data[factionId]) {
            delete reviveDataCache.data[factionId];
            GM_setValue("revive_data_cache", reviveDataCache);
        }

        await processMemberRows();

        // Update cache info in all containers
        document.querySelectorAll('.revive__section-class, .revive-filter-fallback').forEach(container => {
            updateCacheInfo(container);
        });
    }

    // Filter non-revivable members
    function filterNonRevivableMembers(hide) {
        const memberRows = document.querySelectorAll('.table-row, .members-list li, .faction-member-row');
        let hiddenCount = 0;

        memberRows.forEach(row => {
            const isRevivable = row.getAttribute('data-revivable');

            if (hide && isRevivable === 'false') {
                // Hide non-revivable members
                if (row.style.display !== 'none') {
                    row.style.display = 'none';
                    hiddenCount++;
                }
            } else {
                // Show all members
                row.style.display = '';
            }
        });

        // Update statistics if they exist
        updateStatistics(hiddenCount, hide);

        if (hide && hiddenCount > 0) {
            console.log(`[Revive Status] Hid ${hiddenCount} non-revivable members`);
        }
    }

    // Update statistics display
    function updateStatistics(hiddenCount, isFiltering) {
        const statCount = document.querySelector('.stat-count');
        const statTotal = document.querySelector('.stat-total');

        if (statCount && statTotal) {
            const total = parseInt(statTotal.textContent) || 0;
            if (isFiltering && hiddenCount > 0) {
                const showing = total - hiddenCount;
                statCount.textContent = showing;
            } else {
                statCount.textContent = total;
            }
        }
    }

    // Process all member rows (silent operation)
    async function processMemberRows() {
        const factionId = getFactionId();
        if (!factionId) {
            console.log("[Revive Status] No faction ID found");
            return false;
        }

        // Fetch revive data
        const reviveData = await fetchFactionData(factionId);
        if (!reviveData) return false;

        // Find all member rows
        const memberRows = document.querySelectorAll('.table-row, .members-list li, .faction-member-row');
        let processedCount = 0;

        memberRows.forEach(row => {
            // Try to find member ID in the row
            let memberId = null;

            // Check for profile links
            const profileLink = row.querySelector('a[href*="/profiles.php?XID="], a[href*="profiles.php?XID="]');
            if (profileLink) {
                const match = profileLink.href.match(/XID=(\d+)/);
                if (match) memberId = match[1];
            }

            // Check for user ID in data attributes
            if (!memberId) {
                const userIdAttr = row.getAttribute('data-user-id') || row.getAttribute('data-player-id');
                if (userIdAttr) memberId = userIdAttr;
            }

            if (memberId && reviveData[memberId] !== undefined) {
                addReviveIcon(row, memberId, reviveData[memberId].is_revivable);
                processedCount++;
            }
        });

        // Add revive filter to Member Filter section
        await addReviveFilterToMemberFilter();

        return processedCount > 0;
    }

    // Main initialization
    function init() {
        // Check if we're on a faction members page
        if (!window.location.href.includes('factions.php')) {
            return;
        }

        // Add custom styles
        const styles = `
            .revive-status-icon {
                display: inline-flex;
                align-items: center;
                margin-left: 8px;
                vertical-align: middle;
            }
            .revive-status-icon svg {
                filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2));
            }
            .table-row:hover .revive-status-icon svg {
                transform: scale(1.1);
                transition: transform 0.2s;
            }
            .revive-filter-checkbox {
                margin: 0;
                cursor: pointer;
            }
            .revive-filter-checkbox:checked {
                accent-color: #4CAF50;
            }
            .revive-refresh-btn {
                background: #2196F3;
                color: white;
                border: none;
                padding: 6px 12px;
                border-radius: 4px;
                cursor: pointer;
                font-family: Arial, sans-serif;
                font-size: 12px;
                transition: background 0.2s;
            }
            .revive-refresh-btn:hover {
                background: #1976D2;
            }
            .revive-refresh-btn:disabled {
                background: #BDBDBD;
                cursor: not-allowed;
            }
            @keyframes reviveToastSlideIn {
                from { transform: translateX(100%); opacity: 0; }
                to { transform: translateX(0); opacity: 1; }
            }
            @keyframes reviveToastFadeOut {
                from { opacity: 1; }
                to { opacity: 0; }
            }
        `;

        const styleSheet = document.createElement("style");
        styleSheet.textContent = styles;
        document.head.appendChild(styleSheet);

        // Initial processing with delay
        setTimeout(async () => {
            const processed = await processMemberRows();
            if (processed) {
                console.log("[Revive Status] Icons added successfully");
            }
        }, PAGE_CHANGE_DELAY);

        // Observer for dynamic content
        let observerTimer;
        const observer = new MutationObserver((mutations) => {
            let shouldProcess = false;

            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    // Check if new member rows were added
                    const hasNewRows = Array.from(mutation.addedNodes).some(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            return node.matches('.table-row, .members-list li, .faction-member-row') ||
                                   node.querySelector('.table-row, .members-list li, .faction-member-row');
                        }
                        return false;
                    });

                    if (hasNewRows) {
                        shouldProcess = true;
                        break;
                    }
                }

                // Check if member filter was added/modified
                if (mutation.type === 'childList') {
                    const hasFilterChange = Array.from(mutation.addedNodes).some(node => {
                        if (node.nodeType === Node.ELEMENT_NODE) {
                            return node.matches('#memberFilter') ||
                                   node.querySelector('#memberFilter');
                        }
                        return false;
                    });

                    if (hasFilterChange) {
                        shouldProcess = true;
                        break;
                    }
                }
            }

            if (shouldProcess) {
                clearTimeout(observerTimer);
                observerTimer = setTimeout(async () => {
                    await processMemberRows();
                }, 2000);
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    // Wait for page to load
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();