Torn Faction Command

Faction activity + growth tracking, ranked-war opponent scouting, recruit scouting, and an organized-crime board. Read-only against the Torn API.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn Faction Command
// @namespace    torn-advisor.local
// @version      2.0.1
// @description  Faction activity + growth tracking, ranked-war opponent scouting, recruit scouting, and an organized-crime board. Read-only against the Torn API.
// @author       -Versatility-
// @match        https://www.torn.com/*
// @icon         https://www.torn.com/favicon.ico
// @connect      api.torn.com
// @connect      ffscouter.com
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// @run-at       document-idle
// @noframes
// @license      MIT
// ==/UserScript==

/*
 * WHAT THIS SCRIPT CAN AND CANNOT DO
 *
 * The Torn API is READ-ONLY. There is no endpoint to kick a member and no
 * endpoint to send mail. Anything on this dashboard that looks like an action
 * is really a STAGING step:
 *
 *   Kick    -> opens the faction controls tab and copies the member name, so
 *              you can find them in Torn's own list. You do the kick.
 *   Message -> stores a drafted message, opens Torn's compose page, and fills
 *              the box in for you. YOU press Send. Nothing is ever sent
 *              automatically, and the draft is discarded once it is used.
 *
 * That is deliberate as well as forced: these actions land on real people.
 *
 * FFScouter is a THIRD PARTY (ffscouter.com). Using it sends the player IDs you
 * are scouting, plus your Torn API key, to that site. It is therefore OFF by
 * default and has to be switched on in Settings - matching how dashboard.py in
 * this project already treats it.
 */

