Torn - Revive Intel Pro

Fast bulk revive availability scanner for Torn war pages

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         Torn - Revive Intel Pro
// @namespace    torn-revive-business
// @version      2.1.0
// @description  Fast bulk revive availability scanner for Torn war pages
// @author       TheBeastMan
// @license      MIT
// @match        https://www.torn.com/*
// @match        https://torn.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      api.torn.com
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // ============================================================
    // CONFIG
    // ============================================================

    const API = 'https://api.torn.com/v2';
    const VERSION = '2.1';

    let apiKey = GM_getValue('reviveCheckerApiKey', '');

    let scanning = false;
    let lastScanTime = null;
    let autoScanDone = false;

    /*
        All revive information from the factions is stored here.

        player ID -> true / false
    */
    const reviveCache = new Map();

    /*
        Faction ID -> member Map
    */
    const factionCache = new Map();


    // ============================================================
    // CSS
    // ============================================================

    GM_addStyle(`

        :root {
            --ri-bg1: rgba(24, 27, 33, 0.985);
            --ri-bg2: rgba(12, 14, 18, 0.985);

            --ri-border: rgba(255,255,255,0.09);

            --ri-text: #f3f5f8;
            --ri-muted: #808793;

            --ri-green: #4de58a;
            --ri-green-bg: rgba(77,229,138,0.11);

            --ri-red: #ff646e;
            --ri-red-bg: rgba(255,100,110,0.11);

            --ri-blue: #70aaff;
        }


        /* ========================================================
           PANEL
           ======================================================== */

        #ri-panel {
            position: fixed;

            right: 20px;
            top: 140px;

            width: 294px;

            z-index: 999999;

            color: var(--ri-text);

            background:
                linear-gradient(
                    145deg,
                    var(--ri-bg1),
                    var(--ri-bg2)
                );

            border:
                1px solid var(--ri-border);

            border-radius: 14px;

            box-shadow:
                0 20px 55px rgba(0,0,0,0.50),
                0 3px 12px rgba(0,0,0,0.30),
                inset 0 1px 0 rgba(255,255,255,0.04);

            backdrop-filter: blur(15px);
            -webkit-backdrop-filter: blur(15px);

            font-family:
                -apple-system,
                BlinkMacSystemFont,
                "Segoe UI",
                Roboto,
                Arial,
                sans-serif;

            user-select: none;
            -webkit-user-select: none;
        }


        /* ========================================================
           HEADER
           ======================================================== */

        #ri-header {
            height: 52px;

            display: flex;
            align-items: center;

            padding:
                0 10px 0 12px;

            border-bottom:
                1px solid var(--ri-border);

            cursor: grab;

            touch-action: none;
        }


        #ri-header.dragging {
            cursor: grabbing;
        }


        #ri-logo {
            width: 30px;
            height: 30px;

            position: relative;

            flex-shrink: 0;

            margin-right: 9px;

            display: flex;
            align-items: center;
            justify-content: center;

            border-radius: 9px;

            background:
                rgba(112,170,255,0.11);

            border:
                1px solid rgba(112,170,255,0.23);
        }


        #ri-logo::before,
        #ri-logo::after {
            content: "";

            position: absolute;

            border-radius: 2px;

            background: #82b4ff;

            box-shadow:
                0 0 7px rgba(112,170,255,0.35);
        }


        #ri-logo::before {
            width: 15px;
            height: 5px;
        }


        #ri-logo::after {
            width: 5px;
            height: 15px;
        }


        #ri-title-area {
            flex: 1;
            min-width: 0;
        }


        #ri-title {
            font-size: 12px;

            font-weight: 700;

            letter-spacing: 0.35px;
        }


        #ri-subtitle {
            margin-top: 1px;

            color: #7d8490;

            font-size: 8px;

            letter-spacing: 0.65px;

            text-transform: uppercase;
        }


        .ri-head-btn {
            width: 29px;
            height: 29px;

            display: flex;
            align-items: center;
            justify-content: center;

            margin-left: 4px;

            padding: 0;

            border: 1px solid transparent;
            border-radius: 8px;

            background: transparent;

            color: #939aa5;

            cursor: pointer;

            font-size: 15px;
        }


        .ri-head-btn:hover {
            color: white;

            background:
                rgba(255,255,255,0.055);

            border-color:
                rgba(255,255,255,0.06);
        }


        /* ========================================================
           BODY
           ======================================================== */

        #ri-body {
            padding: 11px;
        }


        /* ========================================================
           STATUS BAR
           ======================================================== */

        #ri-status {
            height: 37px;

            display: flex;
            align-items: center;

            padding: 0 10px;

            margin-bottom: 9px;

            border-radius: 9px;

            border:
                1px solid rgba(255,255,255,0.055);

            background:
                rgba(255,255,255,0.032);
        }


        #ri-dot {
            width: 7px;
            height: 7px;

            flex-shrink: 0;

            margin-right: 8px;

            border-radius: 50%;

            background: #707782;
        }


        #ri-dot.ready {
            background: var(--ri-green);

            box-shadow:
                0 0 8px rgba(77,229,138,0.5);
        }


        #ri-dot.scanning {
            background: var(--ri-blue);

            box-shadow:
                0 0 9px rgba(112,170,255,0.55);

            animation:
                riPulse 1s infinite ease-in-out;
        }


        #ri-dot.error {
            background: var(--ri-red);

            box-shadow:
                0 0 8px rgba(255,100,110,0.5);
        }


        @keyframes riPulse {

            0% {
                opacity: .4;
                transform: scale(.8);
            }

            50% {
                opacity: 1;
                transform: scale(1.2);
            }

            100% {
                opacity: .4;
                transform: scale(.8);
            }
        }


        #ri-status-text {
            flex: 1;

            overflow: hidden;

            white-space: nowrap;

            text-overflow: ellipsis;

            color: #b7bdc6;

            font-size: 10px;
        }


        /* ========================================================
           FAST MODE
           ======================================================== */

        #ri-mode {
            display: flex;
            align-items: center;
            justify-content: space-between;

            margin-bottom: 9px;

            padding:
                7px 9px;

            border:
                1px solid rgba(112,170,255,0.10);

            border-radius: 8px;

            background:
                rgba(112,170,255,0.045);
        }


        #ri-mode-left {
            color: #9299a4;

            font-size: 9px;

            letter-spacing: .2px;
        }


        #ri-mode-value {
            display: flex;
            align-items: center;

            gap: 5px;

            color: #86b7ff;

            font-size: 9px;

            font-weight: 700;
        }


        #ri-mode-value::before {
            content: "";

            width: 5px;
            height: 5px;

            border-radius: 50%;

            background: #70aaff;

            box-shadow:
                0 0 6px rgba(112,170,255,.6);
        }


        /* ========================================================
           COUNTERS
           ======================================================== */

        #ri-counters {
            display: grid;

            grid-template-columns:
                repeat(3, 1fr);

            gap: 7px;

            margin-bottom: 9px;
        }


        .ri-counter {
            padding:
                9px 5px 8px;

            text-align: center;

            border:
                1px solid rgba(255,255,255,0.052);

            border-radius: 9px;

            background:
                rgba(255,255,255,0.033);
        }


        .ri-counter-num {
            font-size: 18px;

            font-weight: 700;

            line-height: 20px;

            font-variant-numeric:
                tabular-nums;
        }


        .ri-counter-label {
            margin-top: 3px;

            color: #737b86;

            font-size: 8px;

            font-weight: 600;

            letter-spacing: .65px;

            text-transform: uppercase;
        }


        #ri-count-on {
            color: var(--ri-green);
        }


        #ri-count-off {
            color: var(--ri-red);
        }


        #ri-count-total {
            color: #dce0e5;
        }


        /* ========================================================
           PROGRESS
           ======================================================== */

        #ri-progress-track {
            height: 3px;

            margin:
                2px 1px 10px;

            overflow: hidden;

            border-radius: 10px;

            background:
                rgba(255,255,255,0.06);
        }


        #ri-progress {
            width: 0%;
            height: 100%;

            border-radius: inherit;

            background:
                linear-gradient(
                    90deg,
                    #568de7,
                    #80b9ff
                );

            transition:
                width .22s ease;
        }


        /* ========================================================
           SCAN BUTTON
           ======================================================== */

        #ri-scan {
            width: 100%;
            height: 40px;

            display: flex;
            align-items: center;
            justify-content: center;

            gap: 7px;

            border:
                1px solid rgba(112,170,255,.28);

            border-radius: 9px;

            color: #edf5ff;

            background:
                linear-gradient(
                    180deg,
                    rgba(74,126,207,.31),
                    rgba(47,86,146,.25)
                );

            cursor: pointer;

            font-size: 11px;
            font-weight: 700;
        }


        #ri-scan:hover {
            border-color:
                rgba(112,170,255,.52);

            background:
                linear-gradient(
                    180deg,
                    rgba(82,139,228,.39),
                    rgba(49,91,157,.31)
                );
        }


        #ri-scan:disabled {
            cursor: default;

            opacity: .75;
        }


        #ri-scan-icon {
            font-size: 15px;
        }


        #ri-scan.scanning #ri-scan-icon {
            animation:
                riSpin .75s linear infinite;
        }


        @keyframes riSpin {
            to {
                transform: rotate(360deg);
            }
        }


        /* ========================================================
           FOOTER
           ======================================================== */

        #ri-footer {
            display: flex;

            justify-content: space-between;

            margin-top: 9px;

            padding:
                0 2px;

            color: #686f79;

            font-size: 8px;
        }


        /* ========================================================
           PLAYER BADGES
           ======================================================== */

        .ri-badge {
            display: inline-flex;

            align-items: center;

            gap: 4px;

            margin-left: 5px;

            padding:
                1px 5px;

            border-radius: 5px;

            font-family:
                -apple-system,
                BlinkMacSystemFont,
                "Segoe UI",
                Arial,
                sans-serif;

            font-size: 9px;

            line-height: 14px;

            font-weight: 750;

            letter-spacing: .35px;

            vertical-align: middle;

            white-space: nowrap;
        }


        .ri-badge::before {
            content: "";

            width: 5px;
            height: 5px;

            flex-shrink: 0;

            border-radius: 50%;
        }


        .ri-on {
            color: #68e99b;

            background:
                var(--ri-green-bg);

            border:
                1px solid rgba(77,229,138,.20);
        }


        .ri-on::before {
            background:
                var(--ri-green);

            box-shadow:
                0 0 5px rgba(77,229,138,.45);
        }


        .ri-off {
            color: #ff7f88;

            background:
                var(--ri-red-bg);

            border:
                1px solid rgba(255,100,110,.20);
        }


        .ri-off::before {
            background:
                var(--ri-red);

            box-shadow:
                0 0 5px rgba(255,100,110,.40);
        }


        /* ========================================================
           SETTINGS
           ======================================================== */

        #ri-menu {
            position: absolute;

            right: 8px;
            top: 45px;

            width: 178px;

            display: none;

            padding: 6px;

            z-index: 1000001;

            border:
                1px solid rgba(255,255,255,.12);

            border-radius: 10px;

            background:
                rgba(18,20,25,.99);

            box-shadow:
                0 12px 30px rgba(0,0,0,.45);
        }


        #ri-menu.open {
            display: block;
        }


        .ri-menu-item {
            width: 100%;

            padding:
                8px 9px;

            border: 0;

            border-radius: 7px;

            background: transparent;

            color: #c5cad1;

            font-size: 10px;

            text-align: left;

            cursor: pointer;
        }


        .ri-menu-item:hover {
            color: white;

            background:
                rgba(255,255,255,.06);
        }


        .ri-danger {
            color: #ff7c85;
        }


        .ri-divider {
            height: 1px;

            margin:
                5px 4px;

            background:
                rgba(255,255,255,.07);
        }


        /* ========================================================
           MINIMIZED
           ======================================================== */

        #ri-panel.minimized {
            width: 220px;
        }


        #ri-panel.minimized #ri-body {
            display: none;
        }


        #ri-panel.minimized #ri-header {
            border-bottom: none;
        }


        #ri-panel.minimized #ri-subtitle {
            display: none;
        }


        /* ========================================================
           TOAST
           ======================================================== */

        #ri-toast {
            position: absolute;

            left: 50%;
            bottom: -38px;

            min-width: 180px;

            padding:
                7px 10px;

            opacity: 0;

            pointer-events: none;

            transform:
                translateX(-50%)
                translateY(-5px);

            border:
                1px solid rgba(255,255,255,.09);

            border-radius: 8px;

            background:
                rgba(18,20,25,.99);

            color: #dce0e5;

            box-shadow:
                0 8px 22px rgba(0,0,0,.4);

            font-size: 9px;

            text-align: center;

            transition:
                opacity .16s ease,
                transform .16s ease;
        }


        #ri-toast.show {
            opacity: 1;

            transform:
                translateX(-50%)
                translateY(0);
        }

    `);


    // ============================================================
    // BASIC HELPERS
    // ============================================================

    function setText(id, text) {

        const element =
            document.getElementById(id);

        if (element)
            element.textContent = text;
    }


    function setProgress(value) {

        const element =
            document.getElementById(
                'ri-progress'
            );

        if (!element)
            return;

        value =
            Math.max(
                0,
                Math.min(
                    100,
                    value
                )
            );

        element.style.width =
            value + '%';
    }


    function setStatus(
        text,
        type = 'ready'
    ) {

        setText(
            'ri-status-text',
            text
        );

        const dot =
            document.getElementById(
                'ri-dot'
            );

        if (!dot)
            return;

        dot.className = type;
    }


    function formatTime(date) {

        if (!date)
            return 'Not scanned';

        return date.toLocaleTimeString(
            [],
            {
                hour: '2-digit',
                minute: '2-digit'
            }
        );
    }


    // ============================================================
    // TOAST
    // ============================================================

    let toastTimer = null;


    function showToast(text) {

        const toast =
            document.getElementById(
                'ri-toast'
            );

        if (!toast)
            return;

        toast.textContent =
            text;

        toast.classList.add(
            'show'
        );

        clearTimeout(
            toastTimer
        );

        toastTimer =
            setTimeout(
                () => {
                    toast.classList.remove(
                        'show'
                    );
                },
                2200
            );
    }


    // ============================================================
    // API KEY
    // ============================================================

    function setApiKey() {

        const key =
            prompt(
                'Enter your Torn API key.\n\n' +
                'The key is stored locally in Tampermonkey.'
            );

        if (!key)
            return false;

        apiKey =
            key.trim();

        GM_setValue(
            'reviveCheckerApiKey',
            apiKey
        );

        /*
            Clear old faction information when key changes.
        */

        factionCache.clear();
        reviveCache.clear();

        showToast(
            'API key saved'
        );

        return true;
    }


    function clearApiKey() {

        apiKey = '';

        GM_setValue(
            'reviveCheckerApiKey',
            ''
        );

        factionCache.clear();
        reviveCache.clear();

        removeBadges();

        showToast(
            'API key removed'
        );
    }


    // ============================================================
    // API
    // ============================================================

    function apiRequest(path) {

        return new Promise(
            (resolve, reject) => {

                if (!apiKey) {

                    reject(
                        new Error(
                            'No API key configured'
                        )
                    );

                    return;
                }


                const separator =
                    path.includes('?')
                        ? '&'
                        : '?';


                const url =
                    API +
                    '/' +
                    path +
                    separator +
                    'key=' +
                    encodeURIComponent(
                        apiKey
                    );


                GM_xmlhttpRequest({

                    method: 'GET',

                    url: url,

                    timeout: 12000,


                    onload: response => {

                        try {

                            const data =
                                JSON.parse(
                                    response.responseText
                                );


                            if (data.error) {

                                reject(
                                    new Error(
                                        data.error.error ||
                                        data.error.message ||
                                        'Torn API error'
                                    )
                                );

                                return;
                            }


                            resolve(data);

                        } catch (error) {

                            reject(error);
                        }
                    },


                    onerror: () => {

                        reject(
                            new Error(
                                'Network error'
                            )
                        );
                    },


                    ontimeout: () => {

                        reject(
                            new Error(
                                'API timeout'
                            )
                        );
                    }

                });
            }
        );
    }


    // ============================================================
    // PLAYER ID FROM PROFILE LINK
    // ============================================================

    function playerIdFromLink(link) {

        if (!link)
            return null;

        try {

            const href =
                link.getAttribute('href') ||
                link.href ||
                '';


            const match =
                href.match(
                    /profiles\.php\?[^#]*XID=(\d+)/i
                );


            if (match)
                return String(match[1]);

        } catch (error) {}


        return null;
    }


    // ============================================================
    // FACTION ID FROM LINK
    // ============================================================

    function factionIdFromLink(link) {

        if (!link)
            return null;

        try {

            const href =
                link.getAttribute('href') ||
                link.href ||
                '';


            if (
                !href.includes(
                    'factions.php'
                )
            )
                return null;


            const url =
                new URL(
                    href,
                    location.origin
                );


            const id =
                url.searchParams.get('ID') ||
                url.searchParams.get('id');


            if (
                id &&
                /^\d+$/.test(id)
            )
                return String(id);

        } catch (error) {}


        return null;
    }


    // ============================================================
    // FIND ACTUAL WAR MEMBER LISTS
    //
    // IMPORTANT:
    // We DO NOT scan every profile link on Torn anymore.
    //
    // Torn war scripts commonly use ul.members-list /
    // .f-war-list.members-list.
    // ============================================================

    function getWarLists() {

        const found =
            new Set();


        const selectors = [

            'ul.members-list',

            '.f-war-list.members-list',

            '[class*="members-list"]'

        ];


        selectors.forEach(
            selector => {

                document
                    .querySelectorAll(
                        selector
                    )
                    .forEach(
                        element => {

                            /*
                                Only accept it if it actually
                                contains player profile links.
                            */

                            if (
                                element.querySelector(
                                    'a[href*="profiles.php"][href*="XID="]'
                                )
                            ) {

                                found.add(
                                    element
                                );
                            }
                        }
                    );
            }
        );


        return [
            ...found
        ];
    }


    // ============================================================
    // GET WAR MEMBER LINKS ONLY
    // ============================================================

    function getWarMemberLinks() {

        const links =
            new Set();


        getWarLists().forEach(
            list => {

                list
                    .querySelectorAll(
                        'a[href*="profiles.php"][href*="XID="]'
                    )
                    .forEach(
                        link => {

                            if (
                                playerIdFromLink(
                                    link
                                )
                            ) {

                                links.add(
                                    link
                                );
                            }
                        }
                    );
            }
        );


        return [
            ...links
        ];
    }


    // ============================================================
    // UNIQUE WAR PLAYER IDS
    // ============================================================

    function getWarPlayerIds() {

        const ids =
            new Set();


        getWarMemberLinks()
            .forEach(
                link => {

                    const id =
                        playerIdFromLink(
                            link
                        );


                    if (id)
                        ids.add(id);
                }
            );


        return [
            ...ids
        ];
    }


    // ============================================================
    // DISCOVER THE WAR FACTIONS
    // ============================================================

    function getWarFactionIds() {

        const ids =
            new Set();


        /*
            FIRST:
            Look specifically inside each war member list.
        */

        getWarLists().forEach(
            list => {

                list
                    .querySelectorAll(
                        'a[href*="factions.php"]'
                    )
                    .forEach(
                        link => {

                            const id =
                                factionIdFromLink(
                                    link
                                );


                            if (id)
                                ids.add(id);
                        }
                    );
            }
        );


        /*
            SECOND:
            Sometimes Torn places faction names directly above
            the list instead of inside it.

            Look around the war-area parents.
        */

        if (
            ids.size < 2
        ) {

            getWarLists().forEach(
                list => {

                    let parent =
                        list.parentElement;


                    for (
                        let depth = 0;
                        depth < 4 && parent;
                        depth++
                    ) {

                        parent
                            .querySelectorAll(
                                'a[href*="factions.php"][href*="ID="]'
                            )
                            .forEach(
                                link => {

                                    const id =
                                        factionIdFromLink(
                                            link
                                        );


                                    if (id)
                                        ids.add(id);
                                }
                            );


                        parent =
                            parent.parentElement;
                    }
                }
            );
        }


        /*
            THIRD:
            Current faction URL can contain an ID.
        */

        try {

            const current =
                new URL(
                    location.href
                );


            const id =
                current.searchParams.get(
                    'ID'
                );


            if (
                id &&
                /^\d+$/.test(id)
            ) {

                ids.add(
                    String(id)
                );
            }

        } catch (error) {}


        /*
            FOURTH:
            Fallback to faction links from Torn's main content.

            This still does NOT inspect any user profiles.
        */

        if (
            ids.size < 2
        ) {

            const main =
                document.querySelector(
                    '#mainContainer'
                ) ||
                document.querySelector(
                    'main'
                ) ||
                document.body;


            main
                .querySelectorAll(
                    'a[href*="factions.php"][href*="ID="]'
                )
                .forEach(
                    link => {

                        const id =
                            factionIdFromLink(
                                link
                            );


                        if (id)
                            ids.add(id);
                    }
                );
        }


        /*
            A ranked war normally needs two factions.
            Keep a small safety limit in case Torn page contains
            unrelated faction links.
        */

        return [
            ...ids
        ].slice(
            0,
            6
        );
    }


    // ============================================================
    // READ REVIVABLE VALUE
    // ============================================================

    function normalizeBool(value) {

        if (
            value === true ||
            value === 1 ||
            value === '1'
        )
            return true;


        if (
            value === false ||
            value === 0 ||
            value === '0'
        )
            return false;


        return null;
    }


    function readRevivable(member) {

        if (!member)
            return null;


        let value =
            normalizeBool(
                member.is_revivable
            );


        if (
            value !== null
        )
            return value;


        value =
            normalizeBool(
                member.revivable
            );


        return value;
    }


    // ============================================================
    // NORMALIZE FACTION RESPONSE
    // ============================================================

    function normalizeMembers(data) {

        if (!data)
            return [];


        let members =
            data.members ||
            data.faction_members ||
            [];


        /*
            Current V2 normally uses an array.
        */

        if (
            Array.isArray(
                members
            )
        ) {

            return members;
        }


        /*
            Support object format just in case.
        */

        if (
            members &&
            typeof members === 'object'
        ) {

            return Object.entries(
                members
            )

            .map(
                ([id, member]) => ({

                    ...member,

                    id:
                        member.id ||
                        member.user_id ||
                        member.player_id ||
                        id

                })
            );
        }


        return [];
    }


    // ============================================================
    // FETCH ENTIRE FACTION
    //
    // ONE REQUEST = ALL MEMBERS
    //
    // NO PLAYER PROFILE REQUESTS
    // ============================================================

    async function fetchFaction(
        factionId
    ) {

        const timestamp =
            Math.floor(
                Date.now() / 1000
            );


        const data =
            await apiRequest(

                `faction/${factionId}/members` +
                `?striptags=true&timestamp=${timestamp}`

            );


        const members =
            normalizeMembers(
                data
            );


        const memberMap =
            new Map();


        members.forEach(
            member => {

                const id =
                    member.id ||
                    member.user_id ||
                    member.player_id;


                if (!id)
                    return;


                const playerId =
                    String(id);


                const revive =
                    readRevivable(
                        member
                    );


                memberMap.set(
                    playerId,
                    revive
                );


                if (
                    revive !== null
                ) {

                    reviveCache.set(
                        playerId,
                        revive
                    );
                }
            }
        );


        factionCache.set(
            String(factionId),
            memberMap
        );


        return {
            factionId:
                String(factionId),

            members:
                members.length
        };
    }


    // ============================================================
    // BADGES
    // ============================================================

    function removeBadges() {

        document
            .querySelectorAll(
                '.ri-badge'
            )
            .forEach(
                badge =>
                    badge.remove()
            );
    }


    function addBadge(
        link,
        revivable
    ) {

        if (
            revivable !== true &&
            revivable !== false
        )
            return;


        /*
            Prevent duplicate badges.
        */

        const old =
            link.parentElement
                ?.querySelector(
                    `.ri-badge[data-ri-player="${playerIdFromLink(link)}"]`
                );


        if (old)
            old.remove();


        const badge =
            document.createElement(
                'span'
            );


        const id =
            playerIdFromLink(
                link
            );


        badge.dataset.riPlayer =
            id;


        badge.className =
            'ri-badge ' +
            (
                revivable
                    ? 'ri-on'
                    : 'ri-off'
            );


        badge.textContent =
            revivable
                ? 'ON'
                : 'OFF';


        badge.title =
            revivable
                ? 'You can revive this player'
                : 'You cannot revive this player';


        link.insertAdjacentElement(
            'afterend',
            badge
        );
    }


    // ============================================================
    // PAINT ALL WAR MEMBERS
    // ============================================================

    function paintBadges() {

        const links =
            getWarMemberLinks();


        links.forEach(
            link => {

                const id =
                    playerIdFromLink(
                        link
                    );


                if (!id)
                    return;


                if (
                    reviveCache.has(
                        id
                    )
                ) {

                    addBadge(
                        link,
                        reviveCache.get(
                            id
                        )
                    );
                }
            }
        );


        updateCounters();
    }


    // ============================================================
    // COUNTERS
    // ============================================================

    function calculateCounts() {

        const ids =
            getWarPlayerIds();


        let on = 0;
        let off = 0;


        ids.forEach(
            id => {

                const value =
                    reviveCache.get(id);


                if (
                    value === true
                )
                    on++;


                if (
                    value === false
                )
                    off++;
            }
        );


        return {

            on,

            off,

            total:
                ids.length,

            resolved:
                on + off

        };
    }


    function updateCounters() {

        const counts =
            calculateCounts();


        setText(
            'ri-count-on',
            counts.on
        );


        setText(
            'ri-count-off',
            counts.off
        );


        setText(
            'ri-count-total',
            counts.total
        );


        return counts;
    }


    // ============================================================
    // SCANNING UI
    // ============================================================

    function setScanningUI(active) {

        const button =
            document.getElementById(
                'ri-scan'
            );


        if (!button)
            return;


        button.disabled =
            active;


        button.classList.toggle(
            'scanning',
            active
        );


        setText(
            'ri-scan-icon',
            active
                ? '↻'
                : '↻'
        );


        setText(
            'ri-scan-text',
            active
                ? 'Bulk Scanning...'
                : 'Rescan War'
        );
    }


    // ============================================================
    // FAST BULK SCAN
    // ============================================================

    async function scanPage(
        automatic = false
    ) {

        if (scanning)
            return;


        /*
            Do not do anything unless actual war member
            lists are present.
        */

        const playerIds =
            getWarPlayerIds();


        if (
            playerIds.length === 0
        ) {

            setStatus(
                'Waiting for war member list...',
                'ready'
            );

            setProgress(0);

            return;
        }


        if (!apiKey) {

            if (automatic)
                return;


            if (
                !setApiKey()
            )
                return;
        }


        const factionIds =
            getWarFactionIds();


        if (
            factionIds.length === 0
        ) {

            setStatus(
                'Could not detect war factions',
                'error'
            );

            showToast(
                'No war faction IDs detected'
            );

            return;
        }


        scanning = true;


        reviveCache.clear();
        factionCache.clear();

        removeBadges();


        setScanningUI(
            true
        );


        setStatus(
            `Bulk loading ${factionIds.length} faction${factionIds.length === 1 ? '' : 's'}...`,
            'scanning'
        );


        setProgress(
            10
        );


        try {

            /*
                IMPORTANT SPEED CHANGE

                All faction calls happen AT THE SAME TIME.

                Example:
                faction A -> 50 members
                faction B -> 50 members

                This is only two API requests.

                NOT 100 player profile requests.
            */

            const jobs =
                factionIds.map(
                    id =>
                        fetchFaction(id)
                );


            const results =
                await Promise.allSettled(
                    jobs
                );


            setProgress(
                80
            );


            const successful =
                results.filter(
                    result =>
                        result.status ===
                        'fulfilled'
                );


            const failed =
                results.filter(
                    result =>
                        result.status ===
                        'rejected'
                );


            /*
                Draw everyone from the bulk response.
            */

            paintBadges();


            const counts =
                updateCounters();


            setProgress(
                100
            );


            lastScanTime =
                new Date();


            setText(
                'ri-last-scan',
                'Last scan ' +
                formatTime(
                    lastScanTime
                )
            );


            /*
                Determine whether every visible war member
                received an ON/OFF result.
            */

            if (
                counts.resolved ===
                counts.total
            ) {

                setStatus(
                    `Complete • ${counts.total}/${counts.total} members`,
                    'ready'
                );

            } else {

                setStatus(
                    `${counts.resolved}/${counts.total} members resolved`,
                    failed.length
                        ? 'error'
                        : 'ready'
                );
            }


            showToast(
                `${counts.on} ON • ${counts.off} OFF • ${counts.total} members`
            );


            console.log(
                '[Revive Intel] Bulk scan complete',
                {
                    factions:
                        factionIds,

                    successfulRequests:
                        successful.length,

                    failedRequests:
                        failed.length,

                    players:
                        counts.total,

                    resolved:
                        counts.resolved
                }
            );


        } catch (error) {

            console.error(
                '[Revive Intel]',
                error
            );


            setStatus(
                'Bulk scan failed',
                'error'
            );


            showToast(
                error.message ||
                'Scan failed'
            );


        } finally {

            scanning = false;


            setScanningUI(
                false
            );
        }
    }


    // ============================================================
    // PANEL
    // ============================================================

    function createPanel() {

        if (
            document.getElementById(
                'ri-panel'
            )
        )
            return;


        const panel =
            document.createElement(
                'div'
            );


        panel.id =
            'ri-panel';


        panel.innerHTML = `

            <div id="ri-header">

                <div id="ri-logo"></div>


                <div id="ri-title-area">

                    <div id="ri-title">
                        Revive Intel
                    </div>

                    <div id="ri-subtitle">
                        War Availability Scanner
                    </div>

                </div>


                <button
                    id="ri-settings"
                    class="ri-head-btn"
                    type="button"
                    title="Settings"
                >
                    ⚙
                </button>


                <button
                    id="ri-minimize"
                    class="ri-head-btn"
                    type="button"
                    title="Minimize"
                >
                    −
                </button>

            </div>


            <div id="ri-menu">

                <button
                    id="ri-set-key"
                    class="ri-menu-item"
                    type="button"
                >
                    Set / Change API Key
                </button>


                <button
                    id="ri-reset-position"
                    class="ri-menu-item"
                    type="button"
                >
                    Reset Panel Position
                </button>


                <div class="ri-divider"></div>


                <button
                    id="ri-clear-key"
                    class="ri-menu-item ri-danger"
                    type="button"
                >
                    Remove API Key
                </button>

            </div>


            <div id="ri-body">

                <div id="ri-status">

                    <div
                        id="ri-dot"
                        class="ready"
                    ></div>

                    <div id="ri-status-text">
                        Ready
                    </div>

                </div>


                <div id="ri-mode">

                    <span id="ri-mode-left">
                        Scan engine
                    </span>

                    <span id="ri-mode-value">
                        FAST BULK MODE
                    </span>

                </div>


                <div id="ri-counters">

                    <div class="ri-counter">

                        <div
                            id="ri-count-on"
                            class="ri-counter-num"
                        >
                            0
                        </div>

                        <div class="ri-counter-label">
                            ON
                        </div>

                    </div>


                    <div class="ri-counter">

                        <div
                            id="ri-count-off"
                            class="ri-counter-num"
                        >
                            0
                        </div>

                        <div class="ri-counter-label">
                            OFF
                        </div>

                    </div>


                    <div class="ri-counter">

                        <div
                            id="ri-count-total"
                            class="ri-counter-num"
                        >
                            0
                        </div>

                        <div class="ri-counter-label">
                            Members
                        </div>

                    </div>

                </div>


                <div id="ri-progress-track">

                    <div id="ri-progress"></div>

                </div>


                <button
                    id="ri-scan"
                    type="button"
                >

                    <span id="ri-scan-icon">
                        ↻
                    </span>

                    <span id="ri-scan-text">
                        Rescan War
                    </span>

                </button>


                <div id="ri-footer">

                    <span id="ri-last-scan">
                        Not scanned yet
                    </span>

                    <span>
                        v${VERSION}
                    </span>

                </div>

            </div>


            <div id="ri-toast"></div>

        `;


        document.body.appendChild(
            panel
        );


        restorePanelPosition(
            panel
        );


        setupControls();


        makeDraggable(
            panel
        );


        const savedMinimized =
            GM_getValue(
                'riPanelMinimized',
                false
            );


        if (savedMinimized) {

            panel.classList.add(
                'minimized'
            );


            setText(
                'ri-minimize',
                '+'
            );
        }
    }


    // ============================================================
    // CONTROLS
    // ============================================================

    function setupControls() {

        document
            .getElementById(
                'ri-scan'
            )
            .addEventListener(
                'click',
                () => scanPage(false)
            );


        const menu =
            document.getElementById(
                'ri-menu'
            );


        const settings =
            document.getElementById(
                'ri-settings'
            );


        settings.addEventListener(
            'click',
            event => {

                event.stopPropagation();


                menu.classList.toggle(
                    'open'
                );
            }
        );


        document.addEventListener(
            'click',
            event => {

                if (
                    !menu.contains(
                        event.target
                    ) &&
                    event.target !==
                        settings
                ) {

                    menu.classList.remove(
                        'open'
                    );
                }
            }
        );


        document
            .getElementById(
                'ri-set-key'
            )
            .addEventListener(
                'click',
                () => {

                    menu.classList.remove(
                        'open'
                    );


                    setApiKey();
                }
            );


        document
            .getElementById(
                'ri-clear-key'
            )
            .addEventListener(
                'click',
                () => {

                    menu.classList.remove(
                        'open'
                    );


                    clearApiKey();
                }
            );


        document
            .getElementById(
                'ri-reset-position'
            )
            .addEventListener(
                'click',
                () => {

                    menu.classList.remove(
                        'open'
                    );


                    resetPosition();
                }
            );


        document
            .getElementById(
                'ri-minimize'
            )
            .addEventListener(
                'click',
                event => {

                    event.stopPropagation();


                    const panel =
                        document.getElementById(
                            'ri-panel'
                        );


                    const minimized =
                        panel.classList.toggle(
                            'minimized'
                        );


                    event.currentTarget.textContent =
                        minimized
                            ? '+'
                            : '−';


                    GM_setValue(
                        'riPanelMinimized',
                        minimized
                    );
                }
            );
    }


    // ============================================================
    // DRAG PANEL
    // ============================================================

    function makeDraggable(panel) {

        const header =
            document.getElementById(
                'ri-header'
            );


        let dragging = false;

        let startX = 0;
        let startY = 0;

        let startLeft = 0;
        let startTop = 0;


        header.addEventListener(
            'pointerdown',
            event => {

                if (
                    event.target.closest(
                        'button'
                    )
                )
                    return;


                if (
                    event.pointerType ===
                        'mouse' &&
                    event.button !== 0
                )
                    return;


                dragging = true;


                const rect =
                    panel.getBoundingClientRect();


                startX =
                    event.clientX;

                startY =
                    event.clientY;

                startLeft =
                    rect.left;

                startTop =
                    rect.top;


                panel.style.right =
                    'auto';

                panel.style.left =
                    startLeft + 'px';

                panel.style.top =
                    startTop + 'px';


                header.classList.add(
                    'dragging'
                );


                try {

                    header.setPointerCapture(
                        event.pointerId
                    );

                } catch (error) {}
            }
        );


        header.addEventListener(
            'pointermove',
            event => {

                if (!dragging)
                    return;


                let left =
                    startLeft +
                    (
                        event.clientX -
                        startX
                    );


                let top =
                    startTop +
                    (
                        event.clientY -
                        startY
                    );


                const maxLeft =
                    Math.max(
                        0,
                        window.innerWidth -
                        panel.offsetWidth
                    );


                const maxTop =
                    Math.max(
                        0,
                        window.innerHeight -
                        panel.offsetHeight
                    );


                left =
                    Math.min(
                        Math.max(
                            0,
                            left
                        ),
                        maxLeft
                    );


                top =
                    Math.min(
                        Math.max(
                            0,
                            top
                        ),
                        maxTop
                    );


                panel.style.left =
                    left + 'px';

                panel.style.top =
                    top + 'px';
            }
        );


        function finish(event) {

            if (!dragging)
                return;


            dragging = false;


            header.classList.remove(
                'dragging'
            );


            try {

                header.releasePointerCapture(
                    event.pointerId
                );

            } catch (error) {}


            savePosition();
        }


        header.addEventListener(
            'pointerup',
            finish
        );


        header.addEventListener(
            'pointercancel',
            finish
        );
    }


    // ============================================================
    // POSITION
    // ============================================================

    function savePosition() {

        const panel =
            document.getElementById(
                'ri-panel'
            );


        if (!panel)
            return;


        const rect =
            panel.getBoundingClientRect();


        GM_setValue(
            'riPanelPosition',
            {
                left:
                    Math.round(
                        rect.left
                    ),

                top:
                    Math.round(
                        rect.top
                    )
            }
        );
    }


    function restorePanelPosition(panel) {

        const saved =
            GM_getValue(
                'riPanelPosition',
                null
            );


        if (
            !saved ||
            typeof saved.left !==
                'number' ||
            typeof saved.top !==
                'number'
        )
            return;


        panel.style.right =
            'auto';


        let left =
            saved.left;

        let top =
            saved.top;


        const maxLeft =
            Math.max(
                0,
                window.innerWidth -
                panel.offsetWidth
            );


        const maxTop =
            Math.max(
                0,
                window.innerHeight -
                panel.offsetHeight
            );


        left =
            Math.min(
                Math.max(
                    0,
                    left
                ),
                maxLeft
            );


        top =
            Math.min(
                Math.max(
                    0,
                    top
                ),
                maxTop
            );


        panel.style.left =
            left + 'px';

        panel.style.top =
            top + 'px';
    }


    function resetPosition() {

        GM_setValue(
            'riPanelPosition',
            null
        );


        const panel =
            document.getElementById(
                'ri-panel'
            );


        if (!panel)
            return;


        panel.style.left =
            'auto';

        panel.style.right =
            '20px';

        panel.style.top =
            '140px';


        showToast(
            'Panel position reset'
        );
    }


    // ============================================================
    // DYNAMIC TORN PAGE WATCHER
    //
    // Torn dynamically redraws its member rows.
    //
    // We DO NOT make another API request.
    // We simply reapply badges from the already downloaded cache.
    // ============================================================

    let mutationTimer = null;


    const observer =
        new MutationObserver(
            () => {

                clearTimeout(
                    mutationTimer
                );


                mutationTimer =
                    setTimeout(
                        () => {

                            const players =
                                getWarPlayerIds();


                            if (
                                players.length === 0
                            )
                                return;


                            /*
                                Newly rendered members immediately
                                receive their cached ON/OFF badge.
                            */

                            paintBadges();


                            /*
                                Automatic initial bulk scan only once.
                            */

                            if (
                                !autoScanDone &&
                                apiKey &&
                                !scanning
                            ) {

                                autoScanDone =
                                    true;


                                setTimeout(
                                    () =>
                                        scanPage(
                                            true
                                        ),
                                    400
                                );
                            }

                        },
                        250
                    );
            }
        );


    // ============================================================
    // MENU COMMANDS
    // ============================================================

    GM_registerMenuCommand(
        'Rescan War Revives',
        () => scanPage(false)
    );


    GM_registerMenuCommand(
        'Set Torn API Key',
        setApiKey
    );


    GM_registerMenuCommand(
        'Clear Torn API Key',
        clearApiKey
    );


    GM_registerMenuCommand(
        'Reset Revive Intel Position',
        resetPosition
    );


    // ============================================================
    // START
    // ============================================================

    createPanel();


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


    /*
        Torn may already have loaded the war list before
        MutationObserver starts.
    */

    setTimeout(
        () => {

            const players =
                getWarPlayerIds();


            if (
                players.length > 0
            ) {

                updateCounters();


                if (apiKey) {

                    autoScanDone =
                        true;


                    scanPage(
                        true
                    );

                } else {

                    setStatus(
                        `${players.length} war members detected`,
                        'ready'
                    );
                }

            } else {

                setStatus(
                    'Waiting for war member list...',
                    'ready'
                );
            }

        },
        1200
    );

})();