Torn War Tools

Combined war page enhancement: hospital/travel countdown, hospital-first sort, status highlighting (from "Torn War Stuff Enhanced") + per-target 📢 callout button with editable message templates and one-click send to faction chat. Single unified settings panel via the ⚙ gear (bottom-right). Replaces both "Torn War Stuff Enhanced" and "Hospital Callout".

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Torn War Tools
// @namespace    iSatomi.torn.war.tools
// @version      2.0.1
// @description  Combined war page enhancement: hospital/travel countdown, hospital-first sort, status highlighting (from "Torn War Stuff Enhanced") + per-target 📢 callout button with editable message templates and one-click send to faction chat. Single unified settings panel via the ⚙ gear (bottom-right). Replaces both "Torn War Stuff Enhanced" and "Hospital Callout".
// @author       iSatomi [3580191]
// @license      MIT
// @match        https://www.torn.com/factions.php*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        unsafeWindow
// @connect      api.torn.com
// @run-at       document-idle
// ==/UserScript==

/* eslint-disable no-multi-spaces */
(async function () {
    'use strict';

    /* ───────────────────────────────────────────────────────────────────────
     * GUARD: prevent multiple instances. Also picks up FFScouterV2 disable.
     * ─────────────────────────────────────────────────────────────────── */
    if (document.querySelector('div#FFScouterV2DisableWarMonitor')) return;
    const guardNode = document.createElement('div');
    guardNode.id = 'FFScouterV2DisableWarMonitor';
    guardNode.style.display = 'none';
    document.documentElement.appendChild(guardNode);

    /* ───────────────────────────────────────────────────────────────────────
     * GM polyfills (some envs ship partial GM APIs)
     * ─────────────────────────────────────────────────────────────────── */
    const GMget = (key, def) => {
        try {
            if (typeof GM_getValue === 'function') return GM_getValue(key, def);
            if (typeof GM !== 'undefined' && GM.getValue) {
                const cached = localStorage.getItem('twt_' + key);
                if (cached !== null) { try { return JSON.parse(cached); } catch (e) { return cached; } }
                GM.getValue(key, def).then(v => localStorage.setItem('twt_' + key, JSON.stringify(v)));
                return def;
            }
        } catch (e) { /* fall through */ }
        try {
            const raw = localStorage.getItem('twt_' + key);
            if (raw === null) return def;
            try { return JSON.parse(raw); } catch (e) { return raw; }
        } catch (e) { return def; }
    };
    const GMset = (key, val) => {
        try {
            if (typeof GM_setValue === 'function') { GM_setValue(key, val); return; }
            if (typeof GM !== 'undefined' && GM.setValue) { GM.setValue(key, val); }
        } catch (e) { /* ignore */ }
        try { localStorage.setItem('twt_' + key, JSON.stringify(val)); } catch (e) { /* ignore */ }
    };
    const addStyle = (css) => {
        try { if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; } } catch (e) { /* fall through */ }
        const s = document.createElement('style');
        s.textContent = css;
        document.head.appendChild(s);
    };

    /* ═══════════════════════════════════════════════════════════════════════
     * ╔══════════════════════════════════════════════════════════════════╗
     * ║ PART 1: TORN WAR STUFF ENHANCED CORE                            ║
     * ║ Status processing, hospital countdown, hospital-first sort.     ║
     * ║ Adapted from xentac's "Torn War Stuff Enhanced" v1.17 (MIT).    ║
     * ╚══════════════════════════════════════════════════════════════════╝
     * ─────────────────────────────────────────────────────────────────── */

    // ----- Constants -----
    const STORAGE_KEY_API        = 'xentac-torn_war_stuff_enhanced-apikey';
    const STORAGE_KEY_HOSP_FIRST = 'xentac-torn_war_stuff_enhanced-hosp-first';
    const STORAGE_PREFIX_STATUS  = 'xentac-torn_war_stuff_enhanced-status-';

    const ATTR_TRAVELING       = 'data-twse-traveling';
    const ATTR_HIGHLIGHT       = 'data-twse-highlight';
    const ATTR_STATUS_DIFFERS  = 'data-twse-status-differs';
    const ATTR_SORT_A          = 'data-sortA';
    const ATTR_UNTIL           = 'data-until';
    const ATTR_SINCE           = 'data-since';
    const ATTR_LOCATION        = 'data-location';
    const ATTR_PID             = 'data-twse-pid';
    const ATTR_TRAVEL_ERROR_BAR= 'data-traveling-error-bar';

    const SORT_DIFFERS      = 0;
    const SORT_OKAY         = 1;
    const SORT_HOSPITAL     = 2;
    const SORT_RETURNING    = 3;
    const SORT_ABROAD       = 4;
    const SORT_TRAVELING_TO = 5;
    const SORT_TRAVELING    = 6;
    const SORT_UNKNOWN      = 999;

    const MIN_TIME_SINCE_LAST_REQUEST = 10_000;
    const TIME_BETWEEN_FRAMES         = 500;
    const INITIAL_WAIT_FOR_WARLIST    = 10_000;
    const API_RETRY_DELAY             = 30_000;

    const FATAL_API_ERRORS = new Set([0, 1, 2, 3, 4, 6, 7, 10, 12, 13, 14, 16, 18, 21]);
    const RETRY_API_ERRORS = new Set([5, 8, 9]);

    const COUNTRY_ABBREVIATIONS = {
        'South Africa':    'SA',
        'Cayman Islands':  'CI',
        'United Kingdom':  'UK',
        Argentina:         'Arg',
        Switzerland:       'Switz',
    };

    // ----- API key -----
    let apiKey = localStorage.getItem(STORAGE_KEY_API) ?? '###PDA-APIKEY###';

    function checkApiKey(checkExisting = true) {
        const looksValid = apiKey && apiKey.indexOf('PDA-APIKEY') === -1 && apiKey.length === 16;
        if (checkExisting && looksValid) return;
        const userInput = prompt(
            'Torn War Tools needs a PUBLIC API key (16 chars) for faction status data:',
            apiKey ?? '',
        );
        if (userInput !== null && userInput.length === 16) {
            apiKey = userInput;
            localStorage.setItem(STORAGE_KEY_API, userInput);
        } else {
            console.error('[TornWarTools] User cancelled the API Key input.');
        }
    }
    try { GM_registerMenuCommand('Set API Key', () => checkApiKey(false)); } catch (_) { /* TornPDA */ }

    // ----- Settings (TWSE side) -----
    let hospFirstEnabled = localStorage.getItem(STORAGE_KEY_HOSP_FIRST) !== 'false'; // default ON

    function setHospFirst(enabled) {
        hospFirstEnabled = !!enabled;
        localStorage.setItem(STORAGE_KEY_HOSP_FIRST, hospFirstEnabled ? 'true' : 'false');
        pendingSort = true;
    }

    // ----- State -----
    const SORT_ENABLED = true;
    let running     = true;
    let foundWar    = false;
    let everSorted  = false;
    let pageVisible = !document.hidden;
    let lastRequestAt = null;
    let pendingSort = false;

    const memberStatus = new Map(); // Map<pid, status>
    const memberLis    = new Map(); // Map<pid, { li, div }>

    let memberLists = document.querySelectorAll('ul.members-list');

    document.addEventListener('visibilitychange', () => { pageVisible = !document.hidden; });

    // ----- DOM helpers -----
    const refreshMemberLists = () => { memberLists = document.querySelectorAll('ul.members-list'); };
    const getMemberLists = () => memberLists;

    function getFactionIds() {
        refreshMemberLists();
        const ids = [];
        for (const elem of getMemberLists()) {
            const link = elem.querySelector("A[href^='/factions.php']");
            if (!link) continue;
            const parts = link.href.split('ID=');
            if (parts.length <= 1) continue;
            if (parts[1]) ids.push(parts[1]);
        }
        return ids;
    }

    function getSortedColumn(memberList) {
        const parent = memberList.parentNode;
        const cells = {
            member: parent.querySelector('div.member div'),
            level:  parent.querySelector('div.level div'),
            points: parent.querySelector('div.points div'),
            status: parent.querySelector('div.status div'),
        };
        let column = null, activeClass = '';
        for (const [name, node] of Object.entries(cells)) {
            if (node && /activeIcon__/.test(node.className)) {
                column = name; activeClass = node.className; break;
            }
        }
        const order = activeClass && /asc__/.test(activeClass) ? 'asc' : 'desc';
        if (column !== 'score' && order !== 'desc') everSorted = true;
        return { column, order };
    }

    function extractMemberLi(li) {
        const profile = li.querySelector("A[href^='/profiles.php']");
        if (!profile) return null;
        const id = profile.href.split('ID=')[1];
        if (!id) return null;
        if (li.getAttribute(ATTR_PID) !== id) li.setAttribute(ATTR_PID, id);
        return { id, li, div: li.querySelector('DIV.status') };
    }

    function refreshMemberLis() {
        refreshMemberLists();
        let changed = false;
        const seen = new Set();
        for (const ul of getMemberLists()) {
            for (const li of ul.querySelectorAll('LI.enemy, LI.your')) {
                const entry = extractMemberLi(li);
                if (!entry) continue;
                seen.add(entry.id);
                const existing = memberLis.get(entry.id);
                if (!existing || existing.li !== entry.li) {
                    memberLis.set(entry.id, entry);
                    changed = true;
                }
            }
        }
        for (const id of memberLis.keys()) {
            if (!seen.has(id)) { memberLis.delete(id); changed = true; }
        }
        return changed;
    }

    function extractAllMemberLis() { memberLis.clear(); refreshMemberLis(); }

    // ----- Cache -----
    function cacheStatus(factionId, statusMap) {
        localStorage.setItem(
            `${STORAGE_PREFIX_STATUS}${factionId}`,
            JSON.stringify({ timestamp: Date.now(), status: statusMap }),
        );
    }
    function getCachedStatus(factionId) {
        const raw = localStorage.getItem(`${STORAGE_PREFIX_STATUS}${factionId}`);
        if (!raw) return null;
        try {
            const p = JSON.parse(raw);
            if (!p || Date.now() - p.timestamp > MIN_TIME_SINCE_LAST_REQUEST) return null;
            return p.status;
        } catch (_) { return null; }
    }
    function populateCachedStatus(factionId) {
        const c = getCachedStatus(factionId);
        if (!c) return;
        for (const [k, v] of Object.entries(c)) memberStatus.set(k, v);
        pendingSort = true;
    }
    function cleanCachedStatuses() {
        const now = Date.now();
        for (const key of Object.keys(localStorage)) {
            if (!key.startsWith(STORAGE_PREFIX_STATUS)) continue;
            try {
                const p = JSON.parse(localStorage.getItem(key));
                if (now - p.timestamp > MIN_TIME_SINCE_LAST_REQUEST) localStorage.removeItem(key);
            } catch (_) { localStorage.removeItem(key); }
        }
    }

    // ----- API -----
    function abbreviateDescription(description) {
        let out = description;
        for (const [full, abbr] of Object.entries(COUNTRY_ABBREVIATIONS)) {
            out = out.replace(full, abbr);
        }
        return out;
    }

    async function updateStatuses() {
        if (!running) return;
        const factionIds = getFactionIds();
        if (factionIds.length === 0) return;
        if (lastRequestAt && Date.now() - lastRequestAt < MIN_TIME_SINCE_LAST_REQUEST) return;
        lastRequestAt = Date.now();
        for (const fid of factionIds) {
            const keepGoing = await updateStatus(fid);
            if (!keepGoing) return;
        }
    }

    async function updateStatus(factionId) {
        let result;
        try {
            const resp = await fetch(
                `https://api.torn.com/faction/${factionId}?selections=basic&key=${apiKey}&comment=TornWarTools`,
            );
            result = await resp.json();
        } catch (m) {
            console.error('[TornWarTools]', m);
            return true;
        }

        if (result.error) {
            console.log('[TornWarTools] API error', result.error);
            const code = result.error.code ?? result.error;
            if (FATAL_API_ERRORS.has(code)) {
                console.log('[TornWarTools] Non-recoverable. Stopping.');
                running = false;
                return false;
            }
            if (RETRY_API_ERRORS.has(code)) {
                console.log(`[TornWarTools] Retrying in ${API_RETRY_DELAY / 1000}s.`);
                lastRequestAt = Date.now() + API_RETRY_DELAY;
            }
            return false;
        }
        if (!result.members) return false;

        const reqTime = Date.now();
        const factionStatusMap = {};
        for (const [pid, member] of Object.entries(result.members)) {
            const status = member.status;
            status.last_req_time = reqTime;
            status.description = abbreviateDescription(status.description);

            const prev = memberStatus.get(pid);
            const prevState = prev?.state ?? 'Unknown';
            const prevUntil = prev?.until ?? 0;
            const prevDesc  = prev?.description ?? '';
            const prevLast  = prev?.last_req_time;

            if (prevState === status.state) {
                status.since = prev?.since ?? reqTime;
                status.traveling_error_bar = prev?.traveling_error_bar ?? 0;
            } else {
                status.since = reqTime;
                status.traveling_error_bar = prevState !== 'Traveling' ? (reqTime - (prevLast ?? 0)) : 0;
            }

            if (prevState !== status.state || prevUntil !== status.until || prevDesc !== status.description) {
                pendingSort = true;
            }
            memberStatus.set(pid, status);
            factionStatusMap[pid] = status;
        }
        cacheStatus(factionId, factionStatusMap);
        return true;
    }

    // ----- Deferred writes -----
    const deferredWrites = [];
    function queueAttr(node, attr, value, sortAffecting, dirty) {
        const current = node.getAttribute(attr);
        const next = String(value);
        if (current !== next) {
            deferredWrites.push([node, attr, next]);
            return sortAffecting ? true : dirty;
        }
        return dirty;
    }
    function applyDeferredWrites() {
        for (const [node, attr, value] of deferredWrites) node.setAttribute(attr, value);
        deferredWrites.length = 0;
    }

    // ----- Time helpers -----
    function pad(n) { return n < 10 ? '0' + n : String(n); }
    function formatHMS(totalSeconds) {
        const s = Math.floor(totalSeconds % 60);
        const m = Math.floor((totalSeconds / 60) % 60);
        const h = Math.floor(totalSeconds / 3600);
        return `${pad(h)}:${pad(m)}:${pad(s)}`;
    }
    function nowSeconds() {
        if (window.getCurrentTimestamp) return window.getCurrentTimestamp() / 1000;
        return Date.now() / 1000;
    }

    // ----- Per-member processing -----
    function setContent(statusDiv, content) {
        statusDiv.style.setProperty('--twse-content', `"${content}"`);
    }

    function processTraveling(li, statusDiv, status, dirty) {
        const onSiteTraveling =
            statusDiv.classList.contains('traveling') || statusDiv.classList.contains('abroad');
        if (!onSiteTraveling) {
            if (statusDiv.textContent === 'Okay') {
                dirty = queueAttr(li, ATTR_SORT_A, SORT_DIFFERS, true, dirty);
                dirty = queueAttr(statusDiv, ATTR_STATUS_DIFFERS, 'true', false, dirty);
            }
            setContent(statusDiv, statusDiv.textContent);
            return dirty;
        }
        dirty = queueAttr(statusDiv, ATTR_TRAVEL_ERROR_BAR, status.traveling_error_bar ?? 0, false, dirty);
        dirty = queueAttr(statusDiv, ATTR_STATUS_DIFFERS, 'false', false, dirty);

        const desc = status.description;
        let bucket = SORT_TRAVELING, content = 'Traveling', location = '';

        if (desc.includes('Traveling to ')) {
            bucket = SORT_TRAVELING_TO;
            location = desc.split('Traveling to ')[1];
            content = '► ' + location;
        } else if (desc.includes('Returning')) {
            bucket = SORT_RETURNING;
            location = desc.split('Returning to Torn from ')[1] ?? '';
            content = '◄ ' + location;
        } else if (desc.includes('In ')) {
            bucket = SORT_ABROAD;
            location = desc.split('In ')[1];
            content = location;
        }
        dirty = queueAttr(li, ATTR_SORT_A, bucket, true, dirty);
        dirty = queueAttr(li, ATTR_LOCATION, location, true, dirty);
        setContent(statusDiv, content);
        return dirty;
    }

    function processHospitalOrJail(li, statusDiv, status, dirty) {
        const onSite =
            statusDiv.classList.contains('hospital') || statusDiv.classList.contains('jail');
        const remaining = Math.round(status.until - nowSeconds());

        if (!onSite) {
            if (remaining >= 0) {
                dirty = queueAttr(li, ATTR_SORT_A, SORT_DIFFERS, true, dirty);
                dirty = queueAttr(statusDiv, ATTR_STATUS_DIFFERS, 'true', false, dirty);
            }
            setContent(statusDiv, statusDiv.textContent);
            dirty = queueAttr(statusDiv, ATTR_TRAVELING, 'false', false, dirty);
            dirty = queueAttr(statusDiv, ATTR_HIGHLIGHT, 'false', false, dirty);
            // CALLOUT: ensure button is stripped when row flips out of hospital
            removeCalloutFromRow(li);
            return dirty;
        }
        dirty = queueAttr(statusDiv, ATTR_STATUS_DIFFERS, 'false', false, dirty);
        dirty = queueAttr(li, ATTR_SORT_A, SORT_HOSPITAL, true, dirty);
        dirty = queueAttr(li, ATTR_LOCATION, '', true, dirty);
        dirty = queueAttr(statusDiv, ATTR_TRAVELING,
            status.description.includes('In a') ? 'true' : 'false', false, dirty);

        if (remaining <= 0) {
            dirty = queueAttr(statusDiv, ATTR_HIGHLIGHT, 'false', false, dirty);
            // CALLOUT: timer expired but state hasn't flipped — strip
            removeCalloutFromRow(li);
            return dirty;
        }

        setContent(statusDiv, formatHMS(remaining));
        dirty = queueAttr(statusDiv, ATTR_HIGHLIGHT, remaining < 300 ? 'true' : 'false', false, dirty);

        // CALLOUT: this row is in hospital — make sure the 📢 button is present
        if (status.state === 'Hospital') ensureCalloutOnRow(li, statusDiv);
        return dirty;
    }

    function processOkay(li, statusDiv, dirty) {
        setContent(statusDiv, statusDiv.textContent);
        dirty = queueAttr(li, ATTR_SORT_A, SORT_OKAY, true, dirty);
        dirty = queueAttr(li, ATTR_LOCATION, '', true, dirty);
        dirty = queueAttr(statusDiv, ATTR_TRAVELING, 'false', false, dirty);
        dirty = queueAttr(statusDiv, ATTR_HIGHLIGHT, 'false', false, dirty);
        dirty = queueAttr(statusDiv, ATTR_STATUS_DIFFERS, 'false', false, dirty);
        // CALLOUT: not in hospital, ensure stripped
        removeCalloutFromRow(li);
        return dirty;
    }

    // ----- Comparator -----
    function readNum(node, attr) {
        const v = parseInt(node.getAttribute(attr), 10);
        return Number.isFinite(v) ? v : 0;
    }
    function hasSortKey(node) { return node.getAttribute(ATTR_SORT_A) !== null; }

    function buildComparator(order) {
        const desc = order === 'desc';
        return (a, b) => {
            const aSort = hasSortKey(a) ? readNum(a, ATTR_SORT_A) : SORT_UNKNOWN;
            const bSort = hasSortKey(b) ? readNum(b, ATTR_SORT_A) : SORT_UNKNOWN;
            if (aSort !== bSort) return desc ? bSort - aSort : aSort - bSort;

            if (aSort === SORT_DIFFERS || aSort === SORT_OKAY) {
                const cmp = readNum(b, ATTR_SINCE) - readNum(a, ATTR_SINCE);
                if (cmp !== 0) return cmp;
            } else if (aSort === SORT_HOSPITAL) {
                const cmp = readNum(a, ATTR_UNTIL) - readNum(b, ATTR_UNTIL);
                if (cmp !== 0) return cmp;
            } else {
                const aLoc = a.getAttribute(ATTR_LOCATION) || '';
                const bLoc = b.getAttribute(ATTR_LOCATION) || '';
                if (aLoc < bLoc) return -1;
                if (aLoc > bLoc) return 1;
                const cmp = readNum(b, ATTR_SINCE) - readNum(a, ATTR_SINCE);
                if (cmp !== 0) return cmp;
            }
            return readNum(a, ATTR_PID) - readNum(b, ATTR_PID);
        };
    }

    function hospitalCompare(a, b) {
        const cmp = readNum(a, ATTR_UNTIL) - readNum(b, ATTR_UNTIL);
        if (cmp !== 0) return cmp;
        return readNum(a, ATTR_PID) - readNum(b, ATTR_PID);
    }

    function reorderHospitalFirst(list) {
        const children = Array.from(list.children);
        const hosp = [], rest = [];
        for (const li of children) {
            if (li.getAttribute(ATTR_SORT_A) === String(SORT_HOSPITAL)) hosp.push(li);
            else rest.push(li);
        }
        hosp.sort(hospitalCompare);
        return [...hosp, ...rest];
    }

    function fullStatusReorder(list, order) {
        const comparator = buildComparator(order);
        return Array.from(list.children).slice().sort(comparator);
    }

    function sortLists() {
        const lists = getMemberLists();
        for (const list of lists) {
            const sortedCol = getSortedColumn(list);
            let sorted;
            if (hospFirstEnabled) {
                sorted = reorderHospitalFirst(list);
            } else {
                const colInfo = !everSorted ? { column: 'status', order: 'asc' } : sortedCol;
                if (colInfo.column !== 'status') continue;
                sorted = fullStatusReorder(list, colInfo.order);
            }
            const live = list.children;
            let alreadySorted = true;
            for (let i = 0; i < sorted.length; i++) {
                if (live[i] !== sorted[i]) { alreadySorted = false; break; }
            }
            if (!alreadySorted) {
                listObserverSuspend(list);
                const frag = document.createDocumentFragment();
                for (const li of sorted) frag.appendChild(li);
                list.appendChild(frag);
                listObserverResume(list);
            }
        }
    }

    // ----- Per-list observer -----
    const listObservers = new WeakMap();
    const listObserverSuspended = new WeakSet();
    let immediateSortScheduled = false;

    function listObserverSuspend(list) { listObserverSuspended.add(list); }
    function listObserverResume(list) {
        setTimeout(() => listObserverSuspended.delete(list), 0);
    }
    function scheduleImmediateSort() {
        if (immediateSortScheduled) return;
        immediateSortScheduled = true;
        Promise.resolve().then(() => {
            immediateSortScheduled = false;
            if (!running || !foundWar) return;
            refreshMemberLis();
            if (SORT_ENABLED) { sortLists(); pendingSort = false; }
        });
    }
    function attachListObserver(list) {
        if (listObservers.has(list)) return;
        const obs = new MutationObserver(() => {
            if (listObserverSuspended.has(list)) return;
            pendingSort = true;
            scheduleImmediateSort();
        });
        obs.observe(list, { childList: true });
        listObservers.set(list, obs);
    }
    function attachAllListObservers() {
        for (const list of getMemberLists()) attachListObserver(list);
    }

    /* ═══════════════════════════════════════════════════════════════════════
     * ╔══════════════════════════════════════════════════════════════════╗
     * ║ PART 2: HOSPITAL CALLOUT                                        ║
     * ║ 📢 button per hospital target, modal, templates, send to chat.  ║
     * ╚══════════════════════════════════════════════════════════════════╝
     * ─────────────────────────────────────────────────────────────────── */

    const DEFAULT_TEMPLATES = [
        { name: 'Call X in Y',         body: 'Call on {name} in {time}' },
        { name: 'X out in Y',          body: '{name} out in {time}' },
        { name: 'X available in Y',    body: '{name} is available in {time}' },
        { name: 'X out NOW',           body: '{name} is out now — go go go' },
        { name: 'X out at HH:MM',      body: '{name} out at {clock} TCT' },
        { name: 'Soft on X (Y left)',  body: 'Soft target: {name} ({time} left)' },
    ];

    const settings = {
        templates:     GMget('templates', DEFAULT_TEMPLATES),
        lastTemplate:  GMget('lastTemplate', 0),
        offsetSeconds: GMget('offsetSeconds', 0),
        defaultMode:   GMget('defaultMode', 'remaining'),
    };
    if (!Array.isArray(settings.templates) || settings.templates.length === 0) {
        settings.templates = DEFAULT_TEMPLATES.slice();
        GMset('templates', settings.templates);
    }

    // ----- Styles (callout + visual highlights from TWSE) -----
    addStyle(`
        /* === TWSE visual highlights === */
        .members-list li:has(div.status[data-twse-highlight="true"]) {
            background-color: #99EB99 !important;
        }
        .members-list li:has(div.status[data-twse-status-differs="true"]) {
            background-color: #C4974C !important;
        }
        .members-list div.status[data-twse-traveling="true"]::after {
            color: #696026 !important;
        }
        :root .dark-mode .members-list li:has(div.status[data-twse-highlight="true"]) {
            background-color: #446944 !important;
        }
        :root .dark-mode .members-list li:has(div.status[data-twse-status-differs="true"]) {
            background-color: #795315 !important;
        }
        :root .dark-mode .members-list div.status[data-twse-traveling="true"]::after {
            color: #FFED76 !important;
        }

        /* === TWSE countdown rendering: hide native text, draw --twse-content in ::after === */
        .members-list div.status {
            position: relative !important;
            color: transparent !important;
        }
        .members-list div.status::after {
            content: var(--twse-content);
            position: absolute;
            top: 0;
            left: 0;
            width: calc(100% - 10px);
            height: 100%;
            background: inherit;
            display: flex;
            right: 10px;
            justify-content: flex-end;
            align-items: center;
            pointer-events: none;  /* let clicks pass through to our callout button below */
            z-index: 1;
        }
        .members-list .ok.status::after        { color: var(--user-status-green-color); }
        .members-list .not-ok.status::after    { color: var(--user-status-red-color); }
        .members-list .abroad.status::after,
        .members-list .traveling.status::after { color: var(--user-status-blue-color); }

        /* === Callout button: positioned in the gutter between the Status
              and Attack columns. Sits on the right edge of the status div,
              overflowing slightly past it into the empty gap. Timer text
              keeps its right-aligned spot but gets a little more breathing
              room on the right thanks to the shifted ::after. === */
        .members-list li.has-callout div.status::after {
            /* shift the timer text leftward so the gutter sits between text and button */
            right: 36px !important;
            width: calc(100% - 36px) !important;
        }
        .twt-callout-btn {
            position: absolute;
            top: 50%;
            right: -2px;                 /* nudge into the inter-column gutter */
            transform: translateY(-50%);
            display: inline-flex; align-items: center; justify-content: center;
            width: 24px; height: 22px;
            padding: 0;
            font-size: 13px; line-height: 1;
            background: linear-gradient(180deg, #4a78b8 0%, #2c4d7d 100%);
            color: #fff !important; text-decoration: none !important;
            border: 1px solid #1b3358; border-radius: 3px;
            cursor: pointer; user-select: none;
            box-shadow: 0 1px 0 rgba(255,255,255,0.15) inset, 0 2px 5px rgba(0,0,0,0.55);
            transition: filter 0.1s ease, transform 0.1s ease;
            z-index: 5;
            /* status div has overflow that could clip us — make sure we stay on top */
        }
        /* The status div might clip absolute children — disable that just for hospital rows */
        .members-list li.has-callout div.status {
            overflow: visible !important;
        }
        .twt-callout-btn:hover  { filter: brightness(1.2); transform: translateY(-50%) scale(1.1); }
        .twt-callout-btn:active { filter: brightness(0.9); transform: translateY(-50%) scale(0.95); }

        /* === Modal === */
        .twt-modal-bg {
            position: fixed; inset: 0; background: rgba(0,0,0,0.55);
            z-index: 999999; display: flex; align-items: center; justify-content: center;
            font-family: Arial, Helvetica, sans-serif;
        }
        .twt-modal {
            background: #1e1e1e; color: #ddd; border: 1px solid #444; border-radius: 6px;
            width: 92vw; max-width: 420px; padding: 14px 16px 12px;
            box-shadow: 0 8px 32px rgba(0,0,0,0.6);
            font-size: 13px;
        }
        .twt-modal h2 {
            font-size: 14px; margin: 0 0 10px; padding-bottom: 8px;
            border-bottom: 1px solid #333; color: #7ab3ff;
        }
        .twt-modal label { display: block; margin: 8px 0 3px; font-size: 11px; color: #aaa; text-transform: uppercase; letter-spacing: 0.5px; }
        .twt-modal select, .twt-modal input[type=text], .twt-modal input[type=number], .twt-modal textarea {
            width: 100%; box-sizing: border-box; padding: 6px 8px;
            background: #111; color: #eee; border: 1px solid #444; border-radius: 3px;
            font-size: 13px; font-family: inherit;
        }
        .twt-modal textarea { resize: vertical; min-height: 54px; }
        .twt-row { display: flex; gap: 8px; }
        .twt-row > * { flex: 1; }
        .twt-preview {
            margin-top: 8px; padding: 8px 10px; background: #0d1c2e; border: 1px solid #284970;
            border-radius: 3px; color: #cfe4ff; font-size: 13px; min-height: 18px; word-wrap: break-word;
        }
        .twt-buttons { display: flex; gap: 8px; margin-top: 12px; justify-content: flex-end; }
        .twt-buttons button {
            padding: 6px 14px; border-radius: 3px; cursor: pointer; font-size: 12px;
            border: 1px solid #444; background: #2a2a2a; color: #ddd;
        }
        .twt-buttons button.primary { background: linear-gradient(180deg, #4a78b8 0%, #2c4d7d 100%); border-color: #1b3358; color: #fff; }
        .twt-buttons button.danger  { background: #5a2a2a; border-color: #7a3a3a; color: #fff; }
        .twt-buttons button:hover   { filter: brightness(1.15); }

        .twt-settings-link { display: block; text-align: right; margin-top: 8px; font-size: 11px; color: #7ab3ff; cursor: pointer; text-decoration: underline; }

        .twt-tpl-row { display: flex; gap: 6px; margin-bottom: 4px; }
        .twt-tpl-row input { flex: 1; }
        .twt-tpl-row button { padding: 0 8px; }
        .twt-tpl-add { margin-top: 6px; padding: 4px 10px; font-size: 11px; cursor: pointer; background: #2a4a2a; color: #cfc; border: 1px solid #3a6a3a; border-radius: 3px; }
        .twt-help { font-size: 11px; color: #888; margin-top: 4px; line-height: 1.4; }
        .twt-toggle-row { margin: 6px 0; }
        .twt-switch { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #ddd; cursor: pointer; text-transform: none; letter-spacing: normal; margin: 0; }
        .twt-switch input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; margin: 0; accent-color: #4a78b8; }

        .twt-toast {
            position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
            background: #2a4a7a; color: #fff; padding: 8px 16px; border-radius: 4px;
            font-size: 13px; z-index: 1000000; box-shadow: 0 2px 8px rgba(0,0,0,0.5);
            pointer-events: none; opacity: 0; transition: opacity 0.2s ease;
        }
        .twt-toast.show { opacity: 1; }

        /* === Floating ⚙ settings gear (always visible) === */
        .twt-gear {
            position: fixed;
            bottom: 70px; right: 12px;
            width: 42px; height: 42px;
            border-radius: 50%;
            background: linear-gradient(180deg, #4a78b8 0%, #2c4d7d 100%);
            color: #fff;
            border: 1px solid #1b3358;
            box-shadow: 0 2px 8px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.15) inset;
            font-size: 20px; line-height: 1;
            cursor: pointer; user-select: none;
            display: flex; align-items: center; justify-content: center;
            z-index: 99998;
            transition: transform 0.15s ease, filter 0.15s ease;
        }
        .twt-gear:hover  { filter: brightness(1.2); transform: scale(1.08); }
        .twt-gear:active { transform: scale(0.95); }
        .twt-gear-label {
            position: absolute; right: 52px; top: 50%; transform: translateY(-50%);
            background: rgba(0,0,0,0.85); color: #fff;
            padding: 4px 10px; border-radius: 4px; font-size: 11px;
            white-space: nowrap; pointer-events: none;
            opacity: 0; transition: opacity 0.15s ease;
        }
        .twt-gear:hover .twt-gear-label { opacity: 1; }
    `);

    // ----- Helpers -----
    const $  = (sel, root = document) => root.querySelector(sel);
    const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));

    function pad2(n) { return n < 10 ? '0' + n : '' + n; }
    function formatDuration(totalSec) {
        totalSec = Math.max(0, Math.round(totalSec));
        const h = Math.floor(totalSec / 3600);
        const m = Math.floor((totalSec % 3600) / 60);
        const s = totalSec % 60;
        if (h > 0) return `${h}h ${pad2(m)}m`;
        if (m > 0) return s === 0 ? `${m}m` : `${m}m ${pad2(s)}s`;
        return `${s}s`;
    }
    function formatTctClock(unixSeconds) {
        const d = new Date(unixSeconds * 1000);
        return pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes());
    }
    function buildMessage(template, ctx) {
        return template
            .replace(/\{name\}/g, ctx.name)
            .replace(/\{time\}/g,  ctx.time)
            .replace(/\{clock\}/g, ctx.clock)
            .replace(/\{id\}/g,    ctx.id);
    }
    function escapeHtml(s) {
        return String(s == null ? '' : s)
            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    }
    function showToast(msg, duration = 1800) {
        let t = $('.twt-toast');
        if (!t) { t = document.createElement('div'); t.className = 'twt-toast'; document.body.appendChild(t); }
        t.textContent = msg;
        requestAnimationFrame(() => t.classList.add('show'));
        clearTimeout(t._h);
        t._h = setTimeout(() => t.classList.remove('show'), duration);
    }

    function copyToClipboard(text) {
        try { navigator.clipboard.writeText(text); return true; }
        catch (e) {
            try {
                const ta = document.createElement('textarea');
                ta.value = text; document.body.appendChild(ta); ta.select();
                document.execCommand('copy'); ta.remove(); return true;
            } catch (e2) { return false; }
        }
    }

    // ----- Send to faction chat -----
    function sendToFactionChat(text) {
        const candidates = $$('textarea.tt-chat-autocomplete, .root___dCIzf textarea, textarea[placeholder*="Type your message"]');
        const ta = candidates.find(t => t.offsetParent !== null) || candidates[0];
        if (!ta) { showToast('⚠ Faction chat panel not open. Open it first.'); return false; }

        const proto = window.HTMLTextAreaElement && window.HTMLTextAreaElement.prototype;
        const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value');
        const setter = desc && desc.set;
        if (setter) setter.call(ta, text); else ta.value = text;
        ta.dispatchEvent(new Event('input',  { bubbles: true }));
        ta.dispatchEvent(new Event('change', { bubbles: true }));
        ta.focus();

        const fire = (type) => ta.dispatchEvent(new KeyboardEvent(type, {
            key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
        }));
        fire('keydown'); fire('keypress'); fire('keyup');

        setTimeout(() => {
            if (ta.value && ta.value.length > 0) {
                const btn = ta.parentElement && ta.parentElement.querySelector('button.iconWrapper___SHmCW, button[type="button"]');
                if (btn && !btn.disabled) btn.click();
            }
        }, 30);
        return true;
    }

    // ----- Extract row info for callout (uses TWSE data we now populate ourselves) -----
    function extractRowForCallout(li) {
        const pid = li.getAttribute(ATTR_PID);
        if (!pid) return null;
        const profileLink = li.querySelector('a[href*="profiles.php?XID="]');
        let name = '';
        if (profileLink) {
            const aria = profileLink.getAttribute('aria-label') || '';
            const m1 = aria.match(/View profile of (.+)$/);
            if (m1) name = m1[1].trim();
            if (!name) {
                const ns = profileLink.querySelector('.honor-text');
                if (ns) name = ns.textContent.trim();
            }
        }
        if (!name) name = 'Player ' + pid;

        const untilSec = parseInt(li.getAttribute(ATTR_UNTIL), 10) || 0;
        const isYourFaction = li.classList.contains('your') || /\byour___/.test(li.className);
        return { pid, name, untilSec, isYourFaction, li };
    }

    // ----- Inject/remove callout button on a row -----
    function ensureCalloutOnRow(li, statusDiv) {
        if (!statusDiv) statusDiv = li.querySelector('DIV.status');
        if (!statusDiv) return;
        if (statusDiv.querySelector('.twt-callout-btn')) return;

        const pid = li.getAttribute(ATTR_PID);
        if (!pid) return;

        li.classList.add('has-callout');

        const btn = document.createElement('a');
        btn.className = 'twt-callout-btn';
        btn.href = 'javascript:void(0)';
        btn.title = 'Callout';
        btn.textContent = '📢';
        btn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            const row = extractRowForCallout(li);
            if (!row) return;
            openCalloutModal({
                id: row.pid, name: row.name, untilSec: row.untilSec,
                faction: row.isYourFaction ? 'your' : 'enemy',
            });
        });
        statusDiv.appendChild(btn);
    }

    function removeCalloutFromRow(li) {
        const btn = li.querySelector('.twt-callout-btn');
        if (btn) btn.remove();
        li.classList.remove('has-callout');
    }

    // ----- Modal: build & send callout -----
    function openCalloutModal(ctx) {
        const bg = document.createElement('div');
        bg.className = 'twt-modal-bg';
        bg.addEventListener('click', (e) => { if (e.target === bg) bg.remove(); });

        const m = document.createElement('div');
        m.className = 'twt-modal';
        bg.appendChild(m);

        m.innerHTML = `
            <h2>📢 Callout — ${escapeHtml(ctx.name)}</h2>

            <label>Template</label>
            <select class="twt-tpl-select">
                ${settings.templates.map((t, i) => `<option value="${i}">${escapeHtml(t.name)}</option>`).join('')}
            </select>

            <label>Time mode</label>
            <div class="twt-row">
                <select class="twt-mode">
                    <option value="remaining">Hospital timer</option>
                    <option value="offsetNow">In X minutes (from now)</option>
                    <option value="clock">At specific TCT time</option>
                </select>
                <input type="number" class="twt-offset" min="-60" max="240" step="1" value="${settings.offsetSeconds / 60}" title="Minutes to add (+ later, − earlier)">
            </div>
            <div class="twt-help">Hospital timer mode uses their actual release time + offset (minutes). "In X minutes" ignores the timer.</div>

            <label>Message</label>
            <textarea class="twt-msg" rows="2"></textarea>

            <label>Preview</label>
            <div class="twt-preview"></div>

            <div class="twt-buttons">
                <button type="button" class="copy">Copy</button>
                <button type="button" class="cancel">Cancel</button>
                <button type="button" class="primary send">Send to faction chat</button>
            </div>
            <span class="twt-settings-link">⚙ Manage templates &amp; defaults</span>
        `;

        const tplSelect  = $('.twt-tpl-select', m);
        const modeSelect = $('.twt-mode', m);
        const offsetInput= $('.twt-offset', m);
        const msgInput   = $('.twt-msg', m);
        const preview    = $('.twt-preview', m);

        tplSelect.value = String(Math.min(settings.lastTemplate, settings.templates.length - 1));
        modeSelect.value = settings.defaultMode;

        function recompute() {
            const tplIdx = parseInt(tplSelect.value, 10) || 0;
            const tpl = settings.templates[tplIdx] || settings.templates[0];
            msgInput.value = msgInput.dataset.dirty ? msgInput.value : tpl.body;

            const offsetMin = parseFloat(offsetInput.value) || 0;
            const offsetSec = Math.round(offsetMin * 60);
            const nowSec = Math.floor(Date.now() / 1000);
            const mode = modeSelect.value;
            let targetSec;
            if (mode === 'remaining') {
                targetSec = (ctx.untilSec && ctx.untilSec > 0) ? (ctx.untilSec + offsetSec) : (nowSec + offsetSec);
            } else {
                targetSec = nowSec + offsetSec;
            }
            const remainSec = targetSec - nowSec;

            const built = buildMessage(msgInput.value, {
                name: ctx.name, id: ctx.id,
                time:  formatDuration(remainSec),
                clock: formatTctClock(targetSec),
            });
            preview.textContent = built;
            return built;
        }

        tplSelect.addEventListener('change', () => { delete msgInput.dataset.dirty; recompute(); });
        modeSelect.addEventListener('change', recompute);
        offsetInput.addEventListener('input', recompute);
        msgInput.addEventListener('input', () => { msgInput.dataset.dirty = '1'; recompute(); });

        recompute();

        $('.cancel', m).addEventListener('click', () => bg.remove());
        $('.copy', m).addEventListener('click', () => {
            const text = recompute();
            showToast(copyToClipboard(text) ? '📋 Copied to clipboard' : '⚠ Copy failed');
        });
        $('.send', m).addEventListener('click', () => {
            const text = recompute();
            settings.lastTemplate  = parseInt(tplSelect.value, 10) || 0;
            settings.offsetSeconds = (parseFloat(offsetInput.value) || 0) * 60;
            settings.defaultMode   = modeSelect.value;
            GMset('lastTemplate',  settings.lastTemplate);
            GMset('offsetSeconds', settings.offsetSeconds);
            GMset('defaultMode',   settings.defaultMode);
            if (sendToFactionChat(text)) {
                showToast('✓ Sent: ' + text.slice(0, 40) + (text.length > 40 ? '…' : ''));
                bg.remove();
            }
        });
        $('.twt-settings-link', m).addEventListener('click', () => { bg.remove(); openSettings(); });

        document.body.appendChild(bg);
        msgInput.focus();
        msgInput.setSelectionRange(msgInput.value.length, msgInput.value.length);
    }

    // ----- Unified Settings modal (templates + Hosp First + API key) -----
    function openSettings() {
        const bg = document.createElement('div');
        bg.className = 'twt-modal-bg';
        bg.addEventListener('click', (e) => { if (e.target === bg) bg.remove(); });

        const m = document.createElement('div');
        m.className = 'twt-modal';
        m.style.maxWidth = '500px';
        bg.appendChild(m);

        m.innerHTML = `
            <h2>⚙ Torn War Tools — Settings</h2>

            <label style="margin-top:0;">War list behavior</label>
            <div class="twt-toggle-row">
                <label class="twt-switch">
                    <input type="checkbox" class="twt-hosp-first">
                    <span>Hosp First: pin hospitalized players to top of war list</span>
                </label>
            </div>

            <label style="margin-top:14px;">API key</label>
            <div class="twt-row" style="align-items:center;">
                <input type="text" class="twt-apikey" placeholder="16-char public API key">
                <button type="button" class="twt-apikey-save" style="flex:0 0 auto; padding:6px 12px; background:#2a4a7a; color:#fff; border:1px solid #3a5a8a; border-radius:3px; cursor:pointer;">Save key</button>
            </div>
            <div class="twt-help">Used only to read faction member status. Public key is sufficient.</div>

            <label style="margin-top:14px;">Callout templates</label>
            <div class="twt-help" style="margin-top:0; margin-bottom:6px;">
                Placeholders: <b>{name}</b>, <b>{time}</b> (e.g. "5m 12s"), <b>{clock}</b> (HH:MM TCT), <b>{id}</b>.
            </div>
            <div class="twt-tpl-list"></div>
            <button type="button" class="twt-tpl-add">+ Add template</button>

            <label style="margin-top:14px;">Callout defaults</label>
            <div class="twt-row">
                <div>
                    <label style="margin-top:0;">Time mode</label>
                    <select class="twt-def-mode">
                        <option value="remaining">Hospital timer</option>
                        <option value="offsetNow">In X minutes (from now)</option>
                    </select>
                </div>
                <div>
                    <label style="margin-top:0;">Offset (min)</label>
                    <input type="number" class="twt-def-offset" min="-60" max="240" step="1">
                </div>
            </div>

            <div class="twt-buttons">
                <button type="button" class="danger reset">Reset to defaults</button>
                <button type="button" class="cancel">Close</button>
                <button type="button" class="primary save">Save</button>
            </div>
        `;

        // ----- Templates list -----
        const list = $('.twt-tpl-list', m);
        const renderList = () => {
            list.innerHTML = '';
            settings.templates.forEach((t, i) => {
                const row = document.createElement('div');
                row.className = 'twt-tpl-row';
                row.innerHTML = `
                    <input type="text" class="tpl-name" placeholder="Label" value="${escapeHtml(t.name)}" style="flex:0 0 30%;">
                    <input type="text" class="tpl-body" placeholder="Body (use {name}, {time}, {clock})" value="${escapeHtml(t.body)}">
                    <button type="button" class="tpl-del" title="Delete">✕</button>
                `;
                $('.tpl-name', row).addEventListener('input', e => { settings.templates[i].name = e.target.value; });
                $('.tpl-body', row).addEventListener('input', e => { settings.templates[i].body = e.target.value; });
                $('.tpl-del', row).addEventListener('click', () => {
                    settings.templates.splice(i, 1);
                    if (settings.templates.length === 0) settings.templates.push({ name: 'Custom', body: '{name} out in {time}' });
                    renderList();
                });
                list.appendChild(row);
            });
        };
        renderList();

        $('.twt-tpl-add', m).addEventListener('click', () => {
            settings.templates.push({ name: 'New template', body: '{name} — {time}' });
            renderList();
        });

        // ----- Hosp First -----
        $('.twt-hosp-first', m).checked = hospFirstEnabled;
        $('.twt-hosp-first', m).addEventListener('change', (e) => {
            setHospFirst(!!e.target.checked);
            scheduleImmediateSort();
            showToast(`Hosp First: ${hospFirstEnabled ? 'ON' : 'OFF'}`);
        });

        // ----- API key -----
        $('.twt-apikey', m).value = (apiKey && !apiKey.includes('PDA-APIKEY')) ? apiKey : '';
        $('.twt-apikey-save', m).addEventListener('click', () => {
            const v = $('.twt-apikey', m).value.trim();
            if (v.length !== 16) { showToast('⚠ Key must be exactly 16 characters'); return; }
            apiKey = v;
            localStorage.setItem(STORAGE_KEY_API, v);
            showToast('✓ API key saved');
            // Trigger an immediate refresh
            lastRequestAt = null;
            updateStatuses();
        });

        // ----- Callout defaults -----
        $('.twt-def-mode', m).value = settings.defaultMode;
        $('.twt-def-offset', m).value = settings.offsetSeconds / 60;

        // ----- Bottom buttons -----
        $('.cancel', m).addEventListener('click', () => bg.remove());
        $('.reset', m).addEventListener('click', () => {
            if (!confirm('Reset templates and callout defaults to factory values?\n(API key and Hosp First setting are NOT reset.)')) return;
            settings.templates     = DEFAULT_TEMPLATES.slice();
            settings.defaultMode   = 'remaining';
            settings.offsetSeconds = 0;
            settings.lastTemplate  = 0;
            GMset('templates',     settings.templates);
            GMset('defaultMode',   settings.defaultMode);
            GMset('offsetSeconds', settings.offsetSeconds);
            GMset('lastTemplate',  settings.lastTemplate);
            renderList();
            $('.twt-def-mode', m).value = settings.defaultMode;
            $('.twt-def-offset', m).value = 0;
            showToast('Defaults restored');
        });
        $('.save', m).addEventListener('click', () => {
            settings.templates = settings.templates.filter(t => (t.name || '').trim() && (t.body || '').trim());
            if (settings.templates.length === 0) settings.templates = DEFAULT_TEMPLATES.slice();
            settings.defaultMode   = $('.twt-def-mode', m).value;
            settings.offsetSeconds = (parseFloat($('.twt-def-offset', m).value) || 0) * 60;
            GMset('templates',     settings.templates);
            GMset('defaultMode',   settings.defaultMode);
            GMset('offsetSeconds', settings.offsetSeconds);
            showToast('✓ Settings saved');
            bg.remove();
        });

        document.body.appendChild(bg);
    }

    // ----- Floating settings gear -----
    function addSettingsGear() {
        if (document.querySelector('.twt-gear')) return;
        if (!document.body) return;
        const gear = document.createElement('div');
        gear.className = 'twt-gear';
        gear.title = 'Torn War Tools — settings';
        gear.innerHTML = '⚙<span class="twt-gear-label">War Tools settings</span>';
        gear.addEventListener('click', (e) => {
            e.preventDefault(); e.stopPropagation();
            openSettings();
        });
        document.body.appendChild(gear);
    }

    /* ═══════════════════════════════════════════════════════════════════════
     * MAIN WATCH LOOP — drives status processing, sorting, and callout
     * injection/removal. Runs every TIME_BETWEEN_FRAMES ms.
     * ─────────────────────────────────────────────────────────────────── */
    function watch() {
        if (foundWar) {
            const replaced = refreshMemberLis();
            if (replaced) pendingSort = true;
            attachAllListObservers();
            addSettingsGear();
        }

        for (const [id, ref] of memberLis) {
            const { li, div: statusDiv } = ref;
            if (!li || !statusDiv || !li.isConnected) continue;

            const status = memberStatus.get(id);
            if (!status || !running) {
                setContent(statusDiv, statusDiv.textContent);
                continue;
            }

            queueAttr(li, ATTR_UNTIL, status.until ?? 0, false, false);
            queueAttr(li, ATTR_SINCE, status.since ?? 0, false, false);

            switch (status.state) {
                case 'Abroad':
                case 'Traveling':
                    processTraveling(li, statusDiv, status, false);
                    break;
                case 'Hospital':
                case 'Jail':
                    processHospitalOrJail(li, statusDiv, status, false);
                    break;
                default:
                    processOkay(li, statusDiv, false);
                    break;
            }
        }

        applyDeferredWrites();

        if (SORT_ENABLED && pendingSort) {
            sortLists();
            pendingSort = false;
        }

        for (const [id, ref] of memberLis) {
            if (!ref.li.isConnected) memberLis.delete(id);
        }
    }

    /* ═══════════════════════════════════════════════════════════════════════
     * BOOTSTRAP: watch for #faction_war_list_id and .faction-war
     * ─────────────────────────────────────────────────────────────────── */
    const factionWarObserver = new MutationObserver((mutations) => {
        for (const mut of mutations) for (const node of mut.addedNodes) factionWarCheck(node);
    });
    const descriptionsObserver = new MutationObserver((mutations) => {
        for (const mut of mutations) {
            for (const node of mut.addedNodes) {
                if (node.classList?.contains('descriptions')) {
                    factionWarObserver.observe(node, { childList: true, subtree: true });
                    for (const child of node.childNodes) factionWarCheck(child);
                }
            }
            for (const node of mut.removedNodes) {
                if (node.classList?.contains('descriptions')) factionWarObserver.disconnect();
            }
        }
    });
    function factionWarCheck(node) {
        if (!node.classList?.contains('faction-war')) return;
        foundWar = true;
        extractAllMemberLis();
        attachAllListObservers();
        addSettingsGear();
        for (const fid of getFactionIds()) populateCachedStatus(fid);
        updateStatuses();
        factionWarObserver.disconnect();
    }
    function foundFactionWarList(factwarlist) {
        if (factwarlist.querySelector('.faction-war')) {
            foundWar = true;
            extractAllMemberLis();
            attachAllListObservers();
            addSettingsGear();
            for (const fid of getFactionIds()) populateCachedStatus(fid);
            updateStatuses();
            return;
        }
        if (foundWar) return;
        descriptionsObserver.observe(factwarlist, { childList: true });
        const descriptions = factwarlist.querySelector('.descriptions');
        if (descriptions) factionWarObserver.observe(descriptions, { childList: true, subtree: true });
    }

    const documentObserver = new MutationObserver(() => {
        const factwarlist = document.querySelector('#faction_war_list_id');
        if (factwarlist) {
            foundFactionWarList(factwarlist);
            documentObserver.disconnect();
        }
    });

    const existing = document.querySelector('#faction_war_list_id');
    if (existing) {
        foundFactionWarList(existing);
        documentObserver.disconnect();
    }
    if (document.body) documentObserver.observe(document.body, { subtree: true, childList: true });

    setTimeout(() => {
        const fwl = document.querySelector('#faction_war_list_id');
        if (fwl) foundFactionWarList(fwl);
        documentObserver.disconnect();
    }, INITIAL_WAIT_FOR_WARLIST);

    // Ensure the gear shows up even if the war list never loads (so settings stays reachable)
    addSettingsGear();
    setInterval(addSettingsGear, 2000);

    // Tickers
    setInterval(() => { if (running && foundWar) updateStatuses(); }, MIN_TIME_SINCE_LAST_REQUEST);
    setInterval(() => { if (foundWar && running && pageVisible) watch(); }, TIME_BETWEEN_FRAMES);

    cleanCachedStatuses();
    window.dispatchEvent(new Event('FFScouterV2DisableWarMonitor'));

    // Global hook for console access
    try {
        const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        w.TornWarTools = { openSettings, setHospFirst, version: '2.0.1' };
    } catch (e) { window.TornWarTools = { openSettings, setHospFirst, version: '2.0.1' }; }

    console.log('[TornWarTools] v2.0.1 initialized. Click the ⚙ gear (bottom-right) for settings.');
})();