(function () {
    'use strict';

    const VERSION = '2.0.0';
    const API_V2 = 'https://api.torn.com/v2';
    const API_V1 = 'https://api.torn.com';
    const FF_API = 'https://ffscouter.com/api/v1';

    const API_KEY_STORE = 'tfa_api_key';
    const HISTORY_STORE = 'tfa_history_v1';
    const TREND_STORE = 'tfa_trend_v1';
    const SETTINGS_STORE = 'tfa_settings_v1';
    const FF_STORE = 'tfa_ff_cache_v1';
    const MAIL_STORE = 'tfa_pending_mail_v1';
    const ITEM_STORE = 'tfa_items_v1';

    const HISTORY_TTL = 6 * 60 * 60 * 1000;
    const FF_TTL = 12 * 60 * 60 * 1000;
    const REQUESTS_PER_MINUTE = 75;
    const WINDOW_SECONDS = 30 * 86400;
    const FF_BATCH = 50;
    const TREND_MAX = 40;

    // Only xantaken and timeplayed are required. Everything else is a bonus
    // signal that some accounts simply do not expose, so a missing one must
    // never fail the row.
    const STAT_FIELDS = [
        'xantaken', 'timeplayed', 'activestreak', 'bestactivestreak',
        'energydrinkused', 'statenhancersused', 'refills',
        'attackswon', 'respectforfaction', 'networth',
    ].join(',');

    const DEFAULT_SETTINGS = {
        ffEnabled: false,
        inactiveDays: 7,
        lowXanaxPerDay: 0.5,
        minActiveHours30d: 5,
        // Recruit "good enough" marks. 0 disables a mark.
        recruitMinXanax: 0,
        recruitMinStats: 0,
        messageTemplate:
            'Hi {name},\n\n' +
            'Quick activity check-in from {faction}.\n\n' +
            'Our tracker shows your last action was {last_action} ({days_inactive} days ago), ' +
            'with {xanax_per_day} Xanax/day and {active_30d} of active time over the last 30 days.\n\n' +
            'Are you still playing? If you need time off just let us know and we will note it. ' +
            'If we do not hear back we may need to free the slot up.\n\n' +
            'Thanks!',
    };

    const requestTimes = [];

    const state = {
        tab: 'members',
        members: [],
        faction: {},
        me: null,
        isOwner: false,
        stats: new Map(),
        ff: new Map(),
        filter: 'all',
        search: '',
        sort: 'activity',
        loading: false,
        scanToken: 0,
        war: { opponentId: '', faction: null, members: [], error: '', loading: false },
        recruits: { input: '', rows: [], error: '', loading: false, onlyMatches: false },
        browse: { mode: 'factions', cat: 'respect', rows: [], offset: 0,
                  loading: false, error: '', freeOnly: false },
        // `loaded` is not redundant with crimes.length: a faction with no crimes
        // in progress legitimately returns an empty array, and without this flag
        // the auto-load in renderTab() would re-fire on every single render.
        oc: { crimes: [], error: '', loading: false, loaded: false,
              mine: null, mineError: '' },
        itemNames: new Map(),
    };

    let host;
    let root;
    let launcher;
    let lastRecruitRender = 0;

    /* ---------------------------------------------------------------- utils */

    const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
    })[char]);

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    const num = (value, fallback = 0) => {
        const parsed = Number(value);
        return Number.isFinite(parsed) ? parsed : fallback;
    };

    function settings() {
        const saved = GM_getValue(SETTINGS_STORE, null);
        const parsed = typeof saved === 'string' ? safeParse(saved) : saved;
        return { ...DEFAULT_SETTINGS, ...(parsed && typeof parsed === 'object' ? parsed : {}) };
    }

    function saveSettings(patch) {
        GM_setValue(SETTINGS_STORE, JSON.stringify({ ...settings(), ...patch }));
    }

    function safeParse(text) {
        try { return JSON.parse(text); } catch (_) { return null; }
    }

    function statusKey(member) {
        const value = String(member?.last_action?.status || 'unknown').toLowerCase();
        return ['online', 'idle', 'offline'].includes(value) ? value : 'other';
    }

    function activeTime(seconds) {
        const total = Math.max(0, Math.round(num(seconds)));
        const hours = Math.floor(total / 3600);
        const minutes = Math.floor((total % 3600) / 60);
        if (hours >= 24) return `${Math.floor(hours / 24).toLocaleString()}d ${hours % 24}h`;
        if (hours) return `${hours}h ${minutes}m`;
        return `${minutes}m`;
    }

    function relativeTime(timestamp) {
        const seconds = Math.max(0, Math.floor(Date.now() / 1000) - num(timestamp));
        if (!num(timestamp)) return 'unknown';
        if (seconds < 60) return 'just now';
        if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
        if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
        return `${Math.floor(seconds / 86400)}d ago`;
    }

    function daysSince(timestamp) {
        if (!num(timestamp)) return null;
        return Math.max(0, Math.floor((Date.now() / 1000 - num(timestamp)) / 86400));
    }

    function untilText(timestamp) {
        const ts = num(timestamp);
        if (!ts) return '—';
        const secs = ts - Math.floor(Date.now() / 1000);
        if (secs <= 0) return 'ready now';
        const days = Math.floor(secs / 86400);
        const hours = Math.floor((secs % 86400) / 3600);
        const mins = Math.floor((secs % 3600) / 60);
        if (days) return `in ${days}d ${hours}h`;
        if (hours) return `in ${hours}h ${mins}m`;
        return `in ${mins}m`;
    }

    function shortNumber(value) {
        const n = num(value);
        if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(2)}b`;
        if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(2)}m`;
        if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
        return String(Math.round(n));
    }

    function copyText(text, button) {
        const value = String(text ?? '');
        try {
            if (typeof GM_setClipboard === 'function') GM_setClipboard(value, 'text');
            else navigator.clipboard?.writeText(value);
        } catch (_) {
            navigator.clipboard?.writeText(value).catch(() => {});
        }
        if (!button) return;
        const original = button.dataset.original || button.textContent;
        button.dataset.original = original;
        button.textContent = 'Copied';
        button.classList.add('copied');
        setTimeout(() => {
            button.textContent = original;
            button.classList.remove('copied');
        }, 1100);
    }

    // Rendered next to any name/ID we show, which is the "quick copy" ask.
    function copyBtn(value, label = 'Copy') {
        return `<button class="mini" data-copy="${escapeHtml(value)}" title="Copy ${escapeHtml(value)}">${escapeHtml(label)}</button>`;
    }

    function bindCopy(scope) {
        scope.querySelectorAll('[data-copy]').forEach((button) => {
            if (button.dataset.copyBound) return;
            button.dataset.copyBound = '1';
            button.addEventListener('click', (event) => {
                event.preventDefault();
                copyText(button.dataset.copy, button);
            });
        });
    }

    /* ------------------------------------------------------------------ api */

    async function waitForApiSlot() {
        while (true) {
            const now = Date.now();
            while (requestTimes.length && now - requestTimes[0] >= 60000) requestTimes.shift();
            if (requestTimes.length < REQUESTS_PER_MINUTE) {
                requestTimes.push(now);
                return;
            }
            await sleep(Math.max(250, 60250 - (now - requestTimes[0])));
        }
    }

    function apiKey() {
        return String(GM_getValue(API_KEY_STORE, '') || '').trim();
    }

    function gmRequest(details) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                ...details,
                anonymous: true,
                timeout: 30000,
                onload: resolve,
                onerror: () => reject(new Error('Network request failed')),
                ontimeout: () => reject(new Error('Request timed out')),
                onabort: () => reject(new Error('Request was cancelled')),
            });
        });
    }

    function parseBody(response) {
        try {
            return response.response && typeof response.response === 'object'
                ? response.response
                : JSON.parse(response.responseText || '{}');
        } catch (_) {
            throw new Error(`Unreadable response (${response.status || 'no status'}).`);
        }
    }

    async function api(path, params = {}) {
        const key = apiKey();
        if (!key) throw new Error('Enter a Torn API key first.');
        await waitForApiSlot();
        const query = new URLSearchParams({ ...params, comment: 'torn-faction-command' });
        const response = await gmRequest({
            method: 'GET',
            url: `${API_V2}/${String(path).replace(/^\/+/, '')}?${query}`,
            headers: { Authorization: `ApiKey ${key}`, Accept: 'application/json' },
        });
        const payload = parseBody(response);
        if (response.status < 200 || response.status >= 300) {
            throw new Error(`Torn API HTTP ${response.status}: ${response.statusText || 'request failed'}`);
        }
        if (payload?.error) {
            throw new Error(`Torn API error ${payload.error.code ?? '?'}: ${payload.error.error || 'Unknown error'}`);
        }
        return payload;
    }

    // v1 is still the cleanest way to pull an ARBITRARY faction's roster in a
    // single request (faction/<id>?selections=basic), which is exactly what war
    // and recruit scouting need.
    async function apiV1(path, selections = [], params = {}) {
        const key = apiKey();
        if (!key) throw new Error('Enter a Torn API key first.');
        await waitForApiSlot();
        const query = new URLSearchParams({
            ...params,
            selections: selections.join(','),
            key,
            comment: 'torn-faction-command',
        });
        const response = await gmRequest({
            method: 'GET',
            url: `${API_V1}/${String(path).replace(/^\/+/, '')}?${query}`,
            headers: { Accept: 'application/json' },
        });
        const payload = parseBody(response);
        if (payload?.error) {
            throw new Error(`Torn API error ${payload.error.code ?? '?'}: ${payload.error.error || 'Unknown error'}`);
        }
        return payload;
    }

    /* ----------------------------------------------------------- ffscouter */

    function ffCache() {
        const cache = safeParse(GM_getValue(FF_STORE, '{}')) || {};
        const now = Date.now();
        for (const id of Object.keys(cache)) {
            if (now - num(cache[id]?.cached_at) > FF_TTL) delete cache[id];
        }
        return cache;
    }

    function saveFfRows(rows) {
        const cache = ffCache();
        const now = Date.now();
        for (const row of rows) {
            if (!row?.player_id) continue;
            cache[String(row.player_id)] = { ...row, cached_at: now };
        }
        GM_setValue(FF_STORE, JSON.stringify(cache));
    }

    /**
     * Batched FFScouter lookup. Mirrors dashboard.py: /get-stats with a comma
     * list of targets, 50 at a time, and the response may be a bare array or
     * {targets:[...]}. Returns a Map keyed by player id.
     */
    async function ffLookup(ids, onProgress) {
        const out = new Map();
        if (!settings().ffEnabled) return out;
        const key = apiKey();
        if (!key) return out;

        const cache = ffCache();
        const missing = [];
        for (const raw of ids) {
            const id = Number(raw);
            if (!Number.isFinite(id) || id <= 0) continue;
            const hit = cache[String(id)];
            if (hit) out.set(id, hit);
            else if (!missing.includes(id)) missing.push(id);
        }

        for (let start = 0; start < missing.length; start += FF_BATCH) {
            const batch = missing.slice(start, start + FF_BATCH);
            try {
                const response = await gmRequest({
                    method: 'GET',
                    url: `${FF_API}/get-stats?key=${encodeURIComponent(key)}&targets=${batch.join(',')}`,
                    headers: { Accept: 'application/json' },
                });
                const payload = parseBody(response);
                const rows = Array.isArray(payload)
                    ? payload
                    : (Array.isArray(payload?.targets) ? payload.targets : []);
                saveFfRows(rows);
                for (const row of rows) {
                    if (row?.player_id) out.set(Number(row.player_id), row);
                }
            } catch (_) {
                // A failed FFScouter batch must never break the Torn-side view.
            }
            if (onProgress) onProgress(Math.min(start + FF_BATCH, missing.length), missing.length);
        }
        return out;
    }

    function ffConfidence(row) {
        if (!row || !num(row.bs_estimate)) return 'unknown';
        const source = String(row.source || 'bss').toLowerCase();
        const age = Math.max(0, Math.floor(Date.now() / 1000) - num(row.last_updated || row.bss_public_timestamp));
        if (['spies', 'premium'].includes(source) && age <= 30 * 86400) return 'high';
        if (age <= 14 * 86400) return 'medium';
        return 'low';
    }

    function ffCell(row) {
        if (!settings().ffEnabled) return '<span class="muted">FF off</span>';
        if (!row) return '<span class="muted">—</span>';
        const estimate = num(row.bs_estimate);
        const fair = num(row.fair_fight);
        if (!estimate && !fair) return '<span class="muted">No data</span>';
        const conf = ffConfidence(row);
        return `<span class="stat-main">${estimate ? shortNumber(estimate) : '—'}</span>`
            + `<div class="muted">${fair ? `FF ${fair.toFixed(2)} · ` : ''}<span class="conf ${conf}">${conf}</span></div>`;
    }

    /* --------------------------------------------------- history and growth */

    function historyCache() {
        const cache = safeParse(GM_getValue(HISTORY_STORE, '{}')) || {};
        return cache && typeof cache === 'object' ? cache : {};
    }

    function saveHistoryRow(row) {
        const cache = historyCache();
        cache[String(row.id)] = { ...row, cached_at: Date.now() };
        GM_setValue(HISTORY_STORE, JSON.stringify(cache));
    }

    function cachedHistoryRow(id) {
        const row = historyCache()[String(id)];
        if (!row) return null;
        return Date.now() - num(row.cached_at) < HISTORY_TTL ? row : null;
    }

    /**
     * A rolling snapshot log, so "growth" can eventually mean a real trend
     * rather than a single 30-day window. It fills in as the dashboard is used;
     * a delta only appears once two snapshots are at least 3 days apart.
     */
    function recordTrend(row) {
        const store = safeParse(GM_getValue(TREND_STORE, '{}')) || {};
        const id = String(row.id);
        const list = Array.isArray(store[id]) ? store[id] : [];
        const last = list[list.length - 1];
        const point = {
            t: Math.floor(Date.now() / 1000),
            xan: num(row.xanax_30d),
            active: num(row.active_seconds_30d),
            streak: num(row.login_streak),
        };
        // One point per 12h is plenty and keeps the store small.
        if (!last || point.t - num(last.t) >= 43200) {
            list.push(point);
            store[id] = list.slice(-TREND_MAX);
            GM_setValue(TREND_STORE, JSON.stringify(store));
        }
    }

    function trendFor(id) {
        const store = safeParse(GM_getValue(TREND_STORE, '{}')) || {};
        const list = Array.isArray(store[String(id)]) ? store[String(id)] : [];
        if (list.length < 2) return null;
        const latest = list[list.length - 1];
        const older = [...list].reverse().find((point) => num(latest.t) - num(point.t) >= 3 * 86400);
        if (!older) return null;
        return {
            days: Math.round((num(latest.t) - num(older.t)) / 86400),
            xanax: num(latest.xan) - num(older.xan),
            active: num(latest.active) - num(older.active),
        };
    }

    function statsByName(payload) {
        const rows = Array.isArray(payload?.personalstats)
            ? payload.personalstats
            : Object.values(payload?.personalstats || {});
        return Object.fromEntries(rows.filter((row) => row?.name).map((row) => [
            String(row.name).toLowerCase(),
            { value: num(row.value), timestamp: num(row.timestamp) },
        ]));
    }

    async function loadMemberHistory(member) {
        const currentPayload = await api(`user/${member.id}/personalstats`, { stat: STAT_FIELDS });
        const current = statsByName(currentPayload);
        const currentTimestamp = Math.max(0, ...Object.values(current).map((item) => item.timestamp));
        if (!currentTimestamp || !current.xantaken || !current.timeplayed) {
            throw new Error('Required public personal stats were not returned.');
        }

        const historicalPayload = await api(`user/${member.id}/personalstats`, {
            stat: 'xantaken,timeplayed,energydrinkused,statenhancersused,attackswon,respectforfaction',
            timestamp: currentTimestamp - WINDOW_SECONDS,
        });
        const historical = statsByName(historicalPayload);
        const historicalTimestamp = Math.max(0, ...Object.values(historical).map((item) => item.timestamp));
        if (!historicalTimestamp || !historical.xantaken || !historical.timeplayed) {
            throw new Error('No 30-day historical snapshot is available.');
        }

        const windowDays = Math.max(1, (currentTimestamp - historicalTimestamp) / 86400);
        const delta = (name) => Math.max(0, num(current[name]?.value) - num(historical[name]?.value));
        const xanax30 = delta('xantaken');
        const active30 = delta('timeplayed');

        const row = {
            id: Number(member.id),
            available: true,
            xanax_30d: xanax30,
            xanax_per_day: Math.round((xanax30 / windowDays) * 100) / 100,
            active_seconds_30d: active30,
            active_hours_per_day: Math.round((active30 / 3600 / windowDays) * 100) / 100,
            energy_30d: delta('energydrinkused'),
            enhancers_30d: delta('statenhancersused'),
            attacks_30d: delta('attackswon'),
            respect_30d: delta('respectforfaction'),
            networth: num(current.networth?.value),
            login_streak: num(current.activestreak?.value),
            best_login_streak: num(current.bestactivestreak?.value),
            window_days: Math.round(windowDays * 10) / 10,
            snapshot_at: currentTimestamp,
        };
        row.growth = growthScore(row);
        return row;
    }

    /**
     * A 0-100 "is this account actually growing" index.
     *
     * Deliberately activity-weighted rather than power-weighted: current battle
     * stats tell you how strong someone is TODAY (that is what FFScouter is
     * for), whereas Xanax burn, hours played and streak tell you whether they
     * are still putting the work in. That is the question both for a member
     * review and for a recruit.
     */
    function growthScore(row) {
        const cap = (value, max) => Math.min(1, Math.max(0, num(value) / max));
        const score =
            40 * cap(row.xanax_per_day, 3) +
            30 * cap(row.active_hours_per_day, 6) +
            20 * cap(row.login_streak, 100) +
            10 * cap(row.enhancers_30d, 10);
        return Math.round(score);
    }

    function growthBadge(score) {
        const value = num(score);
        const tier = value >= 70 ? 'high' : value >= 40 ? 'medium' : 'low';
        return `<span class="growth ${tier}">${value}</span>`;
    }

    /* ------------------------------------------------------------ inactivity */

    function inactivityFlags(member, history) {
        const config = settings();
        const flags = [];
        const idle = daysSince(member?.last_action?.timestamp);
        if (idle !== null && idle >= num(config.inactiveDays)) flags.push(`${idle}d since last action`);
        if (history?.available) {
            if (num(history.xanax_per_day) < num(config.lowXanaxPerDay)) {
                flags.push(`${num(history.xanax_per_day).toFixed(2)} xan/day`);
            }
            if (num(history.active_seconds_30d) / 3600 < num(config.minActiveHours30d)) {
                flags.push(`${(num(history.active_seconds_30d) / 3600).toFixed(1)}h active/30d`);
            }
        }
        return flags;
    }

    function fillTemplate(member, history) {
        const config = settings();
        const idle = daysSince(member?.last_action?.timestamp);
        const map = {
            '{name}': member?.name || 'there',
            '{id}': member?.id ?? '',
            '{faction}': state.faction?.name || 'the faction',
            '{last_action}': member?.last_action?.relative || relativeTime(member?.last_action?.timestamp),
            '{days_inactive}': idle === null ? 'unknown' : String(idle),
            '{xanax_per_day}': history?.available ? num(history.xanax_per_day).toFixed(2) : 'n/a',
            '{active_30d}': history?.available ? activeTime(history.active_seconds_30d) : 'n/a',
            '{streak}': history?.available ? String(num(history.login_streak)) : 'n/a',
            '{position}': member?.position || 'Member',
        };
        return String(config.messageTemplate || '').replace(/\{[a-z_]+\}/g, (token) => (
            Object.prototype.hasOwnProperty.call(map, token) ? map[token] : token
        ));
    }

    /**
     * Stage a message. The API cannot send mail, so the draft is parked in
     * script storage and Torn's own compose page is opened; the handler at the
     * bottom of this file fills the box. The user still presses Send.
     */
    function stageMessage(member, history) {
        const body = fillTemplate(member, history);
        GM_setValue(MAIL_STORE, JSON.stringify({
            id: Number(member.id),
            name: member.name,
            body,
            staged_at: Date.now(),
        }));
        window.open(`https://www.torn.com/messages.php#/p=compose&XID=${Number(member.id)}`, '_blank', 'noopener');
    }

    function stageKick(member, button) {
        copyText(member.name, button);
        window.open('https://www.torn.com/factions.php?step=your&type=1#/tab=controls', '_blank', 'noopener');
    }

    /**
     * Torn's error strings are terse and, for code 7, actively misleading -
     * "Incorrect ID-entity relation" reads like a bad ID when it actually
     * means the key's FACTION POSITION lacks the API Access (AA) permission.
     * Verified 2026-08-26 against a Full Access (level 4) key: /faction/crimes
     * returns 7 for every value of `cat`, including none, while /faction/basic
     * and /faction/rankedwars on the same key work fine.
     */
    function friendlyApiError(error, context) {
        const message = String(error?.message || error || '');
        const code = Number((message.match(/Torn API error (\d+)/) || [])[1] || 0);
        if (code === 7) {
            return `${context}: your faction position (${escapeHtml(state.me?.position || 'unknown')}) does not have `
                + `the "API Access" permission, so Torn will not release this faction data. `
                + `A faction leader grants it per position under Faction → Controls → Permissions. `
                + `Nothing is wrong with your API key — it is Full Access endpoints like faction/basic that already work.`;
        }
        if (code === 6) return `${context}: Torn says that ID does not exist. Double-check the number.`;
        if (code === 2) return `${context}: Torn rejected the API key. Re-enter it under “API key”.`;
        if (code === 5) return `${context}: too many API requests — wait a minute and try again.`;
        if (code === 9) return `${context}: the Torn API is in maintenance mode. Try later.`;
        return `${context}: ${message}`;
    }

    /* --------------------------------------------------------------- styles */

    function addStyles() {
        const style = document.createElement('style');
        style.textContent = `
            :host { all: initial; color-scheme: dark; }
            * { box-sizing: border-box; }
            button, input, select, textarea { font: inherit; }
            .launcher { position: fixed; right: 0; top: 150px; width: 52px; height: 42px; z-index: 2147483646;
                border: 2px solid #d32979; border-right: 0; border-radius: 11px 0 0 11px;
                background: linear-gradient(145deg,#211018,#25102f 58%,#17101f); color: #ff4c9e; padding: 0;
                font: 800 12px/1 "Segoe UI",sans-serif; letter-spacing: .7px;
                box-shadow: -7px 0 22px #a3156955, inset 0 0 12px #9e17552e; cursor: pointer;
                transition: background .16s,border-color .16s,color .16s,box-shadow .16s; }
            .launcher:hover { background: linear-gradient(145deg,#59152f,#4a176b); border-color: #f03b91;
                color: #fff; box-shadow: -8px 0 25px #c31e7b88; }
            .backdrop { position: fixed; inset: 0; z-index: 2147483647; background: #07090dcc; backdrop-filter: blur(5px);
                display: grid; place-items: center; padding: 18px; font: 13px/1.45 "Segoe UI",system-ui,sans-serif; color: #e8ebf3; }
            .hidden { display: none !important; }
            .modal { width: min(1500px, 98vw); height: min(900px, 94vh); overflow: hidden; display: flex; flex-direction: column;
                border: 1px solid #394765; border-radius: 15px; background: #11141b; box-shadow: 0 24px 90px #000d; }
            .header { display: flex; align-items: center; gap: 12px; padding: 14px 17px; border-bottom: 1px solid #2b3140;
                background: linear-gradient(135deg,#202b43,#171b24 62%); }
            .brand { margin-right: auto; }
            .eyebrow { color: #9fbaff; text-transform: uppercase; letter-spacing: 1px; font-size: 9px; font-weight: 800; }
            h1 { margin: 2px 0 0; font-size: 19px; line-height: 1.2; color: #f2f5fb; }
            h2 { margin: 0 0 8px; font-size: 20px; }
            h3 { margin: 0 0 8px; font-size: 14px; color: #cfd7e8; }
            .btn { border: 1px solid #363e50; border-radius: 7px; background: #181d27; color: #dce2ee; padding: 8px 11px; cursor: pointer; }
            .btn:hover { border-color: #638ce4; color: #fff; }
            .btn.primary { background: #315eb8; border-color: #5380dc; color: #fff; }
            .btn.danger { border-color: #703b43; color: #ffc0c6; }
            .btn:disabled { opacity: .5; cursor: not-allowed; }
            .mini { border: 1px solid #39425a; border-radius: 5px; background: #161b25; color: #9fb2d4;
                padding: 1px 6px; font-size: 9px; font-weight: 700; cursor: pointer; margin-left: 5px; vertical-align: middle; }
            .mini:hover { border-color: #5f87d8; color: #fff; }
            .mini.copied { border-color: #3d7a54; color: #8add9f; }
            .mini.warn { border-color: #6c5427; color: #e8ba69; }
            .mini.danger { border-color: #703b43; color: #ffc0c6; }
            .tabs { display: flex; gap: 6px; padding: 10px 14px 0; border-bottom: 1px solid #2b3140; overflow-x: auto; }
            .tab { border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; background: transparent;
                color: #93a0b8; padding: 8px 13px; cursor: pointer; white-space: nowrap; font-weight: 700; }
            .tab:hover { color: #dce4f4; }
            .tab.active { background: #181d27; border-color: #333c4f; color: #fff; }
            .content { flex: 1; overflow: auto; padding: 14px; }
            .setup { max-width: 650px; margin: 40px auto; border: 1px solid #303747; border-radius: 13px; padding: 22px;
                background: #181c25; }
            .panel { border: 1px solid #303747; border-radius: 12px; padding: 16px; background: #171b24; margin-bottom: 12px; }
            .muted { color: #8f98aa; }
            .key-row { display: flex; gap: 8px; margin-top: 16px; }
            .key-input, .search, .text-input { min-width: 0; border: 1px solid #363e50; border-radius: 7px;
                background: #10131a; color: #eef2fa; padding: 9px 11px; }
            .key-input { flex: 1; }
            textarea.text-input { width: 100%; min-height: 150px; resize: vertical; font-family: inherit; line-height: 1.5; }
            .field { margin-bottom: 12px; }
            .field input.text-input, .field textarea.text-input { width: 100%; }
            .field label { display: block; margin-bottom: 5px; font-size: 11px; font-weight: 700; color: #b9c4d8; }
            .field label input[type="checkbox"] { width: auto; margin-right: 6px; vertical-align: middle; }
            .field .hint { margin-top: 4px; font-size: 10px; color: #8f98aa; }
            .inline-fields { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; }
            .notice { border: 1px solid #3b4967; background: #151c29; color: #bfcbe1; border-radius: 9px; padding: 10px 12px; margin-top: 12px; }
            .error { border-color: #743d45; background: #25171b; color: #ffc0c6; }
            .warnbox { border-color: #6c5427; background: #221c11; color: #f0d4a0; }
            .metrics { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 9px; margin-bottom: 11px; }
            .metric { min-width: 0; border: 1px solid #2d3442; border-radius: 10px; background: #181c25; padding: 12px; }
            .metric.good { border-color: #315b40; }
            .metric.warn { border-color: #665027; }
            .metric.bad { border-color: #703b43; }
            .label { color: #8f98aa; text-transform: uppercase; letter-spacing: .7px; font-size: 9px; font-weight: 800; }
            .value { margin-top: 4px; font-size: 19px; font-weight: 780; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
            .sub { color: #8f98aa; margin-top: 3px; font-size: 10px; }
            .progress-wrap { border: 1px solid #303849; border-radius: 10px; padding: 10px 12px; background: #151922; margin-bottom: 10px; }
            .progress-line { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; color: #b9c4d8; }
            .progress { height: 7px; margin-top: 8px; background: #0d1016; border-radius: 99px; overflow: hidden; }
            .progress > i { display: block; height: 100%; background: linear-gradient(90deg,#4f7df0,#76a3ff); transition: width .2s; }
            .toolbar { display: flex; gap: 7px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
            .filter.active { background: #263653; border-color: #5279c8; color: #fff; }
            .search { width: 260px; margin-left: auto; }
            .table-wrap { overflow: auto; border: 1px solid #2b3140; border-radius: 10px; background: #151820; }
            table { width: 100%; border-collapse: collapse; color: #e5e9f1; }
            th, td { padding: 9px 10px; border-bottom: 1px solid #282e3a; text-align: left; white-space: nowrap; font-size: 11px; }
            th { position: sticky; top: 0; z-index: 2; background: #11151d; color: #929bad; text-transform: uppercase; letter-spacing: .55px; font-size: 9px; }
            tbody tr:nth-child(even) { background: #ffffff05; }
            tbody tr:hover { background: #202735; }
            tbody tr.flagged { background: #2a1d13; }
            tbody tr.flagged:hover { background: #35251a; }
            .member { color: #eff3fb; font-weight: 750; text-decoration: none; }
            .member:hover { color: #8fb0ff; }
            .pill { display: inline-block; border: 1px solid #3b4354; border-radius: 999px; padding: 2px 7px; font-size: 9px; font-weight: 800; }
            .pill.online { color: #8add9f; border-color: #315b40; background: #142019; }
            .pill.idle { color: #e8ba69; border-color: #664d24; background: #211b12; }
            .pill.offline, .pill.other { color: #a4adbd; }
            .pill.hosp { color: #ff9aa6; border-color: #703b43; background: #221317; }
            .pill.open { color: #ffc0c6; border-color: #703b43; background: #221317; }
            .stat-main { font-weight: 750; color: #eef2fa; }
            .growth { display: inline-block; min-width: 30px; text-align: center; border-radius: 5px; padding: 2px 6px; font-weight: 800; font-size: 10px; }
            .growth.high { background: #14301f; color: #8add9f; border: 1px solid #315b40; }
            .growth.medium { background: #2a2413; color: #e8ba69; border: 1px solid #665027; }
            .growth.low { background: #241417; color: #ffa8b3; border: 1px solid #5e333b; }
            .conf.high { color: #8add9f; }
            .conf.medium { color: #e8ba69; }
            .conf.low, .conf.unknown { color: #a4adbd; }
            .empty { padding: 34px; text-align: center; color: #8f98aa; }
            .row-actions { display: flex; gap: 4px; flex-wrap: wrap; }
            .slot { padding: 2px 0; }
            .slot.open-slot { padding: 3px 6px; margin: 2px 0; border-left: 2px solid #703b43;
                background: #ffffff06; border-radius: 0 5px 5px 0; }
            .cpr { display: inline-block; margin-left: 4px; padding: 1px 5px; border-radius: 4px;
                background: #14243a; border: 1px solid #33507d; color: #9fc0ff; font-size: 9px; font-weight: 800; }
            .slot.me-slot { border-left: 2px solid #4f7df0; background: #4f7df015; padding: 3px 6px;
                margin: 2px 0; border-radius: 0 5px 5px 0; }
            .pill.you { color: #9fc0ff; border-color: #33507d; background: #14243a; }
            tbody tr.match { background: #12291c; }
            tbody tr.match:hover { background: #17331f; }
            .have { color: #8add9f; font-size: 10px; }
            .need { color: #ffa8b3; font-size: 10px; font-weight: 700; }
            @media (max-width: 950px) {
                .metrics { grid-template-columns: repeat(2,minmax(0,1fr)); }
                .inline-fields { grid-template-columns: 1fr; }
            }
            @media (max-width: 760px) {
                .backdrop { padding: 0; place-items: stretch; }
                .modal { width: 100vw; height: 100dvh; max-width: none; max-height: none; border: 0; border-radius: 0; }
                .header { flex: 0 0 auto; gap: 7px; padding: max(10px,env(safe-area-inset-top)) 10px 10px; }
                .brand { min-width: 0; }
                .eyebrow { display: none; }
                h1 { margin: 0; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
                .header .btn { flex: 0 0 auto; padding: 7px 9px; }
                .tabs { padding: 8px 8px 0; }
                .content { min-width: 0; padding: 9px 9px max(9px,env(safe-area-inset-bottom)); overscroll-behavior: contain; }
                .setup, .panel { width: 100%; max-width: none; margin: 10px 0; padding: 16px; }
                .progress-line { align-items: flex-start; }
                .progress-line span:first-child { min-width: 0; }
                .toolbar .filter { flex: 1 1 calc(25% - 7px); min-width: 65px; }
                .search { order: 2; flex: 1 0 100%; width: 100%; margin-left: 0; }
                .table-wrap { width: 100%; max-width: 100%; -webkit-overflow-scrolling: touch; }
            }
            @media (max-width: 600px) {
                .launcher { top: 124px; }
                .metrics { display: flex; gap: 7px; overflow-x: auto; padding-bottom: 3px; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
                .metric { flex: 0 0 145px; padding: 9px 10px; scroll-snap-align: start; }
                .value { font-size: 17px; }
                .key-row { flex-direction: column; }
                .key-row .btn { width: 100%; }
                .table-wrap { overflow: visible; border: 0; background: transparent; }
                table.cards, table.cards tbody { display: block; width: 100%; }
                table.cards thead { display: none; }
                table.cards tbody { display: grid; gap: 8px; }
                table.cards tbody tr { display: block; width: 100%; overflow: hidden;
                    border: 1px solid #30394b; border-radius: 10px; background: #151922; }
                table.cards tbody tr:nth-child(even) { background: #181d27; }
                table.cards th, table.cards td { white-space: normal; }
                table.cards td { display: block; min-width: 0; padding: 6px 9px; text-align: left; overflow-wrap: anywhere; }
                table.cards td::before { content: attr(data-label); display: block; margin-bottom: 2px; color: #91a6d5;
                    font-size: 8px; font-weight: 800; letter-spacing: .5px; text-transform: uppercase; }
                table.cards td .muted { margin-top: 2px; font-size: 9px; line-height: 1.3; }
                table.cards td .stat-main, table.cards td .member { font-size: 11px; }
                .empty { border: 1px solid #30394b; border-radius: 10px; }
            }
            @media (max-width: 360px) {
                .metric { flex-basis: 135px; }
                .header { padding-left: 8px; padding-right: 8px; }
                h1 { font-size: 14px; }
            }
        `;
        root.appendChild(style);
    }

    /* ---------------------------------------------------------------- shell */

    function mountLauncher() {
        host = document.createElement('div');
        host.id = 'tfa-host';
        root = host.attachShadow({ mode: 'open' });
        addStyles();
        launcher = document.createElement('button');
        launcher.className = 'launcher';
        launcher.textContent = 'FC';
        launcher.title = 'Open Faction Command';
        launcher.setAttribute('aria-label', 'Open Faction Command');
        launcher.addEventListener('click', openDashboard);
        root.appendChild(launcher);
        document.documentElement.appendChild(host);
    }

    const TABS = [
        ['members', 'Members'],
        ['war', 'War scout'],
        ['recruits', 'Recruits'],
        ['oc', 'Crimes'],
        ['settings', 'Settings'],
    ];

    function shell(content, withTabs = false) {
        let backdrop = root.querySelector('.backdrop');
        if (!backdrop) {
            backdrop = document.createElement('div');
            backdrop.className = 'backdrop';
            root.appendChild(backdrop);
        }
        backdrop.classList.remove('hidden');
        const tabs = withTabs
            ? `<div class="tabs">${TABS.map(([id, label]) => (
                `<button class="tab ${state.tab === id ? 'active' : ''}" data-tab="${id}">${escapeHtml(label)}</button>`
            )).join('')}</div>`
            : '';
        backdrop.innerHTML = `<div class="modal"><div class="header"><div class="brand">`
            + `<div class="eyebrow">Made by -Versatility- · v${VERSION}</div>`
            + `<h1>Faction Command</h1></div>`
            + `<button class="btn" data-action="settings">API key</button>`
            + `<button class="btn" data-action="close">Close</button></div>`
            + tabs
            + `<div class="content">${content}</div></div>`;
        backdrop.querySelector('[data-action="close"]').addEventListener('click', closeDashboard);
        backdrop.querySelector('[data-action="settings"]').addEventListener('click', () => renderSetup());
        backdrop.querySelectorAll('[data-tab]').forEach((button) => button.addEventListener('click', () => {
            state.tab = button.dataset.tab;
            renderTab();
        }));
        return backdrop.querySelector('.content');
    }

    function closeDashboard() {
        state.scanToken += 1;
        state.loading = false;
        root.querySelector('.backdrop')?.classList.add('hidden');
    }

    function renderSetup(message = '') {
        state.scanToken += 1;
        state.loading = false;
        const saved = Boolean(apiKey());
        const content = shell(`<div class="setup"><h2>${saved ? 'API key settings' : 'Connect your Torn account'}</h2>`
            + `<div class="muted">Use your own Torn API key. It is stored only inside this Tampermonkey script on this browser, and is sent to api.torn.com only.</div>`
            + (message ? `<div class="notice ${message.startsWith('Error:') ? 'error' : ''}">${escapeHtml(message)}</div>` : '')
            + `<div class="key-row"><input class="key-input" type="password" autocomplete="off" placeholder="${saved ? 'A key is already saved — enter a replacement only' : 'Paste Torn API key'}">`
            + `<button class="btn primary" data-action="save-key">${saved ? 'Save & reload' : 'Save & load'}</button></div>`
            + `<div class="notice">Public access is enough. This script is read-only against the Torn API: it cannot kick, mail, or perform any gameplay action. Anything that looks like an action opens Torn's own page for you to confirm.</div>`
            + (saved ? '<div style="margin-top:12px"><button class="btn danger" data-action="clear-key">Remove saved key and cached data</button></div>' : '')
            + `</div>`);
        const input = content.querySelector('.key-input');
        content.querySelector('[data-action="save-key"]').addEventListener('click', async () => {
            const next = input.value.trim();
            if (next) GM_setValue(API_KEY_STORE, next);
            if (!apiKey()) return renderSetup('Error: Enter an API key.');
            await loadDashboard(false);
        });
        input.addEventListener('keydown', (event) => {
            if (event.key === 'Enter') content.querySelector('[data-action="save-key"]').click();
        });
        content.querySelector('[data-action="clear-key"]')?.addEventListener('click', () => {
            [API_KEY_STORE, HISTORY_STORE, TREND_STORE, FF_STORE, MAIL_STORE].forEach(GM_deleteValue);
            renderSetup('Saved API key and cached data removed.');
        });
        input.focus();
    }

    /* -------------------------------------------------------- members tab */

    function memberRowsMarkup() {
        const query = state.search.trim().toLowerCase();
        const visible = state.members.filter((member) => {
            const history = state.stats.get(Number(member.id));
            const flags = inactivityFlags(member, history);
            let filterMatch = true;
            if (state.filter === 'flagged') filterMatch = flags.length > 0;
            else if (state.filter !== 'all') filterMatch = statusKey(member) === state.filter;
            const haystack = `${member.name} ${member.id} ${member.position || ''} ${member.status?.state || ''}`.toLowerCase();
            return filterMatch && (!query || haystack.includes(query));
        });

        const rows = visible.map((member) => {
            const history = state.stats.get(Number(member.id));
            const activity = statusKey(member);
            const flags = inactivityFlags(member, history);
            const ff = state.ff.get(Number(member.id));
            const trend = trendFor(member.id);

            let xanax = '<span class="muted">Queued…</span>';
            let active = '<span class="muted">Queued…</span>';
            let streak = '<span class="muted">Queued…</span>';
            let growth = '<span class="muted">—</span>';
            if (history?.available) {
                xanax = `<span class="stat-main">${num(history.xanax_per_day).toFixed(2)}/day</span>`
                    + `<div class="muted">${num(history.xanax_30d).toLocaleString()} in ${num(history.window_days, 30).toFixed(0)}d</div>`;
                active = `<span class="stat-main">${activeTime(history.active_seconds_30d)}</span>`
                    + `<div class="muted">${num(history.active_hours_per_day).toFixed(2)} h/day</div>`;
                streak = `<span class="stat-main">${num(history.login_streak).toLocaleString()}d</span>`
                    + `<div class="muted">best ${num(history.best_login_streak).toLocaleString()}d</div>`;
                growth = growthBadge(history.growth)
                    + `<div class="muted">${trend ? `${trend.xanax >= 0 ? '+' : ''}${trend.xanax} xan / ${trend.days}d` : 'trend building'}</div>`;
            } else if (history) {
                xanax = active = streak = `<span class="muted" title="${escapeHtml(history.error || 'Unavailable')}">Unavailable</span>`;
            }

            const actions = state.isOwner
                ? `<div class="row-actions">`
                    + `<button class="mini warn" data-msg="${Number(member.id)}">Message</button>`
                    + `<button class="mini danger" data-kick="${Number(member.id)}">Kick…</button></div>`
                : '<span class="muted">—</span>';

            return `<tr class="${flags.length ? 'flagged' : ''}">`
                + `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${Number(member.id)}">${escapeHtml(member.name)}</a>`
                + copyBtn(member.name, 'name')
                + `<div class="muted">ID ${Number(member.id)}${copyBtn(String(member.id), 'id')}</div></td>`
                + `<td>${num(member.level)}</td>`
                + `<td>${escapeHtml(member.position || 'Member')}</td>`
                + `<td><span class="pill ${activity}">${escapeHtml(member.last_action?.status || 'Unknown')}</span>`
                + `<div class="muted">${escapeHtml(member.last_action?.relative || relativeTime(member.last_action?.timestamp))}</div></td>`
                + `<td>${xanax}</td>`
                + `<td>${active}</td>`
                + `<td>${streak}</td>`
                + `<td>${growth}</td>`
                + `<td>${ffCell(ff)}</td>`
                + `<td>${flags.length ? `<span class="pill open">${escapeHtml(flags.join(' · '))}</span>` : '<span class="muted">ok</span>'}</td>`
                + `<td>${actions}</td></tr>`;
        }).join('');

        return { rows, count: visible.length };
    }

    const MEMBER_LABELS = ['Member', 'Level', 'Position', 'Activity', 'Xanax / day', 'Active 30d', 'Login streak', 'Growth', 'Est. stats', 'Flags', 'Actions'];

    function renderMemberRows() {
        const tbody = root.querySelector('[data-member-rows]');
        const empty = root.querySelector('[data-empty]');
        if (!tbody || !empty) return;
        const { rows, count } = memberRowsMarkup();
        tbody.innerHTML = rows;
        tbody.querySelectorAll('tr').forEach((row) => [...row.children].forEach((cell, index) => {
            cell.dataset.label = MEMBER_LABELS[index] || '';
        }));
        empty.classList.toggle('hidden', count > 0);
        bindCopy(tbody);
        bindMemberActions(tbody);
    }

    function bindMemberActions(scope) {
        scope.querySelectorAll('[data-msg]').forEach((button) => button.addEventListener('click', () => {
            const member = state.members.find((item) => Number(item.id) === Number(button.dataset.msg));
            if (member) stageMessage(member, state.stats.get(Number(member.id)));
        }));
        scope.querySelectorAll('[data-kick]').forEach((button) => button.addEventListener('click', () => {
            const member = state.members.find((item) => Number(item.id) === Number(button.dataset.kick));
            if (member) stageKick(member, button);
        }));
    }

    function membersMarkup() {
        const faction = state.faction || {};
        const online = state.members.filter((member) => statusKey(member) === 'online').length;
        const idle = state.members.filter((member) => statusKey(member) === 'idle').length;
        return `<div class="metrics">`
            + `<div class="metric"><div class="label">Faction</div><div class="value">${escapeHtml(faction.name || 'Your faction')}</div><div class="sub">${state.members.length} members</div></div>`
            + `<div class="metric good"><div class="label">Active now</div><div class="value">${online} online</div><div class="sub">${idle} idle</div></div>`
            + `<div class="metric warn" data-metric="xanax"><div class="label">Xanax used · 30d</div><div class="value">0</div><div class="sub">loading history</div></div>`
            + `<div class="metric" data-metric="growth"><div class="label">Median growth</div><div class="value">—</div><div class="sub">0-100 activity index</div></div>`
            + `<div class="metric bad" data-metric="flagged"><div class="label">Flagged low activity</div><div class="value">0</div><div class="sub">against your thresholds</div></div>`
            + `</div>`
            + `<div class="progress-wrap"><div class="progress-line"><span data-progress-label>Preparing member history…</span><span data-progress-count>0/${state.members.length}</span></div><div class="progress"><i data-progress-bar style="width:0%"></i></div></div>`
            // The active filter and the search text have to be re-derived from
            // state here: renderTab() rebuilds this markup from scratch, so
            // hardcoding "active" on All would show the wrong button and an
            // empty box while the rows stayed filtered.
            + `<div class="toolbar">`
            + [['all', 'All'], ['online', 'Online'], ['idle', 'Idle'], ['offline', 'Offline'], ['flagged', 'Flagged']]
                .map(([id, label]) => `<button class="btn filter ${state.filter === id ? 'active' : ''}" data-filter="${id}">${label}</button>`)
                .join('')
            + `<button class="btn" data-action="refresh-roster">Refresh roster</button>`
            + `<button class="btn" data-action="rebuild">Rebuild 30-day data</button>`
            + `<button class="btn" data-action="copy-flagged">Copy flagged IDs</button>`
            + `<input class="search" type="search" placeholder="Search member, ID, or position…" value="${escapeHtml(state.search)}">`
            + `</div>`
            + (state.isOwner ? '' : `<div class="notice">Kick and message tools are hidden: you are <b>${escapeHtml(state.me?.position || 'not in a faction')}</b> in ${escapeHtml(state.faction?.name || 'this faction')}, and they are Leader / Co-leader only.</div>`)
            + `<div class="table-wrap"><table class="cards"><thead><tr>${MEMBER_LABELS.map((label) => `<th>${escapeHtml(label)}</th>`).join('')}</tr></thead>`
            + `<tbody data-member-rows></tbody></table><div class="empty hidden" data-empty>No members match this filter.</div></div>`;
    }

    function updateSummary() {
        const complete = [...state.stats.values()].filter((item) => item.available);
        const loaded = state.stats.size;
        const total = state.members.length;
        const xanax = complete.reduce((sum, item) => sum + num(item.xanax_30d), 0);
        const growths = complete.map((item) => num(item.growth)).sort((a, b) => a - b);
        const median = growths.length ? growths[Math.floor(growths.length / 2)] : null;
        const flagged = state.members.filter((member) => inactivityFlags(member, state.stats.get(Number(member.id))).length).length;

        const setMetric = (name, value, sub) => {
            const metric = root.querySelector(`[data-metric="${name}"]`);
            if (!metric) return;
            metric.querySelector('.value').textContent = value;
            metric.querySelector('.sub').textContent = sub;
        };
        setMetric('xanax', xanax.toLocaleString(), `${complete.length} members with history`);
        setMetric('growth', median === null ? '—' : String(median), 'median 0-100 activity index');
        setMetric('flagged', String(flagged), 'against your thresholds');

        const count = root.querySelector('[data-progress-count]');
        const label = root.querySelector('[data-progress-label]');
        const bar = root.querySelector('[data-progress-bar]');
        if (count) count.textContent = `${loaded}/${total}`;
        if (bar) bar.style.width = `${total ? (loaded / total) * 100 : 100}%`;
        if (label) {
            label.textContent = loaded < total
                ? 'Loading official 30-day snapshots — the dashboard can stay open while it progresses.'
                : `History complete: ${complete.length}/${total} members available${loaded > complete.length ? `, ${loaded - complete.length} unavailable` : ''}.`;
        }
    }

    function bindMembers() {
        root.querySelectorAll('[data-filter]').forEach((button) => button.addEventListener('click', () => {
            state.filter = button.dataset.filter;
            root.querySelectorAll('[data-filter]').forEach((item) => item.classList.toggle('active', item === button));
            renderMemberRows();
        }));
        root.querySelector('.search')?.addEventListener('input', (event) => {
            state.search = event.target.value;
            renderMemberRows();
        });
        root.querySelector('[data-action="refresh-roster"]')?.addEventListener('click', () => loadDashboard(false));
        root.querySelector('[data-action="rebuild"]')?.addEventListener('click', async () => {
            GM_deleteValue(HISTORY_STORE);
            await loadDashboard(true);
        });
        root.querySelector('[data-action="copy-flagged"]')?.addEventListener('click', (event) => {
            const ids = state.members
                .filter((member) => inactivityFlags(member, state.stats.get(Number(member.id))).length)
                .map((member) => member.id);
            copyText(ids.join(','), event.currentTarget);
        });
    }

    /* ------------------------------------------------------------- war tab */

    function threatRows(members, ffMap) {
        return members.map((member) => {
            const ff = ffMap.get(Number(member.id));
            return {
                member,
                ff,
                estimate: num(ff?.bs_estimate),
                fair: num(ff?.fair_fight),
            };
        }).sort((a, b) => (b.estimate - a.estimate)
            || (b.fair - a.fair)
            || (num(b.member.level) - num(a.member.level)));
    }

    const WAR_LABELS = ['Threat', 'Member', 'Level', 'Est. stats', 'Activity', 'Status', 'Actions'];

    function warMarkup() {
        const war = state.war;
        const config = settings();
        let body = '';

        if (war.loading) {
            body = '<div class="empty">Loading opponent roster…</div>';
        } else if (war.error) {
            body = `<div class="notice error">${escapeHtml(war.error)}</div>`;
        } else if (!war.members.length) {
            body = '<div class="empty">No opponent loaded. Use “Find current war” or enter a faction ID.</div>';
        } else {
            const ranked = threatRows(war.members, state.ff);
            const online = war.members.filter((member) => statusKey(member) === 'online').length;
            const hospital = war.members.filter((member) => String(member.status?.state || '').toLowerCase() === 'hospital').length;
            const withEstimate = ranked.filter((row) => row.estimate);
            const total = withEstimate.reduce((sum, row) => sum + row.estimate, 0);

            const rows = ranked.map((row, index) => {
                const member = row.member;
                const activity = statusKey(member);
                const hosp = String(member.status?.state || '').toLowerCase() === 'hospital';
                return `<tr>`
                    + `<td><span class="stat-main">#${index + 1}</span></td>`
                    + `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${Number(member.id)}">${escapeHtml(member.name)}</a>`
                    + copyBtn(member.name, 'name')
                    + `<div class="muted">ID ${Number(member.id)}${copyBtn(String(member.id), 'id')}</div></td>`
                    + `<td>${num(member.level)}</td>`
                    + `<td>${ffCell(row.ff)}</td>`
                    + `<td><span class="pill ${activity}">${escapeHtml(member.last_action?.status || 'Unknown')}</span>`
                    + `<div class="muted">${escapeHtml(member.last_action?.relative || relativeTime(member.last_action?.timestamp))}</div></td>`
                    + `<td><span class="pill ${hosp ? 'hosp' : 'offline'}">${escapeHtml(member.status?.state || 'Okay')}</span>`
                    + `<div class="muted">${escapeHtml(member.status?.description || '')}</div></td>`
                    + `<td><a class="mini" target="_blank" rel="noreferrer" href="https://www.torn.com/loader.php?sid=attack&user2ID=${Number(member.id)}">Attack</a></td>`
                    + `</tr>`;
            }).join('');

            body = `<div class="metrics">`
                + `<div class="metric"><div class="label">Opponent</div><div class="value">${escapeHtml(war.faction?.name || 'Faction')}</div><div class="sub">${war.members.length} members</div></div>`
                + `<div class="metric good"><div class="label">Online now</div><div class="value">${online}</div><div class="sub">${hospital} in hospital</div></div>`
                + `<div class="metric warn"><div class="label">Estimated total stats</div><div class="value">${withEstimate.length ? shortNumber(total) : '—'}</div><div class="sub">${withEstimate.length}/${war.members.length} with FF data</div></div>`
                + `<div class="metric bad"><div class="label">Biggest threat</div><div class="value">${escapeHtml(ranked[0]?.member?.name || '—')}</div><div class="sub">${ranked[0]?.estimate ? shortNumber(ranked[0].estimate) : 'enable FFScouter'}</div></div>`
                + `<div class="metric"><div class="label">Respect</div><div class="value">${escapeHtml(String(war.faction?.respect ?? '—'))}</div><div class="sub">faction respect</div></div>`
                + `</div>`
                + (config.ffEnabled ? '' : '<div class="notice warnbox">FFScouter is off, so threat ranking falls back to level only. Turn it on in Settings for stat estimates.</div>')
                + `<div class="toolbar"><button class="btn" data-action="copy-war-ids">Copy all opponent IDs</button>`
                + `<button class="btn" data-action="copy-war-online">Copy online IDs</button></div>`
                + `<div class="table-wrap"><table class="cards"><thead><tr>${WAR_LABELS.map((l) => `<th>${escapeHtml(l)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
        }

        return `<div class="panel"><h3>Ranked war opponent</h3>`
            + `<div class="toolbar">`
            + `<button class="btn primary" data-action="find-war">Find current war</button>`
            + `<input class="text-input" data-war-id placeholder="…or enter opponent faction ID" value="${escapeHtml(war.opponentId)}" style="width:230px">`
            + `<button class="btn" data-action="load-war">Load faction</button>`
            + `</div>`
            + `<div class="muted">Threat order uses FFScouter battle-stat estimates when enabled, then level. Opponent rosters come from the public Torn faction endpoint.</div>`
            + `</div>${body}`;
    }

    function bindWar(content) {
        content.querySelector('[data-action="find-war"]')?.addEventListener('click', findCurrentWar);
        content.querySelector('[data-action="load-war"]')?.addEventListener('click', () => {
            const input = content.querySelector('[data-war-id]');
            loadOpponent(String(input?.value || '').trim());
        });
        content.querySelector('[data-war-id]')?.addEventListener('keydown', (event) => {
            if (event.key === 'Enter') content.querySelector('[data-action="load-war"]').click();
        });
        content.querySelector('[data-action="copy-war-ids"]')?.addEventListener('click', (event) => {
            copyText(state.war.members.map((m) => m.id).join(','), event.currentTarget);
        });
        content.querySelector('[data-action="copy-war-online"]')?.addEventListener('click', (event) => {
            copyText(state.war.members.filter((m) => statusKey(m) === 'online').map((m) => m.id).join(','), event.currentTarget);
        });
        bindCopy(content);
    }

    async function findCurrentWar() {
        state.war.loading = true;
        state.war.error = '';
        renderTab();
        try {
            const payload = await api('faction/rankedwars');
            const wars = Array.isArray(payload?.rankedwars)
                ? payload.rankedwars
                : Object.values(payload?.rankedwars || {});
            // Exclude ourselves by BOTH id and name. faction/basic does not
            // reliably expose an id on every key, and if it comes back
            // undefined a naive "first faction with an id" pick happily
            // returns our own faction as the opponent.
            const myId = num(state.faction?.id);
            const myName = String(state.faction?.name || '').toLowerCase();
            let opponent = 0;
            for (const war of wars) {
                const factions = Array.isArray(war?.factions) ? war.factions : Object.values(war?.factions || {});
                const other = factions.find((item) => {
                    const id = num(item?.id);
                    if (!id) return false;
                    if (myId && id === myId) return false;
                    if (myName && String(item?.name || '').toLowerCase() === myName) return false;
                    return true;
                });
                if (other) { opponent = num(other.id); break; }
            }
            if (!opponent) {
                throw new Error(wars.length
                    ? 'A war was found but the opponent could not be told apart from your own faction — enter the ID manually.'
                    : 'No ranked war is currently returned for this faction.');
            }
            await loadOpponent(String(opponent));
        } catch (error) {
            state.war.loading = false;
            state.war.error = `Could not find a current ranked war: ${error?.message || error}`;
            renderTab();
        }
    }

    async function loadOpponent(factionId) {
        const id = String(factionId || '').replace(/[^0-9]/g, '');
        if (!id) {
            state.war.error = 'Enter a numeric faction ID.';
            return renderTab();
        }
        state.war.opponentId = id;
        state.war.loading = true;
        state.war.error = '';
        renderTab();
        try {
            const payload = await apiV1(`faction/${id}`, ['basic']);
            const members = Object.entries(payload?.members || {}).map(([memberId, data]) => ({
                id: Number(memberId),
                name: data?.name || `Player ${memberId}`,
                level: num(data?.level),
                position: data?.position || '',
                last_action: data?.last_action || {},
                status: data?.status || {},
                days_in_faction: num(data?.days_in_faction),
            }));
            if (!members.length) throw new Error('That faction returned no members.');
            state.war.faction = { id: num(payload?.ID) || Number(id), name: payload?.name || `Faction ${id}`, respect: payload?.respect };
            state.war.members = members;
            state.war.loading = false;
            renderTab();
            const ffMap = await ffLookup(members.map((m) => m.id));
            ffMap.forEach((value, key) => state.ff.set(key, value));
            renderTab();
        } catch (error) {
            state.war.loading = false;
            state.war.members = [];
            state.war.error = friendlyApiError(error, `Could not load faction ${id}`);
            renderTab();
        }
    }

    /* -------------------------------------------------------- recruits tab */

    const RECRUIT_LABELS = ['Candidate', 'Level', 'Growth', 'Xanax / day', 'Active 30d', 'Streak', 'Est. stats', 'Faction', 'Activity', 'Mark'];

    function recruitsMarkup() {
        const recruits = state.recruits;
        let body = '';
        if (recruits.loading) {
            body = '<div class="empty">Scanning candidates…</div>';
        } else if (recruits.error) {
            body = `<div class="notice error">${escapeHtml(recruits.error)}</div>`;
        } else if (!recruits.rows.length) {
            body = '<div class="empty">No candidates scanned yet.</div>';
        } else {
            const ranked = [...recruits.rows]
                .filter((row) => !recruits.onlyMatches || recruitMatch(row) === true)
                .sort((a, b) => num(b.growth) - num(a.growth));
            const rows = ranked.map((row) => {
                const activity = statusKey(row);
                const match = recruitMatch(row);
                return `<tr class="${match === true ? 'match' : ''}">`
                    + `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${Number(row.id)}">${escapeHtml(row.name)}</a>`
                    + copyBtn(row.name, 'name')
                    + `<div class="muted">ID ${Number(row.id)}${copyBtn(String(row.id), 'id')}</div></td>`
                    + `<td>${num(row.level)}</td>`
                    + `<td>${row.available ? growthBadge(row.growth) : '<span class="muted">—</span>'}</td>`
                    + `<td>${row.available ? `${num(row.xanax_per_day).toFixed(2)}/day` : `<span class="muted" title="${escapeHtml(row.error || '')}">n/a</span>`}</td>`
                    + `<td>${row.available ? activeTime(row.active_seconds_30d) : '<span class="muted">n/a</span>'}</td>`
                    + `<td>${row.available ? `${num(row.login_streak)}d` : '<span class="muted">n/a</span>'}</td>`
                    + `<td>${ffCell(state.ff.get(Number(row.id)))}</td>`
                    + `<td>${escapeHtml(row.faction_name || '—')}</td>`
                    + `<td><span class="pill ${activity}">${escapeHtml(row.last_action?.status || 'Unknown')}</span>`
                    + `<div class="muted">${escapeHtml(row.last_action?.relative || relativeTime(row.last_action?.timestamp))}</div></td>`
                    + `<td>${match === null ? '<span class="muted">—</span>' : (match ? '<span class="pill you">MEETS</span>' : '<span class="muted">below</span>')}</td>`
                    + `</tr>`;
            }).join('');
            body = `<div class="toolbar"><button class="btn" data-action="copy-recruit-ids">Copy candidate IDs</button>`
                + `<button class="btn" data-action="copy-top">Copy top 10 IDs</button>`
                + `<button class="btn" data-action="copy-matches">Copy matching IDs</button>`
                + `<button class="btn filter ${recruits.onlyMatches ? 'active' : ''}" data-action="toggle-matches">Only matches</button></div>`
                + `<div class="table-wrap"><table class="cards"><thead><tr>${RECRUIT_LABELS.map((l) => `<th>${escapeHtml(l)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
        }

        return `<div class="panel"><h3>Recruit scouting</h3>`
            + `<div class="field"><label>Candidates</label>`
            + `<textarea class="text-input" data-recruit-input placeholder="Paste player IDs — comma, space or newline separated&#10;&#10;Or scan an entire faction roster with f followed by ITS id, e.g. f${escapeHtml(String(state.faction?.id || '')) || '<faction id>'} for your own">${escapeHtml(recruits.input)}</textarea>`
            + `<div class="hint">Each candidate costs two Torn API calls for the 30-day window, so large rosters take a moment. Growth is the same 0-100 activity index used on the Members tab.</div></div>`
            + `<div class="inline-fields">`
            + `<div class="field"><label>Mark: min Xanax / day</label><input class="text-input" type="number" min="0" step="0.1" data-set="recruitMinXanax" value="${num(settings().recruitMinXanax)}"><div class="hint">0 = off. 30-day average.</div></div>`
            + `<div class="field"><label>Mark: min est. battle stats</label><input class="text-input" type="number" min="0" step="1000000" data-set="recruitMinStats" value="${num(settings().recruitMinStats)}"><div class="hint">0 = off. Needs FFScouter on.</div></div>`
            + `<div class="field"><label>&nbsp;</label><button class="btn primary" data-action="scan-recruits" style="width:100%">Scan candidates</button></div>`
            + `</div></div>${body}${browseMarkup()}`;
    }

    /* ---- candidate discovery -------------------------------------------
     *
     * There is no "random players" endpoint, so browsing uses the two public
     * leaderboards that DO work (verified live 2026-08-26):
     *   torn/factionhof?cat=respect   -> {id, name, members, position, rank,
     *                                     values:{respect}}
     *   torn/hof?cat=level|attacks|networth|awards
     *                                 -> {id, username, faction_id, level,
     *                                     last_action, rank_name, age_in_days}
     * Both accept limit+offset, so "Load 50 more" walks down the ladder.
     * Player rows carry faction_id, which is what makes "unfactioned only"
     * possible - that is the genuinely recruitable pool.
     */
    const BROWSE_CATS = {
        factions: [['respect', 'Respect']],
        players: [['level', 'Level'], ['attacks', 'Attacks'], ['networth', 'Networth'], ['awards', 'Awards']],
    };

    async function loadBrowse(more = false) {
        const browse = state.browse;
        browse.loading = true;
        browse.error = '';
        if (!more) { browse.rows = []; browse.offset = 0; }
        renderTab();
        try {
            const path = browse.mode === 'factions' ? 'torn/factionhof' : 'torn/hof';
            const payload = await api(path, { cat: browse.cat, limit: 50, offset: browse.offset });
            const rows = browse.mode === 'factions' ? (payload?.factionhof || []) : (payload?.hof || []);
            browse.rows = more ? [...browse.rows, ...rows] : rows;
            browse.offset += 50;
        } catch (error) {
            browse.error = friendlyApiError(error, 'Could not load the leaderboard');
        }
        browse.loading = false;
        renderTab();
    }

    /**
     * "Good enough" mark for a candidate: minimum Xanax/day and/or minimum
     * estimated battle stats. Returns null when both marks are off, so the
     * table can tell "not configured" apart from "did not qualify".
     */
    function recruitMatch(row) {
        const config = settings();
        const minXan = num(config.recruitMinXanax);
        const minStats = num(config.recruitMinStats);
        if (!minXan && !minStats) return null;
        const xanOk = !minXan || (row.available && num(row.xanax_per_day) >= minXan);
        const statsOk = !minStats || num(state.ff.get(Number(row.id))?.bs_estimate) >= minStats;
        return Boolean(xanOk && statsOk);
    }

    function browseMarkup() {
        const browse = state.browse;
        const cats = BROWSE_CATS[browse.mode] || [];
        let table = '';

        if (browse.loading && !browse.rows.length) {
            table = '<div class="empty">Loading…</div>';
        } else if (browse.error) {
            table = `<div class="notice error">${escapeHtml(browse.error)}</div>`;
        } else if (!browse.rows.length) {
            table = '<div class="empty">Pick a list and press Load.</div>';
        } else if (browse.mode === 'factions') {
            const rows = browse.rows.map((row) => `<tr>`
                + `<td>#${num(row.position)}</td>`
                + `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/factions.php?step=profile&ID=${num(row.id)}">${escapeHtml(row.name || '')}</a>`
                + copyBtn(String(num(row.id)), 'id') + `</td>`
                + `<td>${num(row.members)}</td>`
                + `<td>${escapeHtml(row.rank || '—')}</td>`
                + `<td>${num(row.values?.respect).toLocaleString()}</td>`
                + `<td><button class="mini warn" data-scan-faction="${num(row.id)}">Scan roster</button></td></tr>`).join('');
            table = `<div class="table-wrap"><table class="cards"><thead><tr>`
                + ['#', 'Faction', 'Members', 'Rank', 'Respect', ''].map((l) => `<th>${l}</th>`).join('')
                + `</tr></thead><tbody>${rows}</tbody></table></div>`;
        } else {
            const visible = browse.freeOnly ? browse.rows.filter((row) => !num(row.faction_id)) : browse.rows;
            const rows = visible.map((row) => `<tr>`
                + `<td>#${num(row.position)}</td>`
                + `<td><a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${num(row.id)}">${escapeHtml(row.username || '')}</a>`
                + copyBtn(String(num(row.id)), 'id') + `</td>`
                + `<td>${num(row.level)}</td>`
                + `<td>${num(row.faction_id) ? '<span class="muted">in a faction</span>' : '<span class="pill you">FREE</span>'}</td>`
                + `<td>${escapeHtml(relativeTime(row.last_action))}</td>`
                + `<td>${Math.round(num(row.age_in_days) / 36.5) / 10}y</td>`
                + `<td><button class="mini warn" data-add-player="${num(row.id)}">Add</button></td></tr>`).join('');
            table = `<div class="muted" style="margin-bottom:8px">${visible.length} shown of ${browse.rows.length} loaded.</div>`
                + `<div class="table-wrap"><table class="cards"><thead><tr>`
                + ['#', 'Player', 'Level', 'Faction', 'Last action', 'Age', ''].map((l) => `<th>${l}</th>`).join('')
                + `</tr></thead><tbody>${rows}</tbody></table></div>`;
        }

        return `<div class="panel"><h3>Browse candidates</h3>`
            + `<div class="muted">No API lists random players, so this walks Torn's public leaderboards. Click a faction to scan its whole roster, or add players to the box above.</div>`
            + `<div class="toolbar" style="margin-top:10px">`
            + `<button class="btn filter ${browse.mode === 'factions' ? 'active' : ''}" data-browse-mode="factions">Factions</button>`
            + `<button class="btn filter ${browse.mode === 'players' ? 'active' : ''}" data-browse-mode="players">Players</button>`
            + `<select class="text-input" data-browse-cat>${cats.map(([id, label]) => (
                `<option value="${id}" ${browse.cat === id ? 'selected' : ''}>${label}</option>`
            )).join('')}</select>`
            + `<button class="btn primary" data-action="browse-load">Load</button>`
            + (browse.rows.length ? `<button class="btn" data-action="browse-more">Load 50 more</button>` : '')
            + (browse.mode === 'players'
                ? `<label class="muted" style="display:flex;align-items:center;gap:5px"><input type="checkbox" data-browse-free ${browse.freeOnly ? 'checked' : ''}> unfactioned only</label>`
                    + `<button class="btn" data-action="add-all">Add all shown</button>`
                : '')
            + `</div>${table}</div>`;
    }

    function addCandidates(ids) {
        const existing = parseCandidates(state.recruits.input).ids;
        const merged = [...new Set([...existing, ...ids.map((id) => num(id)).filter(Boolean)])];
        state.recruits.input = merged.join(', ');
        const box = root.querySelector('[data-recruit-input]');
        if (box) box.value = state.recruits.input;
    }

    function bindBrowse(content) {
        content.querySelectorAll('[data-browse-mode]').forEach((button) => button.addEventListener('click', () => {
            state.browse.mode = button.dataset.browseMode;
            state.browse.cat = (BROWSE_CATS[state.browse.mode] || [['respect']])[0][0];
            state.browse.rows = [];
            state.browse.offset = 0;
            renderTab();
        }));
        content.querySelector('[data-browse-cat]')?.addEventListener('change', (event) => {
            state.browse.cat = event.target.value;
            state.browse.rows = [];
            state.browse.offset = 0;
        });
        content.querySelector('[data-browse-free]')?.addEventListener('change', (event) => {
            state.browse.freeOnly = event.target.checked;
            renderTab();
        });
        content.querySelector('[data-action="browse-load"]')?.addEventListener('click', () => loadBrowse(false));
        content.querySelector('[data-action="browse-more"]')?.addEventListener('click', () => loadBrowse(true));
        content.querySelectorAll('[data-scan-faction]').forEach((button) => button.addEventListener('click', () => {
            scanRecruits(`f${button.dataset.scanFaction}`);
        }));
        content.querySelectorAll('[data-add-player]').forEach((button) => button.addEventListener('click', () => {
            addCandidates([button.dataset.addPlayer]);
            button.textContent = 'Added';
            button.classList.add('copied');
        }));
        content.querySelector('[data-action="add-all"]')?.addEventListener('click', (event) => {
            const rows = state.browse.freeOnly
                ? state.browse.rows.filter((row) => !num(row.faction_id))
                : state.browse.rows;
            addCandidates(rows.map((row) => row.id));
            event.currentTarget.textContent = `Added ${rows.length}`;
        });
        bindCopy(content);
    }

    function parseCandidates(text) {
        const raw = String(text || '').trim();
        const factionMatch = raw.match(/^f\s*([0-9]{1,10})$/i);
        if (factionMatch) return { factionId: factionMatch[1], ids: [] };
        const ids = [...new Set(raw.split(/[^0-9]+/).map((part) => Number(part)).filter((id) => Number.isFinite(id) && id > 0))];
        return { factionId: '', ids };
    }

    async function scanRecruits(text) {
        const parsed = parseCandidates(text);
        state.recruits.input = text;
        state.recruits.loading = true;
        state.recruits.error = '';
        state.recruits.rows = [];
        renderTab();
        try {
            let candidates = [];
            if (parsed.factionId) {
                const payload = await apiV1(`faction/${parsed.factionId}`, ['basic']);
                candidates = Object.entries(payload?.members || {}).map(([id, data]) => ({
                    id: Number(id),
                    name: data?.name || `Player ${id}`,
                    level: num(data?.level),
                    last_action: data?.last_action || {},
                    faction_name: payload?.name || '',
                }));
            } else {
                candidates = parsed.ids.map((id) => ({ id, name: `Player ${id}`, level: 0, last_action: {}, faction_name: '' }));
            }
            if (!candidates.length) throw new Error('No candidate IDs found in that input.');

            state.recruits.loading = false;
            state.recruits.rows = candidates.map((row) => ({ ...row, available: false }));
            renderTab();

            for (const candidate of candidates) {
                let row;
                try {
                    if (!candidate.name || candidate.name.startsWith('Player ')) {
                        const profile = await apiV1(`user/${candidate.id}`, ['profile']);
                        candidate.name = profile?.name || candidate.name;
                        candidate.level = num(profile?.level, candidate.level);
                        candidate.last_action = profile?.last_action || candidate.last_action;
                        candidate.faction_name = profile?.faction?.faction_name || candidate.faction_name;
                    }
                    const history = await loadMemberHistory(candidate);
                    row = { ...candidate, ...history, available: true };
                } catch (error) {
                    row = { ...candidate, available: false, error: String(error?.message || error) };
                }
                const index = state.recruits.rows.findIndex((item) => Number(item.id) === Number(candidate.id));
                if (index >= 0) state.recruits.rows[index] = row;
                // Throttled: renderTab() rebuilds the whole panel including the
                // candidate textarea, so re-rendering once per candidate would
                // both burn time on a big roster and yank the box out from
                // under anyone still typing in it.
                if (state.tab === 'recruits' && Date.now() - lastRecruitRender > 750) {
                    lastRecruitRender = Date.now();
                    renderTab();
                }
            }
            if (state.tab === 'recruits') renderTab();

            const ffMap = await ffLookup(candidates.map((c) => c.id));
            ffMap.forEach((value, key) => state.ff.set(key, value));
            if (state.tab === 'recruits') renderTab();
        } catch (error) {
            state.recruits.loading = false;
            state.recruits.error = friendlyApiError(error, 'Candidate scan failed');
            renderTab();
        }
    }

    function bindRecruits(content) {
        content.querySelector('[data-action="scan-recruits"]')?.addEventListener('click', () => {
            scanRecruits(String(content.querySelector('[data-recruit-input]')?.value || ''));
        });
        content.querySelector('[data-recruit-input]')?.addEventListener('input', (event) => {
            state.recruits.input = event.target.value;
        });
        content.querySelector('[data-action="copy-recruit-ids"]')?.addEventListener('click', (event) => {
            copyText(state.recruits.rows.map((row) => row.id).join(','), event.currentTarget);
        });
        content.querySelector('[data-action="copy-top"]')?.addEventListener('click', (event) => {
            const top = [...state.recruits.rows].sort((a, b) => num(b.growth) - num(a.growth)).slice(0, 10);
            copyText(top.map((row) => row.id).join(','), event.currentTarget);
        });
        content.querySelector('[data-action="copy-matches"]')?.addEventListener('click', (event) => {
            const ids = state.recruits.rows.filter((row) => recruitMatch(row) === true).map((row) => row.id);
            copyText(ids.join(','), event.currentTarget);
        });
        content.querySelector('[data-action="toggle-matches"]')?.addEventListener('click', () => {
            state.recruits.onlyMatches = !state.recruits.onlyMatches;
            renderTab();
        });
        // The two Mark thresholds live on this tab but are saved through the
        // same [data-set] settings path the Settings tab uses.
        content.querySelectorAll('[data-set]').forEach((input) => {
            input.addEventListener('change', () => {
                saveSettings({ [input.dataset.set]: input.type === 'number' ? num(input.value) : input.value });
                renderTab();
            });
        });
        bindBrowse(content);
        bindCopy(content);
    }

    /* -------------------------------------------------------------- oc tab */

    const OC_LABELS = ['Crime', 'Status', 'Ready', 'Slots', 'Rewards'];

    /**
     * OC 2.0 slot readers - SCHEMA VERIFIED 2026-08-26 against a live
     * /v2/user/organizedcrime payload, not guessed:
     *
     *   position          "Techie"
     *   position_info     { id: "P2", label: "Techie", number: 1 }
     *   item_requirement  { id: 856, is_reusable: false, is_available: true }
     *   user              { id, joined_at, progress, outcome, item_outcome }
     *   checkpoint_pass_rate  65
     *
     * Two earlier guesses were WRONG and are corrected here: the display name
     * is `position_info.label` (not .name), and there is NO top-level
     * `user_id` - only `user.id`, which is null for an empty seat. The old
     * fallbacks are kept because the FACTION board endpoint has still never
     * been seen, and it may differ.
     */
    function slotPosition(slot) {
        return slot?.position_info?.label || slot?.position_info?.name || slot?.position || 'Slot';
    }

    function slotUserId(slot) {
        return num(slot?.user?.id) || num(slot?.user_id);
    }

    function slotProgress(slot) {
        const value = Number(slot?.user?.progress);
        return Number.isFinite(value) ? value : null;
    }

    function slotCpr(slot) {
        const value = slot?.checkpoint_pass_rate ?? slot?.success_chance
            ?? slot?.position_info?.checkpoint_pass_rate ?? slot?.position_info?.success_chance;
        const parsed = Number(value);
        return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
    }

    /** { id, name, reusable, available } or null. */
    function slotItem(slot) {
        const req = slot?.item_requirement;
        if (!req) return null;
        if (typeof req === 'string') return { id: 0, name: req, reusable: false, available: null };
        const id = num(req.id);
        return {
            id,
            name: req.name || req.item_name || state.itemNames.get(id) || (id ? `Item ${id}` : 'Item'),
            reusable: Boolean(req.is_reusable),
            available: typeof req.is_available === 'boolean' ? req.is_available : null,
        };
    }

    function itemCache() {
        const cache = safeParse(GM_getValue(ITEM_STORE, '{}')) || {};
        return cache && typeof cache === 'object' ? cache : {};
    }

    /**
     * item_requirement carries an id but no name, so names are looked up once
     * and cached forever - Torn item names do not change.
     */
    async function resolveItemNames(ids) {
        const cache = itemCache();
        let added = false;
        for (const raw of ids) {
            const id = num(raw);
            if (!id) continue;
            if (cache[id]) { state.itemNames.set(id, cache[id]); continue; }
            try {
                const payload = await apiV1(`torn/${id}`, ['items']);
                const name = payload?.items?.[String(id)]?.name;
                if (name) { cache[id] = name; state.itemNames.set(id, name); added = true; }
            } catch (_) { /* a missing name is cosmetic */ }
        }
        if (added) GM_setValue(ITEM_STORE, JSON.stringify(cache));
    }

    // Torn has no documented per-crime deep link, so this goes to the crimes
    // tab - the same place the old panel linked to. The crime name is copied
    // alongside so it can be found in Torn's list.
    const OC_URL = 'https://www.torn.com/factions.php?step=your&type=1#/tab=crimes';

    function itemChip(item) {
        if (!item) return '';
        const label = escapeHtml(item.name);
        if (item.available === true) return `<span class="have">has ${label}</span>`;
        if (item.available === false) return `<span class="need">needs ${label}</span>`;
        return `<span class="muted">needs ${label}</span>`;
    }

    function slotLine(slot, names, meId) {
        const position = slotPosition(slot);
        const cpr = slotCpr(slot);
        const progress = slotProgress(slot);
        const item = slotItem(slot);
        const uid = slotUserId(slot);
        const isMe = meId && uid === meId;
        const meta = [
            cpr === null ? '' : `<span class="cpr">${cpr}% CPR</span>`,
            progress === null ? '' : `<span class="muted">${progress}% done</span>`,
            itemChip(item),
        ].filter(Boolean).join(' ');

        if (!uid) {
            return `<div class="slot open-slot"><span class="muted">${escapeHtml(position)}:</span> `
                + `<span class="pill open">OPEN</span> ${meta} `
                + `<a class="mini" target="_blank" rel="noreferrer" href="${OC_URL}">Join in Torn</a>`
                + copyBtn(position, 'role') + `</div>`;
        }
        const name = names[uid] || String(uid);
        return `<div class="slot${isMe ? ' me-slot' : ''}"><span class="muted">${escapeHtml(position)}:</span> `
            + `<a class="member" target="_blank" rel="noreferrer" href="https://www.torn.com/profiles.php?XID=${uid}">${escapeHtml(name)}</a>`
            + (isMe ? ' <span class="pill you">YOU</span>' : '') + ` ${meta}`
            + copyBtn(name, 'name') + copyBtn(String(uid), 'id') + `</div>`;
    }

    /**
     * Your own crime. This is the half of the tab that works for EVERY member,
     * because /v2/user/organizedcrime is your own data and needs no faction
     * API Access permission - unlike the faction-wide board below it.
     */
    function myOcMarkup() {
        if (state.oc.mineError) return `<div class="notice error">${escapeHtml(state.oc.mineError)}</div>`;
        const mine = state.oc.mine;
        if (!mine) {
            return `<div class="panel"><h3>Your organized crime</h3>`
                + `<div class="muted">You are not in an organized crime right now.</div>`
                + `<div class="toolbar" style="margin-top:10px">`
                + `<a class="btn primary" target="_blank" rel="noreferrer" href="${OC_URL}">Find a crime to join</a></div></div>`;
        }
        const meId = num(state.me?.player_id);
        const names = Object.fromEntries(state.members.map((member) => [Number(member.id), member.name]));
        const mySlot = (mine.slots || []).find((slot) => slotUserId(slot) === meId);
        const myItem = slotItem(mySlot);
        const ordered = [...(mine.slots || [])].sort((a, b) => {
            const openA = slotUserId(a) ? 1 : 0;
            const openB = slotUserId(b) ? 1 : 0;
            return openA - openB || (slotCpr(b) || 0) - (slotCpr(a) || 0);
        });
        const blocked = myItem && myItem.available === false;

        return `<div class="metrics">`
            + `<div class="metric"><div class="label">Your crime</div><div class="value">${escapeHtml(mine.name || 'Crime')}</div><div class="sub">level ${num(mine.difficulty)} · ${escapeHtml(mine.status || '')}</div></div>`
            + `<div class="metric good"><div class="label">Your role</div><div class="value">${escapeHtml(mySlot ? slotPosition(mySlot) : '—')}</div><div class="sub">${mySlot && slotCpr(mySlot) !== null ? `${slotCpr(mySlot)}% pass rate` : 'pass rate unknown'}</div></div>`
            + `<div class="metric"><div class="label">Your progress</div><div class="value">${mySlot && slotProgress(mySlot) !== null ? `${slotProgress(mySlot)}%` : '—'}</div><div class="sub">recruit progress</div></div>`
            + `<div class="metric ${blocked ? 'bad' : 'good'}"><div class="label">Your item</div><div class="value">${myItem ? escapeHtml(myItem.name) : 'none'}</div><div class="sub">${myItem ? (myItem.available === false ? 'YOU DO NOT HAVE IT' : myItem.available === true ? `in hand${myItem.reusable ? ' · reusable' : ''}` : 'availability unknown') : 'no item needed'}</div></div>`
            + `<div class="metric warn"><div class="label">Ready</div><div class="value">${escapeHtml(untilText(mine.ready_at))}</div><div class="sub">${mine.ready_at ? escapeHtml(new Date(num(mine.ready_at) * 1000).toLocaleString()) : ''}</div></div>`
            + `</div>`
            + (blocked ? `<div class="notice error">Your role needs <b>${escapeHtml(myItem.name)}</b> and Torn reports you do not have it. The crime can fail on your checkpoint without it.</div>` : '')
            + `<div class="panel"><h3>Your crew</h3>${ordered.map((slot) => slotLine(slot, names, meId)).join('')}`
            + `<div class="toolbar" style="margin-top:10px"><a class="btn" target="_blank" rel="noreferrer" href="${OC_URL}">Open in Torn</a>`
            + `<button class="btn" data-action="reload-oc">Reload</button></div></div>`;
    }

    function ocMarkup() {
        const oc = state.oc;
        if (oc.loading) return '<div class="empty">Loading organized crimes…</div>';

        // Your own crime always renders. The faction-wide board is a bonus that
        // needs API Access, so its failure is a soft note under your crime -
        // not a red wall where the whole tab used to be.
        let board = '';
        if (oc.error) {
            board = `<div class="panel"><h3>Faction crime board</h3>`
                + `<div class="notice warnbox">${escapeHtml(oc.error)}</div></div>`;
        } else if (!oc.crimes.length) {
            board = `<div class="panel"><h3>Faction crime board</h3>`
                + `<div class="muted">No organized crimes in progress.</div></div>`;
        } else {
            const names = Object.fromEntries(state.members.map((member) => [Number(member.id), member.name]));
            const meId = num(state.me?.player_id);
            const recruiting = oc.crimes.filter((crime) => String(crime.status).toLowerCase() === 'recruiting').length;
            const openSlots = oc.crimes.reduce((sum, crime) => sum + (crime.slots || []).filter((slot) => !slotUserId(slot)).length, 0);
            const ready = oc.crimes.filter((crime) => num(crime.ready_at) <= Date.now() / 1000 && String(crime.status).toLowerCase() === 'planning').length;

            const ordered = [...oc.crimes].sort((a, b) => {
                const openA = (a.slots || []).filter((slot) => !slotUserId(slot)).length;
                const openB = (b.slots || []).filter((slot) => !slotUserId(slot)).length;
                return (openB ? 1 : 0) - (openA ? 1 : 0) || num(a.ready_at) - num(b.ready_at);
            });

            const rows = ordered.map((crime) => {
                const sorted = [...(crime.slots || [])].sort((a, b) => {
                    const openA = slotUserId(a) ? 1 : 0;
                    const openB = slotUserId(b) ? 1 : 0;
                    return openA - openB || (slotCpr(b) || 0) - (slotCpr(a) || 0);
                });
                const assigned = (crime.slots || []).filter((slot) => slotUserId(slot))
                    .map((slot) => names[slotUserId(slot)] || slotUserId(slot));
                return `<tr>`
                    + `<td><span class="stat-main">${escapeHtml(crime.name || `Crime ${crime.id}`)}</span>`
                    + (assigned.length ? copyBtn(assigned.join(', '), 'all names') : '')
                    + `<div class="muted">Level ${num(crime.difficulty)}</div></td>`
                    + `<td><span class="pill ${String(crime.status).toLowerCase() === 'recruiting' ? 'idle' : 'offline'}">${escapeHtml(crime.status || '')}</span></td>`
                    + `<td><span class="stat-main">${escapeHtml(untilText(crime.ready_at))}</span></td>`
                    + `<td>${sorted.map((slot) => slotLine(slot, names, meId)).join('') || '<span class="muted">—</span>'}</td>`
                    + `<td>${crime.rewards?.money ? `$${num(crime.rewards.money).toLocaleString()}` : '<span class="muted">—</span>'}</td>`
                    + `</tr>`;
            }).join('');

            board = `<div class="metrics">`
                + `<div class="metric"><div class="label">Crimes</div><div class="value">${oc.crimes.length}</div><div class="sub">in progress</div></div>`
                + `<div class="metric warn"><div class="label">Recruiting</div><div class="value">${recruiting}</div><div class="sub">still filling</div></div>`
                + `<div class="metric bad"><div class="label">Open slots</div><div class="value">${openSlots}</div><div class="sub">need a member</div></div>`
                + `<div class="metric good"><div class="label">Ready</div><div class="value">${ready}</div><div class="sub">planning complete</div></div>`
                + `<div class="metric"><div class="label">Members</div><div class="value">${state.members.length}</div><div class="sub">on the roster</div></div>`
                + `</div>`
                + `<div class="toolbar"><button class="btn" data-action="reload-oc">Reload crimes</button>`
                + `<a class="btn" target="_blank" rel="noreferrer" href="${OC_URL}">Open faction crimes</a>`
                + `<button class="btn" data-action="copy-open">Copy open roles</button></div>`
                + `<div class="notice">Open seats first, then by pass rate. <b>Joining happens on Torn</b> — the API has no join endpoint, and auto-clicking one would be automation.</div>`
                + `<div class="table-wrap"><table class="cards"><thead><tr>${OC_LABELS.map((l) => `<th>${escapeHtml(l)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
        }

        return myOcMarkup() + board;
    }

    async function loadCrimes() {
        state.oc.loading = true;
        state.oc.error = '';
        state.oc.mineError = '';
        renderTab();

        // YOUR OWN crime needs no faction permission - verified live. This is
        // the part that works for every member regardless of rank, so it is
        // fetched first and independently of the faction board.
        try {
            const payload = await api('user/organizedcrime');
            state.oc.mine = payload?.organizedCrime || null;
        } catch (error) {
            state.oc.mine = null;
            state.oc.mineError = friendlyApiError(error, 'Could not load your organized crime');
        }

        // The faction-WIDE board additionally needs the API Access permission,
        // so this half is allowed to fail without taking the tab down with it.
        // No `cat` is sent: the valid enum is unconfirmed and omitting it is
        // the safer default.
        try {
            const payload = await api('faction/crimes');
            state.oc.crimes = Array.isArray(payload?.crimes) ? payload.crimes : [];
        } catch (error) {
            state.oc.crimes = [];
            state.oc.error = friendlyApiError(error, 'The faction-wide crime board is unavailable');
        }

        const ids = [];
        const collect = (slots) => (slots || []).forEach((slot) => {
            const id = num(slot?.item_requirement?.id);
            if (id) ids.push(id);
        });
        collect(state.oc.mine?.slots);
        state.oc.crimes.forEach((crime) => collect(crime.slots));
        await resolveItemNames([...new Set(ids)]);

        state.oc.loading = false;
        state.oc.loaded = true;
        renderTab();
    }

    function bindOc(content) {
        content.querySelectorAll('[data-action="reload-oc"]').forEach((button) => button.addEventListener('click', () => {
            state.oc.loaded = false;
            loadCrimes();
        }));
        content.querySelector('[data-action="copy-open"]')?.addEventListener('click', (event) => {
            const open = [];
            for (const crime of state.oc.crimes) {
                for (const slot of crime.slots || []) {
                    if (!slotUserId(slot)) open.push(`${crime.name || 'Crime'} - ${slotPosition(slot)}`);
                }
            }
            copyText(open.join('\n'), event.currentTarget);
        });
        bindCopy(content);
    }

    /* --------------------------------------------------------- settings tab */

    function settingsMarkup() {
        const config = settings();
        return `<div class="panel"><h3>Low activity thresholds</h3>`
            + `<div class="muted">A member is flagged when any of these is true. Flagged rows are highlighted on the Members tab.</div>`
            + `<div class="inline-fields" style="margin-top:12px">`
            + `<div class="field"><label>Days since last action</label><input class="text-input" type="number" min="1" max="365" data-set="inactiveDays" value="${num(config.inactiveDays)}"><div class="hint">Flag at or above this.</div></div>`
            + `<div class="field"><label>Xanax per day below</label><input class="text-input" type="number" min="0" step="0.1" data-set="lowXanaxPerDay" value="${num(config.lowXanaxPerDay)}"><div class="hint">30-day average.</div></div>`
            + `<div class="field"><label>Active hours / 30d below</label><input class="text-input" type="number" min="0" step="0.5" data-set="minActiveHours30d" value="${num(config.minActiveHours30d)}"><div class="hint">Total, not per day.</div></div>`
            + `</div></div>`
            + `<div class="panel"><h3>FFScouter</h3>`
            + `<div class="muted">FFScouter (ffscouter.com) is a third party. Turning this on sends the player IDs you scout, and your Torn API key, to that site. It is off by default.</div>`
            + `<div class="field" style="margin-top:12px"><label><input type="checkbox" data-set-check="ffEnabled" ${config.ffEnabled ? 'checked' : ''}> Enable FFScouter battle-stat estimates</label>`
            + `<div class="hint">Without it, war threat ranking falls back to level only.</div></div>`
            + `<button class="btn" data-action="clear-ff">Clear cached FFScouter data</button></div>`
            + `<div class="panel"><h3>Inactivity message template</h3>`
            + `<div class="muted">Used by the Message button on flagged members. Placeholders: {name} {id} {faction} {position} {last_action} {days_inactive} {xanax_per_day} {active_30d} {streak}</div>`
            + `<div class="field" style="margin-top:12px"><textarea class="text-input" data-set="messageTemplate">${escapeHtml(config.messageTemplate)}</textarea></div>`
            + `<div class="notice">The Torn API cannot send mail. This fills Torn's own compose box and leaves the Send button to you.</div>`
            + `<button class="btn" data-action="reset-template">Reset to default</button></div>`
            + `<div class="panel"><h3>Your access</h3>`
            + `<div class="muted">Signed in as ${escapeHtml(state.me?.name || 'unknown')} (ID ${num(state.me?.player_id)}) · position ${escapeHtml(state.me?.position || 'unknown')}.`
            + ` Kick and message tools are ${state.isOwner ? 'ENABLED' : 'hidden — Leader or Co-leader only'}.</div></div>`;
    }

    function bindSettings(content) {
        content.querySelectorAll('[data-set]').forEach((input) => {
            input.addEventListener('change', () => {
                const key = input.dataset.set;
                const value = input.type === 'number' ? num(input.value) : input.value;
                saveSettings({ [key]: value });
                if (state.tab === 'members') renderTab();
            });
        });
        content.querySelectorAll('[data-set-check]').forEach((input) => {
            input.addEventListener('change', () => {
                saveSettings({ [input.dataset.setCheck]: input.checked });
                renderTab();
            });
        });
        content.querySelector('[data-action="clear-ff"]')?.addEventListener('click', () => {
            GM_deleteValue(FF_STORE);
            state.ff = new Map();
            renderTab();
        });
        content.querySelector('[data-action="reset-template"]')?.addEventListener('click', () => {
            saveSettings({ messageTemplate: DEFAULT_SETTINGS.messageTemplate });
            renderTab();
        });
    }

    /* ---------------------------------------------------------- tab router */

    function renderTab() {
        let markup = '';
        if (state.tab === 'members') markup = membersMarkup();
        else if (state.tab === 'war') markup = warMarkup();
        else if (state.tab === 'recruits') markup = recruitsMarkup();
        else if (state.tab === 'oc') markup = ocMarkup();
        else markup = settingsMarkup();

        const content = shell(markup, true);
        // The Crimes tab had no loader of its own - it rendered state.oc, which
        // nothing ever filled, so it always read "No organized crimes returned".
        if (state.tab === 'oc' && !state.oc.loaded && !state.oc.loading) {
            loadCrimes();
        }
        if (state.tab === 'members') {
            bindMembers();
            renderMemberRows();
            updateSummary();
        } else if (state.tab === 'war') bindWar(content);
        else if (state.tab === 'recruits') bindRecruits(content);
        else if (state.tab === 'oc') bindOc(content);
        else bindSettings(content);
        return content;
    }

    /* ------------------------------------------------------------- loading */

    async function scanHistory(force, token) {
        for (const member of state.members) {
            if (token !== state.scanToken) return;
            let row = force ? null : cachedHistoryRow(member.id);
            if (!row) {
                try {
                    row = await loadMemberHistory(member);
                    saveHistoryRow(row);
                    recordTrend(row);
                } catch (error) {
                    row = { id: Number(member.id), available: false, error: String(error?.message || error) };
                }
            }
            state.stats.set(Number(member.id), row);
            if (state.tab === 'members') {
                renderMemberRows();
                updateSummary();
            }
        }
        if (token === state.scanToken) state.loading = false;
    }

    async function resolveIdentity() {
        try {
            const profile = await apiV1('user', ['profile']);
            state.me = {
                player_id: num(profile?.player_id),
                name: profile?.name || '',
                position: profile?.faction?.position || '',
            };
        } catch (_) {
            state.me = null;
        }
        // faction/basic returns leader_id and co_leader_id, which is far more
        // reliable than string-matching a position name (factions rename ranks
        // freely). The position string stays as a fallback for the case where
        // faction/basic did not include the ids.
        const myId = num(state.me?.player_id);
        const byId = Boolean(myId) && (myId === num(state.faction?.leader_id) || myId === num(state.faction?.co_leader_id));
        const position = String(state.me?.position || '').toLowerCase();
        const byPosition = ['leader', 'co-leader', 'coleader'].includes(position);
        state.isOwner = byId || byPosition;
    }

    async function loadDashboard(forceHistory = false) {
        const token = ++state.scanToken;
        state.loading = true;
        state.stats = new Map();
        shell('<div class="setup"><h2>Loading faction roster…</h2><div class="notice">Connecting to the official Torn API.</div></div>');
        try {
            const basicPayload = await api('faction/basic');
            const membersPayload = await api('faction/members', { striptags: 'true' });
            if (token !== state.scanToken) return;
            state.faction = basicPayload?.basic || {};
            state.members = Array.isArray(membersPayload?.members) ? membersPayload.members : [];
            const order = { online: 0, idle: 1, offline: 2, other: 3 };
            state.members.sort((a, b) => (order[statusKey(a)] - order[statusKey(b)])
                || num(b.last_action?.timestamp) - num(a.last_action?.timestamp)
                || String(a.name).localeCompare(String(b.name)));
            if (!state.members.length) throw new Error('No faction members were returned for this API key.');

            await resolveIdentity();
            if (token !== state.scanToken) return;

            state.tab = 'members';
            renderTab();
            await scanHistory(forceHistory, token);

            if (settings().ffEnabled) {
                const ffMap = await ffLookup(state.members.map((m) => m.id));
                ffMap.forEach((value, key) => state.ff.set(key, value));
                if (state.tab === 'members') renderMemberRows();
            }
        } catch (error) {
            if (token !== state.scanToken) return;
            state.loading = false;
            renderSetup(`Error: ${error?.message || error}`);
        }
    }

    function openDashboard() {
        if (!apiKey()) renderSetup();
        else if (state.members.length) renderTab();
        else loadDashboard(false);
    }

    /* -------------------------------------------------- mail draft handler */

    /**
     * Runs on Torn's own compose page. Picks up a draft staged by the Message
     * button and types it into the box. It NEVER submits: the user reviews and
     * presses Send. The draft is cleared as soon as it is used, and expires by
     * itself after 10 minutes so an abandoned one cannot resurface later.
     */
    function setNativeValue(element, value) {
        const prototype = element instanceof HTMLTextAreaElement
            ? window.HTMLTextAreaElement.prototype
            : window.HTMLInputElement.prototype;
        const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
        if (setter) setter.call(element, value);
        else element.value = value;
        element.dispatchEvent(new Event('input', { bubbles: true }));
        element.dispatchEvent(new Event('change', { bubbles: true }));
    }

    function installMailFiller() {
        let attempts = 0;
        const timer = setInterval(() => {
            attempts += 1;
            if (attempts > 40) return clearInterval(timer);

            const draft = safeParse(GM_getValue(MAIL_STORE, 'null'));
            if (!draft || Date.now() - num(draft.staged_at) > 10 * 60 * 1000) {
                if (draft) GM_deleteValue(MAIL_STORE);
                return;
            }
            if (!/messages\.php/i.test(location.pathname) || !/compose/i.test(location.hash)) return;
            if (!location.hash.includes(String(draft.id))) return;

            const box = document.querySelector('textarea[name="message"], .message-box textarea, form textarea');
            if (!box || box.dataset.tfaFilled) return;

            box.dataset.tfaFilled = '1';
            setNativeValue(box, draft.body);
            GM_deleteValue(MAIL_STORE);
            clearInterval(timer);

            const banner = document.createElement('div');
            banner.textContent = `Draft filled by Faction Command for ${draft.name}. Review it, then press Send yourself.`;
            banner.style.cssText = 'margin:8px 0;padding:9px 12px;border:1px solid #5380dc;border-radius:8px;'
                + 'background:#151c29;color:#bfcbe1;font:12px/1.4 "Segoe UI",sans-serif;';
            box.parentElement?.insertBefore(banner, box);
        }, 500);
    }

    /* ---------------------------------------------------------------- boot */

    if (typeof document === 'undefined') return;

    installMailFiller();
    mountLauncher();

    if (typeof GM_registerMenuCommand === 'function') {
        GM_registerMenuCommand('Open Faction Command', openDashboard);
        GM_registerMenuCommand('Reset API key and cached data', () => {
            [API_KEY_STORE, HISTORY_STORE, TREND_STORE, FF_STORE, MAIL_STORE].forEach(GM_deleteValue);
            renderSetup('Saved API key and cached data removed.');
        });
    }
}());