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와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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
    );

})();