Torn War Intel

Faction War Intelligence: stats overlay, filters, sorting, and multi-API integration

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn War Intel
// @namespace    https://torn.com/
// @version      1.0.0
// @description  Faction War Intelligence: stats overlay, filters, sorting, and multi-API integration
// @author       WarIntel
// @match        https://www.torn.com/factions.php*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      api.torn.com
// @connect      yata.yt
// @connect      tornstats.com
// @connect      ffscouter.com
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // ─────────────────────────────────────────────
    // CONSTANTS & STATE
    // ─────────────────────────────────────────────
    const STORAGE_KEY = 'TWI_data';
    const SETTINGS_KEY = 'TWI_settings';
    const VERSION = '1.8.0';

    const defaultSettings = {
        tornKey: '',
        yataKey: '',
        tornstatsKey: '',
        ffscouterKey: '',
        enableYata: true,
        enableTornStats: true,
        enableFFScouter: true,
    };

    const defaultFilters = {
        minBattle: '',
        maxBattle: '',
        minStr: '',
        maxStr: '',
        minSpd: '',
        maxSpd: '',
        minDef: '',
        maxDef: '',
        minDex: '',
        maxDex: '',
        hideUnknown: false,
    };

    let settings = loadSettings();
    let cachedStats = loadCache();
    let activeFilters = { ...defaultFilters };
    let sortMode = 'default'; // default | status | hospital | abroad | traveling | score
    let panelOpen = false;
    let settingsOpen = false;

    // ─────────────────────────────────────────────
    // STORAGE HELPERS
    // ─────────────────────────────────────────────
    function loadSettings() {
        try {
            const raw = localStorage.getItem(SETTINGS_KEY);
            return raw ? { ...defaultSettings, ...JSON.parse(raw) } : { ...defaultSettings };
        } catch { return { ...defaultSettings }; }
    }

    function saveSettings() {
        localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
    }

    function loadCache() {
        try {
            const raw = localStorage.getItem(STORAGE_KEY);
            return raw ? JSON.parse(raw) : {};
        } catch { return {}; }
    }

    function saveCache() {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(cachedStats));
    }

    function clearCache() {
        cachedStats = {};
        localStorage.removeItem(STORAGE_KEY);
        showToast('Cache cleared!', 'info');
    }

    // ─────────────────────────────────────────────
    // STYLES
    // ─────────────────────────────────────────────
    function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
        @import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&display=swap');

        :root {
            --twi-bg: #0a0c10;
            --twi-bg2: #0f1318;
            --twi-bg3: #151c25;
            --twi-border: #1e2d3d;
            --twi-accent: #00d4ff;
            --twi-accent2: #ff4757;
            --twi-accent3: #2ed573;
            --twi-gold: #ffa502;
            --twi-text: #c8d6e5;
            --twi-text-dim: #576574;
            --twi-panel-w: 480px;
            --twi-font: 'Rajdhani', sans-serif;
            --twi-mono: 'Share Tech Mono', monospace;
        }

        /* ── Floating Toggle Button ── */
        #twi-toggle {
            position: fixed;
            top: 120px;
            right: 0;
            z-index: 99998;
            background: linear-gradient(135deg, #0f1318, #151c25);
            border: 1px solid var(--twi-accent);
            border-right: none;
            border-radius: 8px 0 0 8px;
            padding: 10px 14px;
            cursor: pointer;
            color: var(--twi-accent);
            font-family: var(--twi-mono);
            font-size: 11px;
            letter-spacing: 1px;
            writing-mode: vertical-rl;
            text-orientation: mixed;
            transform: rotate(180deg);
            box-shadow: -4px 0 20px rgba(0,212,255,0.15);
            transition: all 0.2s;
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 6px;
        }
        #twi-toggle:hover {
            background: var(--twi-accent);
            color: var(--twi-bg);
            box-shadow: -4px 0 30px rgba(0,212,255,0.4);
        }
        #twi-toggle .twi-pulse {
            width: 6px;
            height: 6px;
            border-radius: 50%;
            background: var(--twi-accent3);
            animation: twi-pulse 1.5s infinite;
        }

        /* ── Main Panel ── */
        #twi-panel {
            position: fixed;
            top: 0;
            right: -520px;
            width: var(--twi-panel-w);
            height: 100vh;
            z-index: 99997;
            background: var(--twi-bg);
            border-left: 1px solid var(--twi-border);
            display: flex;
            flex-direction: column;
            transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
            box-shadow: -10px 0 40px rgba(0,0,0,0.6);
            font-family: var(--twi-font);
            color: var(--twi-text);
            overflow: hidden;
        }
        #twi-panel.open { right: 0; }

        /* ── Panel Header ── */
        #twi-header {
            background: var(--twi-bg2);
            border-bottom: 1px solid var(--twi-border);
            padding: 14px 16px;
            display: flex;
            align-items: center;
            gap: 10px;
            flex-shrink: 0;
        }
        #twi-header .twi-logo {
            font-family: var(--twi-mono);
            font-size: 13px;
            color: var(--twi-accent);
            letter-spacing: 2px;
            flex: 1;
        }
        #twi-header .twi-logo span {
            color: var(--twi-accent2);
        }
        #twi-header-btns {
            display: flex;
            gap: 6px;
        }
        .twi-icon-btn {
            background: var(--twi-bg3);
            border: 1px solid var(--twi-border);
            border-radius: 6px;
            color: var(--twi-text-dim);
            cursor: pointer;
            width: 30px;
            height: 30px;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 14px;
            transition: all 0.2s;
        }
        .twi-icon-btn:hover { border-color: var(--twi-accent); color: var(--twi-accent); }
        .twi-icon-btn.active { border-color: var(--twi-accent); color: var(--twi-accent); background: rgba(0,212,255,0.1); }
        .twi-icon-btn.cooldown {
            cursor: not-allowed !important;
            opacity: 0.5;
            pointer-events: none;
            font-size: 10px !important;
            font-family: var(--twi-mono);
            color: var(--twi-text-dim) !important;
            border-color: var(--twi-text-dim) !important;
        }

        /* ── Tabs ── */
        #twi-tabs {
            display: flex;
            border-bottom: 1px solid var(--twi-border);
            background: var(--twi-bg2);
            flex-shrink: 0;
        }
        .twi-tab {
            flex: 1;
            padding: 9px 0;
            text-align: center;
            font-size: 12px;
            font-weight: 600;
            letter-spacing: 1px;
            text-transform: uppercase;
            color: var(--twi-text-dim);
            cursor: pointer;
            border-bottom: 2px solid transparent;
            transition: all 0.2s;
        }
        .twi-tab:hover { color: var(--twi-text); }
        .twi-tab.active { color: var(--twi-accent); border-bottom-color: var(--twi-accent); }

        /* ── Content Area ── */
        #twi-content {
            flex: 1;
            overflow-y: auto;
            overflow-x: hidden;
        }
        #twi-content::-webkit-scrollbar { width: 4px; }
        #twi-content::-webkit-scrollbar-track { background: var(--twi-bg); }
        #twi-content::-webkit-scrollbar-thumb { background: var(--twi-border); border-radius: 2px; }

        /* ── Tab Panels ── */
        .twi-tab-panel { display: none; padding: 12px; }
        .twi-tab-panel.active { display: block; }

        /* ── Section Header ── */
        .twi-section-title {
            font-family: var(--twi-mono);
            font-size: 10px;
            letter-spacing: 2px;
            color: var(--twi-text-dim);
            text-transform: uppercase;
            margin: 12px 0 8px;
            padding-bottom: 4px;
            border-bottom: 1px solid var(--twi-border);
        }

        /* ── Sort Sticky Container ── */
        #twi-sort-sticky {
            position: sticky;
            top: 0;
            z-index: 10;
            background: var(--twi-bg);
            padding: 8px 0 4px;
            border-bottom: 1px solid var(--twi-border);
            margin-bottom: 8px;
        }

        /* ── Sort Bar ── */
        #twi-sort-bar {
            display: flex;
            gap: 5px;
            flex-wrap: wrap;
        }
        .twi-sort-btn {
            background: var(--twi-bg3);
            border: 1px solid var(--twi-border);
            border-radius: 5px;
            color: var(--twi-text-dim);
            font-family: var(--twi-font);
            font-size: 12px;
            font-weight: 600;
            letter-spacing: 0.5px;
            padding: 5px 10px;
            cursor: pointer;
            transition: all 0.2s;
        }
        .twi-sort-btn:hover { border-color: var(--twi-gold); color: var(--twi-gold); }
        .twi-sort-btn.active { background: rgba(255,165,2,0.15); border-color: var(--twi-gold); color: var(--twi-gold); }

        /* ── Filter Grid ── */
        .twi-filter-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 6px;
            margin-bottom: 10px;
        }
        .twi-filter-group {
            display: flex;
            flex-direction: column;
            gap: 3px;
        }
        .twi-filter-group label {
            font-size: 10px;
            font-family: var(--twi-mono);
            color: var(--twi-text-dim);
            letter-spacing: 1px;
        }
        .twi-filter-group input[type="number"] {
            background: var(--twi-bg3);
            border: 1px solid var(--twi-border);
            border-radius: 5px;
            color: var(--twi-text);
            font-family: var(--twi-mono);
            font-size: 12px;
            padding: 5px 8px;
            width: 100%;
            box-sizing: border-box;
            transition: border-color 0.2s;
        }
        .twi-filter-group input[type="number"]:focus {
            outline: none;
            border-color: var(--twi-accent);
        }
        .twi-filter-group input[type="number"]::placeholder { color: var(--twi-text-dim); }

        .twi-checkbox-row {
            display: flex;
            align-items: center;
            gap: 8px;
            margin: 6px 0;
            cursor: pointer;
        }
        .twi-checkbox-row input[type="checkbox"] { accent-color: var(--twi-accent); cursor: pointer; }
        .twi-checkbox-row span { font-size: 12px; font-weight: 600; }

        .twi-btn {
            background: transparent;
            border: 1px solid var(--twi-accent);
            border-radius: 6px;
            color: var(--twi-accent);
            font-family: var(--twi-font);
            font-weight: 700;
            font-size: 12px;
            letter-spacing: 1px;
            padding: 7px 14px;
            cursor: pointer;
            transition: all 0.2s;
            text-transform: uppercase;
        }
        .twi-btn:hover { background: rgba(0,212,255,0.15); }
        .twi-btn.danger { border-color: var(--twi-accent2); color: var(--twi-accent2); }
        .twi-btn.danger:hover { background: rgba(255,71,87,0.15); }
        .twi-btn.success { border-color: var(--twi-accent3); color: var(--twi-accent3); }
        .twi-btn.success:hover { background: rgba(46,213,115,0.15); }
        .twi-btn-row {
            display: flex;
            gap: 6px;
            flex-wrap: wrap;
            margin-top: 6px;
        }

        /* ── Member Cards ── */
        .twi-member-card {
            background: var(--twi-bg2);
            border: 1px solid var(--twi-border);
            border-radius: 8px;
            margin-bottom: 6px;
            overflow: hidden;
            transition: border-color 0.2s;
        }
        .twi-member-card:hover { border-color: rgba(0,212,255,0.3); }
        .twi-member-card.hidden { display: none; }

        .twi-member-header {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 8px 10px;
            cursor: pointer;
            user-select: none;
        }
        .twi-status-dot {
            width: 8px;
            height: 8px;
            border-radius: 50%;
            flex-shrink: 0;
        }
        .twi-status-dot.okay { background: var(--twi-accent3); box-shadow: 0 0 6px var(--twi-accent3); }
        .twi-status-dot.hospital { background: var(--twi-accent2); box-shadow: 0 0 6px var(--twi-accent2); }
        .twi-status-dot.abroad, .twi-status-dot.traveling { background: var(--twi-gold); box-shadow: 0 0 6px var(--twi-gold); }
        .twi-status-dot.jail { background: #a855f7; box-shadow: 0 0 6px #a855f7; }
        .twi-status-dot.unknown { background: var(--twi-text-dim); }

        /* ── Online indicator ── */
        .twi-online-dot {
            width: 7px;
            height: 7px;
            border-radius: 50%;
            flex-shrink: 0;
            position: relative;
        }
        .twi-online-dot.online {
            background: #2ed573;
            box-shadow: 0 0 5px #2ed573;
            animation: twi-online-pulse 2s infinite;
        }
        .twi-online-dot.idle {
            background: var(--twi-gold);
            box-shadow: 0 0 5px var(--twi-gold);
        }
        .twi-online-dot.offline { background: #444c56; }
        @keyframes twi-online-pulse {
            0%, 100% { box-shadow: 0 0 4px #2ed573; }
            50% { box-shadow: 0 0 10px #2ed573, 0 0 20px rgba(46,213,115,0.4); }
        }

        /* ── Live DOM badge ── */
        .twi-live-badge {
            font-family: var(--twi-mono);
            font-size: 9px;
            letter-spacing: 1px;
            color: var(--twi-accent3);
            background: rgba(46,213,115,0.1);
            border: 1px solid rgba(46,213,115,0.3);
            border-radius: 3px;
            padding: 1px 5px;
        }

        .twi-member-name {
            flex: 1;
            font-size: 15px;
            font-weight: 700;
            color: var(--twi-text);
            text-decoration: none;
            cursor: default;
        }

        .twi-member-level {
            font-family: var(--twi-mono);
            font-size: 12px;
            color: var(--twi-text-dim);
            padding: 2px 6px;
            background: var(--twi-bg3);
            border-radius: 4px;
            flex-shrink: 0;
        }

        .twi-member-score {
            font-family: var(--twi-mono);
            font-size: 12px;
            color: var(--twi-gold);
            flex-shrink: 0;
        }

        .twi-battle-inline {
            font-family: var(--twi-mono);
            font-size: 13px;
            color: var(--twi-accent);
            background: rgba(0,212,255,0.08);
            border: 1px solid rgba(0,212,255,0.2);
            border-radius: 4px;
            padding: 2px 6px;
            flex-shrink: 0;
        }
        .twi-battle-inline.unknown {
            color: var(--twi-text-dim);
            background: transparent;
            border-color: var(--twi-border);
        }

        .twi-action-btns {
            display: flex;
            gap: 4px;
            flex-shrink: 0;
        }
        .twi-action-btn {
            font-family: var(--twi-mono);
            font-size: 12px;
            font-weight: 700;
            letter-spacing: 0.5px;
            padding: 3px 7px;
            border-radius: 4px;
            cursor: pointer;
            border: 1px solid;
            transition: all 0.15s;
            text-decoration: none;
            display: inline-flex;
            align-items: center;
            gap: 3px;
            white-space: nowrap;
        }
        .twi-action-btn.profile {
            border-color: var(--twi-border);
            color: var(--twi-text-dim);
            background: transparent;
        }
        .twi-action-btn.profile:hover {
            border-color: var(--twi-accent);
            color: var(--twi-accent);
        }
        .twi-action-btn.attack {
            border-color: var(--twi-accent2);
            color: var(--twi-accent2);
            background: rgba(255,71,87,0.08);
        }
        .twi-action-btn.attack:hover {
            background: rgba(255,71,87,0.22);
        }

        .twi-status-badge {
            font-size: 12px;
            font-weight: 700;
            letter-spacing: 0.5px;
            padding: 2px 7px;
            border-radius: 4px;
        }
        .twi-status-badge.okay { background: rgba(46,213,115,0.15); color: var(--twi-accent3); }
        .twi-status-badge.hospital { background: rgba(255,71,87,0.15); color: var(--twi-accent2); }
        .twi-status-badge.abroad { background: rgba(255,165,2,0.15); color: var(--twi-gold); }
        .twi-status-badge.traveling { background: rgba(255,165,2,0.15); color: var(--twi-gold); }

        .twi-expand-icon {
            font-size: 10px;
            color: var(--twi-text-dim);
            transition: transform 0.2s;
        }
        .twi-member-card.expanded .twi-expand-icon { transform: rotate(180deg); }

        /* ── Stats Body ── */
        .twi-stats-body {
            display: none;
            padding: 0 10px 10px;
            border-top: 1px solid var(--twi-border);
        }
        .twi-member-card.expanded .twi-stats-body { display: block; }

        .twi-stats-grid {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 5px;
            margin-top: 8px;
        }
        .twi-stat-box {
            background: var(--twi-bg3);
            border: 1px solid var(--twi-border);
            border-radius: 6px;
            padding: 6px 8px;
            text-align: center;
        }
        .twi-stat-label {
            font-family: var(--twi-mono);
            font-size: 11px;
            letter-spacing: 1px;
            color: var(--twi-text-dim);
            text-transform: uppercase;
            margin-bottom: 3px;
        }
        .twi-stat-value {
            font-family: var(--twi-mono);
            font-size: 15px;
            font-weight: bold;
            color: var(--twi-accent);
        }
        .twi-stat-value.unknown { color: var(--twi-text-dim); font-size: 13px; }

        .twi-stat-source {
            font-size: 9px;
            font-family: var(--twi-mono);
            color: var(--twi-text-dim);
            margin-top: 6px;
            text-align: right;
            letter-spacing: 0.5px;
        }

        .twi-loading-bar {
            height: 2px;
            background: linear-gradient(90deg, var(--twi-accent), var(--twi-accent2));
            border-radius: 1px;
            margin: 6px 0;
            animation: twi-shimmer 1.5s infinite;
        }
        @keyframes twi-shimmer {
            0% { opacity: 0.4; }
            50% { opacity: 1; }
            100% { opacity: 0.4; }
        }

        /* ── Settings Panel ── */
        #twi-settings-overlay {
            display: none;
            position: fixed;
            top: 0; left: 0;
            width: 100%; height: 100%;
            background: rgba(0,0,0,0.7);
            z-index: 100000;
            backdrop-filter: blur(4px);
            align-items: center;
            justify-content: center;
        }
        #twi-settings-overlay.open { display: flex; }

        #twi-settings-box {
            background: var(--twi-bg);
            border: 1px solid var(--twi-border);
            border-radius: 12px;
            width: 440px;
            max-height: 85vh;
            overflow-y: auto;
            box-shadow: 0 20px 60px rgba(0,0,0,0.8);
            font-family: var(--twi-font);
            color: var(--twi-text);
        }
        #twi-settings-box::-webkit-scrollbar { width: 4px; }
        #twi-settings-box::-webkit-scrollbar-thumb { background: var(--twi-border); border-radius: 2px; }

        .twi-settings-header {
            background: var(--twi-bg2);
            border-bottom: 1px solid var(--twi-border);
            padding: 16px 20px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            border-radius: 12px 12px 0 0;
            position: sticky;
            top: 0;
            z-index: 1;
        }
        .twi-settings-header h2 {
            margin: 0;
            font-family: var(--twi-mono);
            font-size: 14px;
            letter-spacing: 2px;
            color: var(--twi-accent);
        }

        .twi-settings-body { padding: 16px 20px; }

        .twi-api-card {
            background: var(--twi-bg2);
            border: 1px solid var(--twi-border);
            border-radius: 8px;
            padding: 12px 14px;
            margin-bottom: 10px;
            transition: border-color 0.2s;
        }
        .twi-api-card:focus-within { border-color: var(--twi-accent); }
        .twi-api-card-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: 8px;
        }
        .twi-api-card-title {
            font-size: 13px;
            font-weight: 700;
            letter-spacing: 0.5px;
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .twi-api-dot {
            width: 7px;
            height: 7px;
            border-radius: 50%;
            background: var(--twi-text-dim);
        }
        .twi-api-dot.configured { background: var(--twi-accent3); box-shadow: 0 0 6px var(--twi-accent3); }

        .twi-toggle-switch {
            position: relative;
            width: 36px;
            height: 20px;
            flex-shrink: 0;
        }
        .twi-toggle-switch input { display: none; }
        .twi-toggle-switch .slider {
            position: absolute;
            inset: 0;
            background: var(--twi-bg3);
            border: 1px solid var(--twi-border);
            border-radius: 20px;
            cursor: pointer;
            transition: all 0.2s;
        }
        .twi-toggle-switch .slider:before {
            content: '';
            position: absolute;
            width: 14px;
            height: 14px;
            left: 2px;
            top: 2px;
            background: var(--twi-text-dim);
            border-radius: 50%;
            transition: all 0.2s;
        }
        .twi-toggle-switch input:checked + .slider { background: rgba(0,212,255,0.2); border-color: var(--twi-accent); }
        .twi-toggle-switch input:checked + .slider:before { transform: translateX(16px); background: var(--twi-accent); }

        .twi-input-field {
            width: 100%;
            box-sizing: border-box;
            background: var(--twi-bg3);
            border: 1px solid var(--twi-border);
            border-radius: 6px;
            color: var(--twi-text);
            font-family: var(--twi-mono);
            font-size: 12px;
            padding: 7px 10px;
            transition: border-color 0.2s;
        }
        .twi-input-field:focus { outline: none; border-color: var(--twi-accent); }
        .twi-input-field::placeholder { color: var(--twi-text-dim); }
        .twi-input-label {
            font-size: 10px;
            font-family: var(--twi-mono);
            color: var(--twi-text-dim);
            letter-spacing: 1px;
            margin-bottom: 4px;
            display: block;
        }

        /* ── Status Summary Bar ── */
        #twi-summary {
            display: flex;
            gap: 6px;
            padding: 8px 12px;
            background: var(--twi-bg2);
            border-bottom: 1px solid var(--twi-border);
            flex-shrink: 0;
        }
        .twi-summary-pill {
            flex: 1;
            text-align: center;
            padding: 4px 6px;
            border-radius: 5px;
            font-family: var(--twi-mono);
            font-size: 11px;
        }
        .twi-summary-pill .count { font-size: 16px; font-weight: bold; display: block; line-height: 1.2; }
        .twi-summary-pill.okay { background: rgba(46,213,115,0.1); color: var(--twi-accent3); border: 1px solid rgba(46,213,115,0.3); }
        .twi-summary-pill.hospital { background: rgba(255,71,87,0.1); color: var(--twi-accent2); border: 1px solid rgba(255,71,87,0.3); }
        .twi-summary-pill.away { background: rgba(255,165,2,0.1); color: var(--twi-gold); border: 1px solid rgba(255,165,2,0.3); }
        .twi-summary-pill.unknown { background: rgba(87,101,116,0.1); color: var(--twi-text-dim); border: 1px solid rgba(87,101,116,0.3); }

        /* ── Toast ── */
        #twi-toast {
            position: fixed;
            bottom: 30px;
            right: 30px;
            z-index: 100001;
            display: flex;
            flex-direction: column;
            gap: 8px;
        }
        .twi-toast-item {
            background: var(--twi-bg2);
            border: 1px solid var(--twi-border);
            border-radius: 8px;
            padding: 10px 16px;
            font-family: var(--twi-font);
            font-size: 13px;
            color: var(--twi-text);
            animation: twi-toast-in 0.3s ease;
            min-width: 200px;
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .twi-toast-item.success { border-color: var(--twi-accent3); }
        .twi-toast-item.error { border-color: var(--twi-accent2); }
        .twi-toast-item.info { border-color: var(--twi-accent); }
        @keyframes twi-toast-in {
            from { opacity: 0; transform: translateX(20px); }
            to { opacity: 1; transform: translateX(0); }
        }
        @keyframes twi-pulse {
            0%, 100% { opacity: 0.4; transform: scale(1); }
            50% { opacity: 1; transform: scale(1.3); }
        }

        /* ── Empty State ── */
        .twi-empty {
            text-align: center;
            padding: 30px 20px;
            color: var(--twi-text-dim);
            font-family: var(--twi-mono);
            font-size: 12px;
            letter-spacing: 1px;
        }
        .twi-empty .twi-empty-icon { font-size: 32px; margin-bottom: 8px; }

        /* ── Refresh indicator ── */
        .twi-refreshing { animation: twi-spin 1s linear infinite; display: inline-block; }
        @keyframes twi-spin { to { transform: rotate(360deg); } }
        `;
        document.head.appendChild(style);
    }

    // ─────────────────────────────────────────────
    // UI BUILDER
    // ─────────────────────────────────────────────
    function buildUI() {
        // Toast container
        const toast = document.createElement('div');
        toast.id = 'twi-toast';
        document.body.appendChild(toast);

        // Toggle button
        const toggle = document.createElement('div');
        toggle.id = 'twi-toggle';
        toggle.innerHTML = `<div class="twi-pulse"></div>WAR INTEL`;
        toggle.title = 'Toggle War Intel Panel';
        toggle.addEventListener('click', togglePanel);
        document.body.appendChild(toggle);

        // Main panel
        const panel = document.createElement('div');
        panel.id = 'twi-panel';
        panel.innerHTML = `
            <div id="twi-header">
                <div class="twi-logo">WAR<span>INTEL</span> <span style="font-size:9px;color:var(--twi-text-dim)">v${VERSION} by Pentax</span> <span class="twi-live-badge">● LIVE</span></div>
                <div id="twi-header-btns">
                    <button class="twi-icon-btn" id="twi-refresh-members-btn" title="Refresh Members">↻</button>
                    <button class="twi-icon-btn" id="twi-refresh-stats-btn" title="Refresh Stats from APIs" style="font-size:11px;">📊</button>
                    <button class="twi-icon-btn" id="twi-settings-btn" title="Settings">⚙</button>
                    <button class="twi-icon-btn" id="twi-close-btn" title="Close">✕</button>
                </div>
            </div>
            <div id="twi-tabs">
                <div class="twi-tab active" data-tab="members">Members</div>
                <div class="twi-tab" data-tab="filters">Filters</div>
                <div class="twi-tab" data-tab="cache">Cache</div>
            </div>
            <div id="twi-summary">
                <div class="twi-summary-pill okay"><span class="count" id="twi-count-okay">0</span>Okay</div>
                <div class="twi-summary-pill hospital"><span class="count" id="twi-count-hosp">0</span>Hospital</div>
                <div class="twi-summary-pill away"><span class="count" id="twi-count-away">0</span>Away</div>
                <div class="twi-summary-pill unknown"><span class="count" id="twi-count-unk">0</span>Unknown</div>
            </div>
            <div id="twi-content">
                <!-- Members Tab -->
                <div class="twi-tab-panel active" id="tab-members">
                    <div id="twi-sort-sticky">
                        <div class="twi-section-title" style="margin-bottom:6px;">Sort by</div>
                        <div id="twi-sort-bar">
                            <button class="twi-sort-btn active" data-sort="default">Default</button>
                            <button class="twi-sort-btn" data-sort="okay">🟢 Okay</button>
                            <button class="twi-sort-btn" data-sort="hospital">🔴 Hospital</button>
                            <button class="twi-sort-btn" data-sort="abroad">🟡 Abroad</button>
                            <button class="twi-sort-btn" data-sort="traveling">✈ Traveling</button>
                            <button class="twi-sort-btn" data-sort="score">Score ↓</button>
                            <button class="twi-sort-btn" data-sort="battle">Battle ↓</button>
                        </div>
                        <div class="twi-section-title" style="margin-top:8px;margin-bottom:4px;">Enemy Members</div>
                    </div>
                    <div id="twi-member-list">
                        <div class="twi-empty"><div class="twi-empty-icon">⚔</div>Navigate to a faction war page to load members</div>
                    </div>
                </div>

                <!-- Filters Tab -->
                <div class="twi-tab-panel" id="tab-filters">
                    <div class="twi-section-title">Battle Stats Range</div>
                    <div class="twi-filter-grid">
                        <div class="twi-filter-group">
                            <label>MIN BATTLE</label>
                            <input type="text" inputmode="numeric" id="f-min-battle" placeholder="e.g. 1,000,000">
                        </div>
                        <div class="twi-filter-group">
                            <label>MAX BATTLE</label>
                            <input type="text" inputmode="numeric" id="f-max-battle" placeholder="e.g. 50,000,000">
                        </div>
                    </div>
                    <div class="twi-section-title">Individual Stats</div>
                    <div class="twi-filter-grid">
                        <div class="twi-filter-group">
                            <label>MIN STR</label>
                            <input type="text" inputmode="numeric" id="f-min-str" placeholder="e.g. 500,000">
                        </div>
                        <div class="twi-filter-group">
                            <label>MAX STR</label>
                            <input type="text" inputmode="numeric" id="f-max-str" placeholder="e.g. 10,000,000">
                        </div>
                        <div class="twi-filter-group">
                            <label>MIN SPD</label>
                            <input type="text" inputmode="numeric" id="f-min-spd" placeholder="e.g. 500,000">
                        </div>
                        <div class="twi-filter-group">
                            <label>MAX SPD</label>
                            <input type="text" inputmode="numeric" id="f-max-spd" placeholder="e.g. 10,000,000">
                        </div>
                        <div class="twi-filter-group">
                            <label>MIN DEF</label>
                            <input type="text" inputmode="numeric" id="f-min-def" placeholder="e.g. 500,000">
                        </div>
                        <div class="twi-filter-group">
                            <label>MAX DEF</label>
                            <input type="text" inputmode="numeric" id="f-max-def" placeholder="e.g. 10,000,000">
                        </div>
                        <div class="twi-filter-group">
                            <label>MIN DEX</label>
                            <input type="text" inputmode="numeric" id="f-min-dex" placeholder="e.g. 500,000">
                        </div>
                        <div class="twi-filter-group">
                            <label>MAX DEX</label>
                            <input type="text" inputmode="numeric" id="f-max-dex" placeholder="e.g. 10,000,000">
                        </div>
                    </div>
                    <label class="twi-checkbox-row">
                        <input type="checkbox" id="f-hide-unknown">
                        <span>Hide members with unknown stats</span>
                    </label>
                    <div class="twi-btn-row">
                        <button class="twi-btn" id="apply-filters-btn">Apply Filters</button>
                        <button class="twi-btn danger" id="reset-filters-btn">Reset</button>
                    </div>
                </div>

                <!-- Cache Tab -->
                <div class="twi-tab-panel" id="tab-cache">
                    <div class="twi-section-title">Cache Management</div>
                    <div id="twi-cache-info" style="font-family:var(--twi-mono);font-size:12px;color:var(--twi-text-dim);margin-bottom:10px;line-height:1.8;"></div>
                    <div class="twi-btn-row">
                        <button class="twi-btn success" id="force-refresh-btn">Force Refresh All</button>
                        <button class="twi-btn danger" id="clear-cache-btn">Clear Cache</button>
                    </div>
                    <div class="twi-section-title" style="margin-top:16px;">Cached Entries</div>
                    <div id="twi-cache-list" style="font-family:var(--twi-mono);font-size:11px;color:var(--twi-text-dim);"></div>
                </div>
            </div>
        `;
        document.body.appendChild(panel);

        // Settings overlay
        const settingsOverlay = document.createElement('div');
        settingsOverlay.id = 'twi-settings-overlay';
        settingsOverlay.innerHTML = `
            <div id="twi-settings-box">
                <div class="twi-settings-header">
                    <h2>⚙ SETTINGS</h2>
                    <button class="twi-icon-btn" id="close-settings-btn">✕</button>
                </div>
                <div class="twi-settings-body">
                    <div class="twi-section-title">API Keys</div>

                    <div class="twi-api-card" id="card-torn">
                        <div class="twi-api-card-header">
                            <div class="twi-api-card-title">
                                <div class="twi-api-dot" id="dot-torn"></div>
                                Torn API
                            </div>
                        </div>
                        <label class="twi-input-label">API KEY (Full or Limited)</label>
                        <input class="twi-input-field" type="password" id="input-torn" placeholder="16-character key…">
                    </div>

                    <div class="twi-api-card" id="card-yata">
                        <div class="twi-api-card-header">
                            <div class="twi-api-card-title">
                                <div class="twi-api-dot" id="dot-yata"></div>
                                YATA
                            </div>
                            <label class="twi-toggle-switch">
                                <input type="checkbox" id="toggle-yata">
                                <div class="slider"></div>
                            </label>
                        </div>
                        <label class="twi-input-label">YATA API KEY</label>
                        <input class="twi-input-field" type="password" id="input-yata" placeholder="YATA key…">
                    </div>

                    <div class="twi-api-card" id="card-tornstats">
                        <div class="twi-api-card-header">
                            <div class="twi-api-card-title">
                                <div class="twi-api-dot" id="dot-tornstats"></div>
                                TornStats
                            </div>
                            <label class="twi-toggle-switch">
                                <input type="checkbox" id="toggle-tornstats">
                                <div class="slider"></div>
                            </label>
                        </div>
                        <label class="twi-input-label">TORNSTATS API KEY</label>
                        <input class="twi-input-field" type="password" id="input-tornstats" placeholder="TornStats key…">
                    </div>

                    <div class="twi-api-card" id="card-ffscouter">
                        <div class="twi-api-card-header">
                            <div class="twi-api-card-title">
                                <div class="twi-api-dot" id="dot-ffscouter"></div>
                                FFScouter
                            </div>
                            <label class="twi-toggle-switch">
                                <input type="checkbox" id="toggle-ffscouter">
                                <div class="slider"></div>
                            </label>
                        </div>
                        <label class="twi-input-label">FFSCOUTER API KEY</label>
                        <input class="twi-input-field" type="password" id="input-ffscouter" placeholder="FFScouter key…">
                    </div>

                    <div class="twi-section-title">API ToS Disclosure</div>
                    <div style="font-size:11px;font-family:var(--twi-mono);color:var(--twi-text-dim);line-height:1.7;margin-bottom:14px;">
                        Data Storage: Only locally (localStorage)<br>
                        Data Sharing: Nobody<br>
                        Purpose: Personal war intelligence tool<br>
                        Key Storage: Stored locally, never shared<br>
                        Key Access: Minimal / Limited
                    </div>

                    <div class="twi-btn-row">
                        <button class="twi-btn success" id="save-settings-btn">Save Settings</button>
                        <button class="twi-btn" id="cancel-settings-btn">Cancel</button>
                    </div>
                </div>
            </div>
        `;
        document.body.appendChild(settingsOverlay);

        bindEvents();
        populateSettingsForm();
        bindFilterFormatting();
    }

    // ─────────────────────────────────────────────
    // EVENT BINDING
    // ─────────────────────────────────────────────
    function bindEvents() {
        // Panel controls
        document.getElementById('twi-close-btn').addEventListener('click', togglePanel);
        document.getElementById('twi-settings-btn').addEventListener('click', openSettings);
        document.getElementById('twi-refresh-members-btn').addEventListener('click', () => {
            if (isOnCooldown('members')) return;
            startCooldown('members', 'twi-refresh-members-btn');
            reloadMembers();
        });
        document.getElementById('twi-refresh-stats-btn').addEventListener('click', () => {
            if (isOnCooldown('stats')) return;
            startCooldown('stats', 'twi-refresh-stats-btn');
            refreshStats(true);
        });

        // Tabs
        document.querySelectorAll('.twi-tab').forEach(tab => {
            tab.addEventListener('click', () => {
                document.querySelectorAll('.twi-tab').forEach(t => t.classList.remove('active'));
                document.querySelectorAll('.twi-tab-panel').forEach(p => p.classList.remove('active'));
                tab.classList.add('active');
                document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
                if (tab.dataset.tab === 'cache') renderCacheTab();
            });
        });

        // Sort buttons
        document.querySelectorAll('.twi-sort-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                document.querySelectorAll('.twi-sort-btn').forEach(b => b.classList.remove('active'));
                btn.classList.add('active');
                sortMode = btn.dataset.sort;
                renderMemberList();
            });
        });

        // Filter controls
        document.getElementById('apply-filters-btn').addEventListener('click', applyFilters);
        document.getElementById('reset-filters-btn').addEventListener('click', resetFilters);

        // Cache controls
        document.getElementById('force-refresh-btn').addEventListener('click', () => reloadMembers());
        document.getElementById('clear-cache-btn').addEventListener('click', () => {
            clearCache();
            renderCacheTab();
        });

        // Settings controls
        document.getElementById('close-settings-btn').addEventListener('click', closeSettings);
        document.getElementById('cancel-settings-btn').addEventListener('click', closeSettings);
        document.getElementById('save-settings-btn').addEventListener('click', saveSettingsForm);

        // Close overlay on backdrop click
        document.getElementById('twi-settings-overlay').addEventListener('click', (e) => {
            if (e.target === document.getElementById('twi-settings-overlay')) closeSettings();
        });
    }

    // ─────────────────────────────────────────────
    // PANEL TOGGLE
    // ─────────────────────────────────────────────
    async function togglePanel() {
        panelOpen = !panelOpen;
        document.getElementById('twi-panel').classList.toggle('open', panelOpen);
        if (panelOpen) {
            await loadMembers();
        } else {
            stopLiveObserver();
            stopHospitalCountdowns();
        }
    }

    // ── Refresh Members: re-fetch member status from Torn API, preserve cached stats ──
    async function reloadMembers() {
        stopLiveObserver();


        const container = document.getElementById('twi-member-list');
        if (container) {
            container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon" style="animation:twi-spin 1s linear infinite;display:inline-block">&#8635;</div><br>Refreshing members&hellip;</div>`;
        }

        const freshMembers = await parseEnemyMembers();
        if (freshMembers.length === 0) {
            showToast('Could not fetch members — check API key and war page', 'error');
            if (container) container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon">&#9876;</div>Refresh failed. Check Settings &#9881;</div>`;
            return;
        }

        // Merge fresh status fields onto existing members — preserve cached battle stats
        const freshMap = Object.fromEntries(freshMembers.map(m => [m.xid, m]));
        if (currentMembers.length > 0) {
            currentMembers.forEach(m => {
                const fresh = freshMap[m.xid];
                if (!fresh) return;
                m.status     = fresh.status;
                m.apiOnline  = fresh.apiOnline;
                m.lastAction = fresh.lastAction;
                m.level      = fresh.level;
            });
            // Add brand-new members who joined mid-war
            freshMembers.forEach(fm => {
                if (!currentMembers.find(m => m.xid === fm.xid)) currentMembers.push(fm);
            });
        } else {
            currentMembers = freshMembers;
        }

        // Reset memberState so live observer picks up fresh status transitions
        for (const xid of Object.keys(memberState)) {
            memberState[xid].domStatus = null; // force re-detect on next DOM scrape
        }

        renderMemberList();
        startLiveObserver();
        showToast(`Members refreshed — ${freshMembers.length} members`, 'success');
    }

    async function loadMembers() {
        const container = document.getElementById('twi-member-list');
        if (container) {
            container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon" style="animation:twi-spin 1s linear infinite;display:inline-block">&#8635;</div><br>Loading members&hellip;</div>`;
        }
        const members = await parseEnemyMembers();
        if (members.length > 0) {
            renderMemberList(members);
            refreshStats(false);
            // Start live observer — syncs DOM status + handles hospital transitions
            startLiveObserver();
        } else {
            if (container) {
                container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon">&#9876;</div>No enemy members found.<br><br><b style="color:var(--twi-accent)">Tips:</b><br>&bull; Add your Torn API key in Settings &#9881;<br>&bull; Make sure you're on the #/war/rank tab<br>&bull; Try the Refresh &#8635; button<br><br><span style="font-size:10px;color:var(--twi-text-dim)">v${VERSION}</span></div>`;
            }
        }
    }

    // ─────────────────────────────────────────────
    // SETTINGS
    // ─────────────────────────────────────────────
    function openSettings() {
        populateSettingsForm();
        document.getElementById('twi-settings-overlay').classList.add('open');
    }
    function closeSettings() {
        document.getElementById('twi-settings-overlay').classList.remove('open');
    }

    function populateSettingsForm() {
        document.getElementById('input-torn').value = settings.tornKey || '';
        document.getElementById('input-yata').value = settings.yataKey || '';
        document.getElementById('input-tornstats').value = settings.tornstatsKey || '';
        document.getElementById('input-ffscouter').value = settings.ffscouterKey || '';
        document.getElementById('toggle-yata').checked = settings.enableYata;
        document.getElementById('toggle-tornstats').checked = settings.enableTornStats;
        document.getElementById('toggle-ffscouter').checked = settings.enableFFScouter;
        updateApiDots();
    }

    function updateApiDots() {
        const apis = { torn: settings.tornKey, yata: settings.yataKey, tornstats: settings.tornstatsKey, ffscouter: settings.ffscouterKey };
        for (const [name, key] of Object.entries(apis)) {
            const dot = document.getElementById('dot-' + name);
            if (dot) dot.classList.toggle('configured', !!key);
        }
    }

    function saveSettingsForm() {
        settings.tornKey = document.getElementById('input-torn').value.trim();
        settings.yataKey = document.getElementById('input-yata').value.trim();
        settings.tornstatsKey = document.getElementById('input-tornstats').value.trim();
        settings.ffscouterKey = document.getElementById('input-ffscouter').value.trim();
        settings.enableYata = document.getElementById('toggle-yata').checked;
        settings.enableTornStats = document.getElementById('toggle-tornstats').checked;
        settings.enableFFScouter = document.getElementById('toggle-ffscouter').checked;
        saveSettings();
        updateApiDots();
        closeSettings();
        showToast('Settings saved!', 'success');
    }

    // ─────────────────────────────────────────────
    // DOM PARSER - Extract enemy members from page
    // ─────────────────────────────────────────────

    // ─────────────────────────────────────────────
    // LIVE DOM STATUS SCRAPER
    // Reads status, hospital timer, and online state directly
    // from Torn's rendered React DOM — always real-time, no API lag
    // ─────────────────────────────────────────────

    // ─────────────────────────────────────────────
    // LIVE STATUS SYSTEM
    //
    // Flow:
    //  1. DOM MutationObserver watches li[class*="enemy"] rows on the Torn page
    //  2. On every DOM change it scrapes: online status + member status (okay/hospital/etc.)
    //  3. If DOM says "Hospital"  → show hospital badge + start API countdown timer
    //  4. If DOM says "Okay"      → show okay badge, hide timer
    //  5. When API timer hits 0   → trust DOM to flip it back to Okay naturally
    //
    // Online dot (online/idle/offline) is ALWAYS from DOM — API can't provide this reliably.
    // Hospital timer countdown uses API's status.until Unix timestamp (already fetched).
    // ─────────────────────────────────────────────


    // ─────────────────────────────────────────────
    // BUTTON COOLDOWN SYSTEM
    // Torn ToS hard cap: 100 req/min (can drop to 50 without notice).
    // ↻ Members: 60s cooldown — 3 Torn API calls (user, rankedwars, faction members)
    // 📊 Stats:  30s cooldown — 3rd-party APIs only (TornStats/YATA/FFS)
    //                           May call Torn API if member list is empty.
    // Buttons show a live countdown and cannot be clicked during cooldown.
    // ─────────────────────────────────────────────

    const BTN_COOLDOWNS = { members: 60, stats: 30 };
    const _cdTimers  = {};
    const _cdExpiry  = {};

    function startCooldown(key, btnId) {
        const btn = document.getElementById(btnId);
        if (!btn) return;
        if (_cdTimers[key]) clearInterval(_cdTimers[key]);
        _cdExpiry[key] = Date.now() + BTN_COOLDOWNS[key] * 1000;
        btn.dataset.origContent = btn.innerHTML;
        btn.dataset.origTitle   = btn.title;
        btn.classList.add('cooldown');
        function tick() {
            const rem = Math.ceil((_cdExpiry[key] - Date.now()) / 1000);
            if (rem <= 0) {
                clearInterval(_cdTimers[key]);
                _cdTimers[key] = null;
                btn.classList.remove('cooldown');
                btn.innerHTML = btn.dataset.origContent;
                btn.title     = btn.dataset.origTitle;
                return;
            }
            btn.innerHTML = `${rem}s`;
            btn.title = `Available in ${rem}s`;
        }
        tick();
        _cdTimers[key] = setInterval(tick, 1000);
    }

    function isOnCooldown(key) {
        return !!(_cdExpiry[key] && Date.now() < _cdExpiry[key]);
    }

    // liveStatusMap[xid] = { online, domStatus }
    // ─────────────────────────────────────────────────────────────────
    // LIVE STATUS SYSTEM
    //
    // DOM-only. Zero API calls during monitoring.
    // MutationObserver watches Torn's React war table and reads:
    //   - Member status (okay/hospital/traveling/abroad/jail) from the status cell
    //   - Online/idle/offline from aria-label on the user status wrap
    // Fully ToS compliant — no API calls whatsoever during live monitoring.
    // ─────────────────────────────────────────────────────────────────

    // Per-member state: xid → { domStatus, online }
    const memberState = {};

    // ── 1. DOM SCRAPER — reads Torn's rendered enemy rows ──
    function scrapeEnemyRows() {
        const result = {};
        document.querySelectorAll('li[class*="enemy"]').forEach(li => {
            const link = li.querySelector('a[href*="profiles.php?XID="]');
            if (!link) return;
            const xid = link.href.match(/XID=(\d+)/)?.[1];
            if (!xid) return;

            // Online status from aria-label on the user status wrap
            let online = 'offline';
            const wrap = li.querySelector('[class*="userStatusWrap"]');
            if (wrap) {
                const lbl = (wrap.getAttribute('aria-label') || '').toLowerCase();
                if (lbl.includes('is online')) online = 'online';
                else if (lbl.includes('is idle')) online = 'idle';
            }

            // Game status from the status cell
            let domStatus = 'unknown';
            const cell = li.querySelector('[class*="status___"]') || li.querySelector('.status');
            if (cell) {
                const txt = cell.textContent.trim().toLowerCase();
                if      (txt.includes('okay'))                       domStatus = 'okay';
                else if (txt.includes('hospital'))                   domStatus = 'hospital';
                else if (txt.includes('traveling'))                  domStatus = 'traveling';
                else if (txt.includes('abroad'))                     domStatus = 'abroad';
                else if (txt.includes('jail') || txt.includes('federal')) domStatus = 'jail';
            }

            result[xid] = { online, domStatus };
        });
        return result;
    }

    // ── 2. APPLY DOM STATUS UPDATE to a member's card (lightweight, no re-render) ──
    function applyStatusToCard(xid, domStatus, online) {
        const card = document.querySelector(`.twi-member-card[data-xid="${xid}"]`);
        if (!card) return;

        card.dataset.status = domStatus;

        const dot = card.querySelector('.twi-status-dot');
        if (dot) dot.className = `twi-status-dot ${domStatus}`;

        const badge = card.querySelector('.twi-status-badge');
        if (badge) {
            badge.className = `twi-status-badge ${domStatus}`;
            badge.textContent = capitalize(domStatus);
        }

        const onlineDot = card.querySelector('.twi-online-dot');
        if (onlineDot) {
            onlineDot.className = `twi-online-dot ${online}`;
            onlineDot.title = capitalize(online);
        }
    }

    // ── 3. MAIN SYNC — called by MutationObserver on every DOM change ──
    function syncFromDOM() {
        const scraped = scrapeEnemyRows();
        if (Object.keys(scraped).length === 0) return;

        let countsChanged = false;

        for (const [xid, row] of Object.entries(scraped)) {
            if (!memberState[xid]) {
                memberState[xid] = { domStatus: null, online: 'offline' };
            }
            const ms = memberState[xid];
            const prevStatus = ms.domStatus;

            // Update online dot immediately if changed
            if (ms.online !== row.online) {
                ms.online = row.online;
                const card = document.querySelector(`.twi-member-card[data-xid="${xid}"]`);
                if (card) {
                    const dot = card.querySelector('.twi-online-dot');
                    if (dot) {
                        dot.className = `twi-online-dot ${row.online}`;
                        dot.title = capitalize(row.online);
                    }
                }
            }

            if (prevStatus === row.domStatus) continue; // no status change

            ms.domStatus = row.domStatus;
            applyStatusToCard(xid, row.domStatus, row.online);
            countsChanged = true;
        }

        if (countsChanged) refreshSummaryCounts();
    }

    // ── 4. SUMMARY BAR refresh ──
    // ── 7. SUMMARY BAR refresh ──
    function refreshSummaryCounts() {
        const counts = { okay: 0, hospital: 0, away: 0, unknown: 0 };
        currentMembers.forEach(m => {
            const st = memberState[m.xid]?.domStatus || m.status;
            if      (st === 'okay')                          counts.okay++;
            else if (st === 'hospital')                      counts.hospital++;
            else if (st === 'traveling' || st === 'abroad')  counts.away++;
            else                                             counts.unknown++;
        });
        const $ = id => document.getElementById(id);
        if ($('twi-count-okay')) $('twi-count-okay').textContent = counts.okay;
        if ($('twi-count-hosp')) $('twi-count-hosp').textContent = counts.hospital;
        if ($('twi-count-away')) $('twi-count-away').textContent = counts.away;
        if ($('twi-count-unk'))  $('twi-count-unk').textContent  = counts.unknown;
        applyFiltersToDOM();
    }



    // ── 9. MUTATIONOBSERVER — watches Torn's React war table ──
    let liveObserver = null;

    function startLiveObserver() {
        if (liveObserver) liveObserver.disconnect();

        // Throttle: don't fire more than once per 500ms to avoid excess work
        let throttleTimer = null;
        function onMutation() {
            if (throttleTimer) return;
            throttleTimer = setTimeout(() => { throttleTimer = null; syncFromDOM(); }, 500);
        }

        syncFromDOM(); // initial sync
        liveObserver = new MutationObserver(onMutation);
        const root = document.getElementById('react-root') || document.body;
        liveObserver.observe(root, { childList: true, subtree: true, characterData: false });
    }

    function stopLiveObserver() {
        if (liveObserver) { liveObserver.disconnect(); liveObserver = null; }

    }

    // Legacy stubs so existing call sites don't break
    function startHospitalCountdowns() {} // now handled per-member inside syncFromDOM
    function stopHospitalCountdowns() {}  // handled by stopLiveObserver

    // ─────────────────────────────────────────────
    // TORN API HELPERS
    // ─────────────────────────────────────────────

    // Simple promise wrapper for GM_xmlhttpRequest
    function apiGet(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                onload: (r) => {
                    try {
                        const data = JSON.parse(r.responseText);
                        if (data.error) {
                            const code = data.error.code;
                            if (code === 5 || code === 8) {
                                showToast(`⚠ Torn API rate limit hit (error ${code}). Slow down.`, 'error');
                            }
                            reject(new Error(`Torn API error ${code}: ${data.error.error}`));
                        } else {
                            resolve(data);
                        }
                    } catch(e) { reject(e); }
                },
                onerror: () => reject(new Error('Network error')),
                ontimeout: () => reject(new Error('Request timed out')),
            });
        });
    }

    // Get faction ID from the URL if present (e.g. factions.php?step=profile&ID=39756)
    // Returns null when on step=your (no ID in URL) — that's fine, we handle it below.
    function getMyFactionId() {
        const m = location.href.match(/[?&]ID=(\d+)/);
        return m ? m[1] : null;
    }

    // Get YOUR player ID from the hidden torn-user input Torn embeds on every page
    function getMyPlayerId() {
        try {
            const el = document.getElementById('torn-user');
            if (el) return JSON.parse(el.value).id;
        } catch {}
        return null;
    }

    // ─────────────────────────────────────────────
    // MEMBER LOADING — API ONLY (page is pure React SPA, no HTML members)
    // ─────────────────────────────────────────────
    async function parseEnemyMembers() {
        if (!settings.tornKey) {
            showError('No Torn API key set. Open Settings ⚙ and add your key.');
            return [];
        }

        // ── Step 1: Get your faction ID ──
        // Source A: ID= in the URL  (works on step=profile&ID=39756)
        // Source B: torn-user DOM element Torn embeds on every page (works on step=your)
        // Source C: API call user?selections=profile as last resort
        updateLoadingMsg('Step 1/3: Identifying your faction…');

        let myFid = getMyFactionId(); // Source A — fast, free, works when ID is in URL

        if (!myFid) {
            // Source B: Torn embeds a #twi-torn-user or similar — try the known hidden inputs
            try {
                // Torn stores the current user's faction ID in the page's redux store / hidden inputs.
                // The most reliable DOM source is the faction link in the sidebar.
                // e.g. <a href="/factions.php?step=profile&ID=39756">My Faction</a>
                const factionLink = document.querySelector('a[href*="factions.php?step=profile&ID="]');
                if (factionLink) {
                    const dm = factionLink.href.match(/ID=(\d+)/);
                    if (dm) myFid = dm[1];
                }
            } catch {}
        }

        if (!myFid) {
            // Source C: API call — user?selections=profile always includes faction.faction_id
            try {
                const profileData = await apiGet(
                    `https://api.torn.com/user/?selections=profile&key=${settings.tornKey}`
                );
                // v1 profile nests faction info under a "faction" object
                if (profileData?.faction?.faction_id && profileData.faction.faction_id !== 0) {
                    myFid = String(profileData.faction.faction_id);
                } else if (profileData?.faction_id && profileData.faction_id !== 0) {
                    myFid = String(profileData.faction_id);
                }
                if (!myFid) {
                    showError(`Could not find your faction ID. Are you in a faction? API said: ${JSON.stringify(profileData).slice(0, 200)}`);
                    return [];
                }
            } catch(e) {
                showError(`Step 1 failed — ${e.message}`);
                return [];
            }
        }

        // ── Step 2: Get active ranked war via v2/faction/{id}/rankedwars ──
        updateLoadingMsg('Step 2/3: Fetching active ranked war…');
        let enemyFactionId = null;
        try {
            const rwData = await apiGet(
                `https://api.torn.com/v2/faction/${myFid}/rankedwars?key=${settings.tornKey}`
            );
            // v2 response: { rankedwars: [ { id, factions: [{id, name, score}, ...], end, start, ... } ] }
            const wars = rwData.rankedwars || [];
            for (const war of wars) {
                // Active war has no end timestamp (0 or null)
                if (!war.end || war.end === 0) {
                    const factions = war.factions || [];
                    const enemy = factions.find(f => String(f.id) !== String(myFid));
                    if (enemy) { enemyFactionId = String(enemy.id); break; }
                }
            }
            if (!enemyFactionId) {
                showError(`No active ranked war found. Response: ${JSON.stringify(rwData).slice(0, 200)}`);
                return [];
            }
        } catch(e) {
            showError(`Step 2 failed — rankedwars error: ${e.message}`);
            return [];
        }

        // ── Step 3: Fetch enemy faction members via v2/faction/{id}/members ──
        updateLoadingMsg(`Step 3/3: Fetching members of enemy faction #${enemyFactionId}…`);
        let factionData;
        try {
            factionData = await apiGet(
                `https://api.torn.com/v2/faction/${enemyFactionId}/members?key=${settings.tornKey}`
            );
        } catch(e) {
            showError(`Step 3 failed — members fetch error: ${e.message}`);
            return [];
        }

        // Handle both v2 (array) and v1 (object) member formats
        // v2: { members: [ {id, name, level, status:{state}, ...}, ... ] }
        // v1: { members: { "xid": {name, level, status:{state}, ...}, ... } }
        let rawMembers = factionData.members;

        if (!rawMembers) {
            showError(`No members found. Full response: ${JSON.stringify(factionData).slice(0, 200)}`);
            return [];
        }

        // Normalise to array of [xid, memberObj]
        let memberEntries = [];
        if (Array.isArray(rawMembers)) {
            // v2 array format
            memberEntries = rawMembers.map(m => [String(m.id || m.player_id || '?'), m]);
        } else {
            // v1 object format
            memberEntries = Object.entries(rawMembers);
        }

        updateLoadingMsg(`Building member list (${memberEntries.length} members)…`);

        const members = memberEntries.map(([xid, m]) => {
            let status = 'unknown';
            const st = (m.status?.state || m.status?.description || m.status || '').toLowerCase();
            if (st.includes('okay') || st === 'alive') status = 'okay';
            else if (st.includes('hospital')) status = 'hospital';
            else if (st.includes('travel')) status = 'traveling';
            else if (st.includes('abroad')) status = 'abroad';
            else if (st.includes('federal') || st.includes('jail')) status = 'jail';

            // last_action.status = "Online" | "Idle" | "Offline" (added in Torn API patch #130)
            const apiOnline = (m.last_action?.status || '').toLowerCase();

            return {
                xid: String(xid),
                name: m.name || `#${xid}`,
                level: String(m.level || ''),
                score: '',
                status,
                apiOnline,
                lastAction: m.last_action?.relative || m.lastAction?.relative || '',
            };
        }).filter(m => m.xid !== '?');

        return members;
    }

    function updateLoadingMsg(msg) {
        const container = document.getElementById('twi-member-list');
        if (container) {
            container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon" style="animation:twi-spin 1s linear infinite;display:inline-block">&#8635;</div><br><span style="font-size:11px;color:var(--twi-text)">${msg}</span></div>`;
        }
    }

    function showError(msg) {
        const container = document.getElementById('twi-member-list');
        if (container) {
            container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon">&#9888;</div><span style="color:var(--twi-accent2);font-size:12px;">${msg}</span></div>`;
        }
        showToast(msg.slice(0, 60), 'error');
    }

    // ─────────────────────────────────────────────
    // RENDER MEMBER LIST
    // ─────────────────────────────────────────────
    let currentMembers = [];

    function renderMemberList(members) {
        if (members) currentMembers = members;
        const list = currentMembers;
        const container = document.getElementById('twi-member-list');
        if (!container) return;

        if (!list || list.length === 0) {
            container.innerHTML = `<div class="twi-empty"><div class="twi-empty-icon">⚔</div>No enemy members found.<br>Make sure you're on the war page.</div>`;
            return;
        }

        // Update summary
        const counts = { okay: 0, hospital: 0, away: 0, unknown: 0 };
        list.forEach(m => {
            if (m.status === 'okay') counts.okay++;
            else if (m.status === 'hospital') counts.hospital++;
            else if (m.status === 'abroad' || m.status === 'traveling') counts.away++;
            else counts.unknown++;
        });
        document.getElementById('twi-count-okay').textContent = counts.okay;
        document.getElementById('twi-count-hosp').textContent = counts.hospital;
        document.getElementById('twi-count-away').textContent = counts.away;
        document.getElementById('twi-count-unk').textContent = counts.unknown;

        // Sort
        let sorted = [...list];
        const statusOrder = { okay: 0, hospital: 1, traveling: 2, abroad: 3, unknown: 4 };
        if (sortMode === 'default') {
            // keep original order
        } else if (['okay', 'hospital', 'abroad', 'traveling'].includes(sortMode)) {
            sorted.sort((a, b) => {
                if (a.status === sortMode && b.status !== sortMode) return -1;
                if (b.status === sortMode && a.status !== sortMode) return 1;
                return 0;
            });
        } else if (sortMode === 'score') {
            sorted.sort((a, b) => parseFloat(b.score || 0) - parseFloat(a.score || 0));
        } else if (sortMode === 'battle') {
            sorted.sort((a, b) => {
                const ba = getBattleStats(b.xid);
                const aa = getBattleStats(a.xid);
                return (ba || 0) - (aa || 0);
            });
        }

        container.innerHTML = '';
        sorted.forEach(member => {
            const card = buildMemberCard(member);
            container.appendChild(card);
        });

        applyFiltersToDOM();
    }

    function getBattleStats(xid) {
        const data = cachedStats[xid];
        if (!data) return null;
        const { str, spd, def, dex } = data;
        if (str && spd && def && dex) return str + spd + def + dex;
        return null;
    }

    function buildMemberCard(member) {
        const card = document.createElement('div');
        card.className = 'twi-member-card';
        card.dataset.xid = member.xid;
        card.dataset.status = member.status;

        const data = cachedStats[member.xid] || {};
        const { str, spd, def, dex, source } = data;
        const battleRaw = (str && spd && def && dex) ? (str + spd + def + dex) : null;
        const battleFmt = battleRaw ? formatStat(battleRaw) : null;
        const battleFull = battleRaw ? formatStatFull(battleRaw) : null;

        // Attack URL: torn.com/loader.php?sid=attack&user2ID=XID
        const attackUrl = `https://www.torn.com/loader.php?sid=attack&user2ID=${member.xid}`;
        const profileUrl = `https://www.torn.com/profiles.php?XID=${member.xid}`;

        // Pull live state if already synced from DOM
        const ms = memberState[member.xid];
        const liveOnline = ms?.online || member.apiOnline || 'offline';
        const liveStatus = ms?.domStatus || member.status;

        card.innerHTML = `
            <div class="twi-member-header">
                <div class="twi-online-dot ${liveOnline}" title="${capitalize(liveOnline)}"></div>
                <div class="twi-status-dot ${liveStatus}"></div>
                <span class="twi-member-name">${member.name}</span>
                ${member.level ? `<span class="twi-member-level">Lv${member.level}</span>` : ''}
                <span class="twi-battle-inline ${battleFmt ? '' : 'unknown'}" title="${battleFull ? 'Total: ' + battleFull : 'No stats yet'}">${battleFmt || '? BS'}</span>
                <span class="twi-status-badge ${liveStatus}">${capitalize(liveStatus)}</span>
                <div class="twi-action-btns">
                    <a class="twi-action-btn profile" href="${profileUrl}" target="_blank" title="View Profile">👤</a>
                    <a class="twi-action-btn attack" href="${attackUrl}" target="_blank" title="Attack player">⚔ ATK</a>
                </div>
                <span class="twi-expand-icon">▼</span>
            </div>
            <div class="twi-stats-body">
                ${data.loading ? '<div class="twi-loading-bar"></div>' : ''}
                <div class="twi-stats-grid">
                    <div class="twi-stat-box">
                        <div class="twi-stat-label">BATTLE STATS</div>
                        <div class="twi-stat-value ${battleFmt ? '' : 'unknown'}" title="${battleFull || ''}">${battleFmt || '?'}</div>
                    </div>
                    <div class="twi-stat-box">
                        <div class="twi-stat-label">STR</div>
                        <div class="twi-stat-value ${str ? '' : 'unknown'}" title="${str ? formatStatFull(str) : ''}">${str ? formatStat(str) : '?'}</div>
                    </div>
                    <div class="twi-stat-box">
                        <div class="twi-stat-label">SPD</div>
                        <div class="twi-stat-value ${spd ? '' : 'unknown'}" title="${spd ? formatStatFull(spd) : ''}">${spd ? formatStat(spd) : '?'}</div>
                    </div>
                    <div class="twi-stat-box">
                        <div class="twi-stat-label">DEF</div>
                        <div class="twi-stat-value ${def ? '' : 'unknown'}" title="${def ? formatStatFull(def) : ''}">${def ? formatStat(def) : '?'}</div>
                    </div>
                    <div class="twi-stat-box">
                        <div class="twi-stat-label">DEX</div>
                        <div class="twi-stat-value ${dex ? '' : 'unknown'}" title="${dex ? formatStatFull(dex) : ''}">${dex ? formatStat(dex) : '?'}</div>
                    </div>
                    <div class="twi-stat-box">
                        <div class="twi-stat-label">PLAYER ID</div>
                        <div class="twi-stat-value" style="font-size:11px;color:var(--twi-text-dim)">${member.xid}</div>
                    </div>
                </div>
                ${source ? `<div class="twi-stat-source">Source: ${source}</div>` : ''}
                ${!source && !data.loading ? `<div class="twi-stat-source">No stat data — configure APIs in Settings</div>` : ''}
            </div>
        `;

        // Only expand/collapse on clicking the header area, not buttons
        card.querySelector('.twi-member-header').addEventListener('click', (e) => {
            if (e.target.closest('.twi-action-btns')) return;
            card.classList.toggle('expanded');
        });

        return card;
    }

    function updateMemberCard(xid) {
        const card = document.querySelector(`.twi-member-card[data-xid="${xid}"]`);
        if (!card) return;
        const member = currentMembers.find(m => m.xid === xid);
        if (!member) return;
        const newCard = buildMemberCard(member);
        const wasExpanded = card.classList.contains('expanded');
        if (wasExpanded) newCard.classList.add('expanded');
        card.replaceWith(newCard);
    }

    // ─────────────────────────────────────────────
    // FILTERS
    // ─────────────────────────────────────────────
    // Strip commas and parse shorthand like "1.5m", "500k"
    function parseFilterVal(raw) {
        if (!raw) return '';
        const s = raw.toLowerCase().replace(/,/g, '').trim();
        if (s.endsWith('b')) return String(parseFloat(s) * 1e9);
        if (s.endsWith('m')) return String(parseFloat(s) * 1e6);
        if (s.endsWith('k')) return String(parseFloat(s) * 1e3);
        return s;
    }

    function applyFilters() {
        const ids = ['min-battle','max-battle','min-str','max-str','min-spd','max-spd','min-def','max-def','min-dex','max-dex'];
        const keys = ['minBattle','maxBattle','minStr','maxStr','minSpd','maxSpd','minDef','maxDef','minDex','maxDex'];
        ids.forEach((id, i) => {
            activeFilters[keys[i]] = parseFilterVal(document.getElementById('f-' + id).value);
        });
        activeFilters.hideUnknown = document.getElementById('f-hide-unknown').checked;
        applyFiltersToDOM();
        showToast('Filters applied', 'info');
    }

    function resetFilters() {
        activeFilters = { ...defaultFilters };
        document.getElementById('f-min-battle').value = '';
        document.getElementById('f-max-battle').value = '';
        document.getElementById('f-min-str').value = '';
        document.getElementById('f-max-str').value = '';
        document.getElementById('f-min-spd').value = '';
        document.getElementById('f-max-spd').value = '';
        document.getElementById('f-min-def').value = '';
        document.getElementById('f-max-def').value = '';
        document.getElementById('f-min-dex').value = '';
        document.getElementById('f-max-dex').value = '';
        document.getElementById('f-hide-unknown').checked = false;
        applyFiltersToDOM();
        showToast('Filters reset', 'info');
    }

    function applyFiltersToDOM() {
        const cards = document.querySelectorAll('.twi-member-card');
        cards.forEach(card => {
            const xid = card.dataset.xid;
            const data = cachedStats[xid] || {};
            const { str, spd, def, dex } = data;
            const hasStats = !!(str || spd || def || dex);
            const battle = (str && spd && def && dex) ? str + spd + def + dex : null;

            let hide = false;

            if (activeFilters.hideUnknown && !hasStats) { hide = true; }

            if (!hide && battle !== null) {
                if (activeFilters.minBattle && battle < parseFloat(activeFilters.minBattle)) hide = true;
                if (activeFilters.maxBattle && battle > parseFloat(activeFilters.maxBattle)) hide = true;
            }
            if (!hide && str !== undefined) {
                if (activeFilters.minStr && (str || 0) < parseFloat(activeFilters.minStr)) hide = true;
                if (activeFilters.maxStr && (str || 0) > parseFloat(activeFilters.maxStr)) hide = true;
            }
            if (!hide && spd !== undefined) {
                if (activeFilters.minSpd && (spd || 0) < parseFloat(activeFilters.minSpd)) hide = true;
                if (activeFilters.maxSpd && (spd || 0) > parseFloat(activeFilters.maxSpd)) hide = true;
            }
            if (!hide && def !== undefined) {
                if (activeFilters.minDef && (def || 0) < parseFloat(activeFilters.minDef)) hide = true;
                if (activeFilters.maxDef && (def || 0) > parseFloat(activeFilters.maxDef)) hide = true;
            }
            if (!hide && dex !== undefined) {
                if (activeFilters.minDex && (dex || 0) < parseFloat(activeFilters.minDex)) hide = true;
                if (activeFilters.maxDex && (dex || 0) > parseFloat(activeFilters.maxDex)) hide = true;
            }

            card.classList.toggle('hidden', hide);
        });
    }

    // ─────────────────────────────────────────────
    // CACHE TAB
    // ─────────────────────────────────────────────
    function renderCacheTab() {
        const entries = Object.entries(cachedStats);
        const info = document.getElementById('twi-cache-info');
        const list = document.getElementById('twi-cache-list');
        const size = JSON.stringify(cachedStats).length;

        info.innerHTML = `
            Cached players: <span style="color:var(--twi-accent)">${entries.length}</span><br>
            Storage size: <span style="color:var(--twi-accent)">${(size / 1024).toFixed(1)} KB</span><br>
            Last refresh: <span style="color:var(--twi-accent)">${localStorage.getItem('TWI_last_refresh') || 'Never'}</span>
        `;

        if (entries.length === 0) {
            list.innerHTML = '<div style="color:var(--twi-text-dim);margin-top:8px;">No cached data yet.</div>';
            return;
        }

        list.innerHTML = entries.map(([xid, data]) => {
            const battle = data.str && data.spd && data.def && data.dex
                ? formatStat(data.str + data.spd + data.def + data.dex) : '?';
            return `<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--twi-border)">
                <span style="color:var(--twi-text)">${data.name || xid}</span>
                <span style="color:var(--twi-accent)">${battle} [${data.source || '?'}]</span>
            </div>`;
        }).join('');
    }

    // ─────────────────────────────────────────────
    // API FETCHING
    // ─────────────────────────────────────────────
    async function refreshStats(force = false) {
        const members = currentMembers;
        if (!members || members.length === 0) {
            const parsed = await parseEnemyMembers();
            if (parsed.length > 0) {
                renderMemberList(parsed);
                return refreshStats(force);
            }
            showToast('No enemy members found — add Torn API key in Settings', 'error');
            return;
        }

        const refreshIcon = document.getElementById('twi-refresh-stats-btn');
        if (refreshIcon) refreshIcon.classList.add('twi-refreshing');

        let fetched = 0;
        const toFetch = force ? members : members.filter(m => !cachedStats[m.xid]);

        showToast(`Fetching stats for ${toFetch.length} members…`, 'info');

        for (const member of toFetch) {
            cachedStats[member.xid] = { ...cachedStats[member.xid], loading: true, name: member.name };
            updateMemberCard(member.xid);

            const statData = await fetchAllSources(member.xid);
            cachedStats[member.xid] = { ...statData, name: member.name, loading: false };
            updateMemberCard(member.xid);
            fetched++;
        }

        saveCache();
        localStorage.setItem('TWI_last_refresh', new Date().toLocaleString());
        if (refreshIcon) refreshIcon.classList.remove('twi-refreshing');
        applyFiltersToDOM();
        showToast(`Done! Updated ${fetched} members.`, 'success');
    }

    async function fetchAllSources(xid) {
        const results = {};
        const sources = [];

        // Try all enabled APIs in parallel
        const promises = [];

        if (settings.tornstatsKey && settings.enableTornStats) {
            promises.push(fetchTornStats(xid).then(d => { if (d) { Object.assign(results, d); sources.push('TornStats'); } }));
        }
        if (settings.yataKey && settings.enableYata) {
            promises.push(fetchYata(xid).then(d => { if (d) { mergeStats(results, d); sources.push('YATA'); } }));
        }
        if (settings.ffscouterKey && settings.enableFFScouter) {
            promises.push(fetchFFScouter(xid).then(d => { if (d) { mergeStats(results, d); sources.push('FFS'); } }));
        }

        await Promise.all(promises);

        results.source = sources.join('+') || 'none';
        return results;
    }

    function mergeStats(base, incoming) {
        // Prefer higher values (more accurate from more recent scout)
        for (const key of ['str', 'spd', 'def', 'dex']) {
            if (incoming[key] && (!base[key] || incoming[key] > base[key])) {
                base[key] = incoming[key];
            }
        }
    }

    function fetchTornStats(xid) {
        return new Promise(resolve => {
            if (!settings.tornstatsKey) return resolve(null);
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://www.tornstats.com/api/v2/${settings.tornstatsKey}/spy/user/${xid}`,
                onload: (r) => {
                    try {
                        const data = JSON.parse(r.responseText);
                        // TornStats response: data.spy.strength, data.spy.speed, data.spy.defense, data.spy.dexterity
                        if (data && data.spy) {
                            resolve({
                                str: data.spy.strength,
                                spd: data.spy.speed,
                                def: data.spy.defense,
                                dex: data.spy.dexterity,
                            });
                        } else resolve(null);
                    } catch { resolve(null); }
                },
                onerror: () => resolve(null),
            });
        });
    }

    function fetchYata(xid) {
        return new Promise(resolve => {
            if (!settings.yataKey) return resolve(null);
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://yata.yt/api/v1/users/${xid}/spy/?key=${settings.yataKey}`,
                onload: (r) => {
                    try {
                        const data = JSON.parse(r.responseText);
                        // YATA: data.strength, data.speed, data.defense, data.dexterity
                        if (data && data.strength !== undefined) {
                            resolve({
                                str: data.strength,
                                spd: data.speed,
                                def: data.defense,
                                dex: data.dexterity,
                            });
                        } else resolve(null);
                    } catch { resolve(null); }
                },
                onerror: () => resolve(null),
            });
        });
    }

    function fetchFFScouter(xid) {
        return new Promise(resolve => {
            if (!settings.ffscouterKey) return resolve(null);
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://ffscouter.com/api/v1/stats?torn_user_id=${xid}&key=${settings.ffscouterKey}`,
                onload: (r) => {
                    try {
                        const data = JSON.parse(r.responseText);
                        // FFScouter: data.strength, data.speed, data.defense, data.dexterity
                        if (data && data.strength !== undefined) {
                            resolve({
                                str: data.strength,
                                spd: data.speed,
                                def: data.defense,
                                dex: data.dexterity,
                            });
                        } else resolve(null);
                    } catch { resolve(null); }
                },
                onerror: () => resolve(null),
            });
        });
    }

    // ─────────────────────────────────────────────
    // HELPERS
    // ─────────────────────────────────────────────
    function formatStat(n) {
        if (n === undefined || n === null) return '?';
        if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
        if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
        if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
        return n.toString();
    }

    // Full number with comma punctuation for tooltips and filter hints
    function formatStatFull(n) {
        if (n === undefined || n === null) return '';
        return Math.round(n).toLocaleString();
    }

    // Auto-format filter input with commas as user types
    function bindFilterFormatting() {
        const filterIds = ['f-min-battle','f-max-battle','f-min-str','f-max-str',
                           'f-min-spd','f-max-spd','f-min-def','f-max-def','f-min-dex','f-max-dex'];
        filterIds.forEach(id => {
            const el = document.getElementById(id);
            if (!el) return;
            el.addEventListener('input', () => {
                // Allow digits, commas, k/m/b shorthand, decimal point
                const raw = el.value.replace(/[^0-9.,kmb]/gi, '');
                el.value = raw;
            });
            el.addEventListener('blur', () => {
                // On blur, if it's a plain number, format with commas
                const raw = el.value.replace(/,/g, '').trim();
                const num = parseFloat(raw);
                if (!isNaN(num) && !/[kmb]/i.test(raw)) {
                    el.value = num.toLocaleString();
                }
            });
            el.addEventListener('focus', () => {
                // On focus, strip commas for easy editing
                el.value = el.value.replace(/,/g, '');
            });
        });
    }

    function capitalize(str) {
        return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
    }

    function showToast(message, type = 'info') {
        const container = document.getElementById('twi-toast');
        if (!container) return;
        const item = document.createElement('div');
        const icons = { success: '✓', error: '✕', info: '◈' };
        item.className = `twi-toast-item ${type}`;
        item.innerHTML = `<span>${icons[type] || '•'}</span>${message}`;
        container.appendChild(item);
        setTimeout(() => {
            item.style.opacity = '0';
            item.style.transition = 'opacity 0.3s';
            setTimeout(() => item.remove(), 300);
        }, 3000);
    }

    // ─────────────────────────────────────────────
    // INIT
    // ─────────────────────────────────────────────
    function init() {
        if (!location.href.includes('factions.php')) return;

        // Remove old instance if re-navigated
        ['twi-panel', 'twi-toggle', 'twi-settings-overlay', 'twi-toast'].forEach(id => {
            const el = document.getElementById(id);
            if (el) el.remove();
        });

        injectStyles();
        buildUI();
        observePage();

        // Auto-open panel on war tab — API-based so no need to wait for DOM
        const isWarTab = location.href.includes('war') || location.hash.includes('war');
        if (isWarTab) {
            panelOpen = true;
            const panel = document.getElementById('twi-panel');
            if (panel) panel.classList.add('open');
            setTimeout(() => {
                showToast(`WARINTEL v${VERSION} loaded`, 'info');
                loadMembers();
            }, 500);
        }
    }

    // MutationObserver watches for hash changes (React SPA navigation)
    function observePage() {
        let lastUrl = location.href;
        const observer = new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                setTimeout(init, 1200);
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

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

})();