Torn Loadout Share

Shared defender loadouts on Torn attack pages (desktop + Torn PDA). Firebase, no registration.

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

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

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

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

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

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Torn Loadout Share
// @namespace    loadout
// @version      1.1.4
// @description  Shared defender loadouts on Torn attack pages (desktop + Torn PDA). Firebase, no registration.
// @license      MIT
// @supportURL   https://greasyfork.org/en/scripts/570314/feedback
// @homepageURL  https://greasyfork.org/en/scripts/570314-torn-loadout-share
// @match        https://www.torn.com/page.php?sid=attack&user2ID=*
// @match        https://www.torn.com/loader.php?sid=attack&user2ID=*
// @connect      torn-loadout-share-default-rtdb.europe-west1.firebasedatabase.app
// @run-at       document-end
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// ==/UserScript==
 
(function () {
    "use strict";

    const W = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
    if (W.__loadoutShareInit) return;
    W.__loadoutShareInit = true;
    const IS_PDA = !!(W.flutter_inappwebview?.callHandler);
    // LOG_LEVEL: 0 = none, 1 = log, 2 = debug
    const LOG_LEVEL = 0;
    let EFFECTIVE_LOG_LEVEL = LOG_LEVEL;
    if (W.__loadoutDebug || W.__loadoutPdaDebug || /[?&]loadout_debug=1/.test(W.location?.search || "")) {
        EFFECTIVE_LOG_LEVEL = Math.max(EFFECTIVE_LOG_LEVEL, 2);
    }
    function log(...args) { if (EFFECTIVE_LOG_LEVEL >= 1 && W.console?.log) W.console.log("[Loadout]", ...args); }
    function dbg(...args) { if (EFFECTIVE_LOG_LEVEL >= 2 && W.console?.log) W.console.log("[Loadout]", ...args); }
    const BONUS_KEY_FIX = { hazarfouse: "hazardous" }; // Torn CSS class typos
    const FIREBASE_FETCH_TIMEOUT_MS = 5000;
    const TORN_BASE = "https://www.torn.com";
    const CONFIG = {
        firebaseUrl: "https://torn-loadout-share-default-rtdb.europe-west1.firebasedatabase.app",
        imagesBase: `${TORN_BASE}/images`,
    };
    const WEAPON_SLOTS = [1, 2, 3, 5];
    // Slot 10 is alternate helmet/mask, but Torn treats it as body cosmetics on many pages.
    // We intentionally ignore it across parsing/validation/rendering.
    const ARMOR_SLOTS = [8, 7, 9, 6, 4];
    const DOM_POLL_MS = 100;
    const PREFIGHT_REAPPLY_MS = 250;
    const RENDER_MAX_MS = 15000;
    const PREFIGHT_RENDER_MAX_MS = 120000;
    const FALLBACK_DELAYS_MS = [1500, 3000, 5000];
    const UPLOAD_RETRY_DELAYS_MS = [1500, 3500, 7000];
    const ARMOR_Z_INDEX = { 8: 10, 7: 11, 9: 12, 6: 13, 4: 14 };
    const ARMOR_MASK_SLOTS = new Set([8, 6, 4]);
    const FALLBACK_ARMOR_DOM = {
        container: "armourContainer___ftMzt",
        armour: "armour___wqLa7",
        img: "itemImg___r9DqK",
        mask: "mask___rDyND",
    };
    const SILHOUETTES = { 1: "primary", 2: "secondary", 3: "melee", 5: "temporary" };
    const ARMOR_MAP_NAME = "loadout-armor-map";
    const ARMOR_SLOT_AREAS = {
        4: [{ coords: "119,79,99,73,80,96,62,131,54,150,52,167,62,169,79,138,91,118,99,142,95,159,143,161,144,143,148,118,162,141,174,166,187,165,176,129,162,95,140,75" }],
        6: [{ coords: "118,77,104,67,99,52,104,36,118,26,132,32,136,51,133,69" }],
        7: [{ coords: "94,162,145,162,157,204,154,239,150,261,156,275,150,301,136,303,131,283,121,209,109,284,105,300,89,299,85,276,87,257,84,236,85,201" }],
        8: [
            { coords: "87,300,89,322,86,336,78,349,88,354,99,354,104,340,106,325,105,302" },
            { coords: "136,304,153,300,151,318,153,330,160,343,153,352,138,353,132,330" },
        ],
        9: [
            { coords: "48,203,55,192,62,195,67,192,61,172,50,169,44,183,40,203" },
            { coords: "175,171,189,170,196,185,198,200,191,202,184,191,177,196,176,180" },
        ],
    };
 
    const STATE = {
        uploaded: false,
        uploadInProgress: false,
        uploadRetryCount: 0,
        attackData: null,
        loadoutRendered: false,
        applyTimerId: null,
    };
 
    const PROPS_ROW_STYLE = "display:flex;flex-direction:row;align-items:center;justify-content:center;gap:2px;";
    const TOP_ROW_STYLE = "display:flex;flex-direction:row;align-items:center;justify-content:space-between;width:100%;";
    const PANEL_ID = "loadout-defender-panel";
    const PANEL_BTN_ID = "loadout-defender-panel-toggle";
    const LS_KEY_COLLAPSED = "loadout_panel_collapsed_v1";
 
    function bonusToLabel(b) {
        if (!b || typeof b !== "object") return "";
        return String(b.name || b.bonus_key || b.key || "").trim();
    }
 
    function formatWeaponBonuses(item) {
        const b1 = bonusToLabel(item?.bonuses?.[0]);
        const b2 = bonusToLabel(item?.bonuses?.[1]);
        if (!b1 && !b2) return "";
        if (b1 && b2) return ` (${b1} + ${b2})`;
        return ` (${b1 || b2})`;
    }
 
    function buildWeaponRow(item) {
        if (!item) return "";
        const name = escapeHtml(item.item_name || "Unknown");
        const bonuses = formatWeaponBonuses(item);
        return `<div style="color:#f2f5ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}${escapeHtml(bonuses)}</div>`;
    }
 
    function buildArmorRow(item) {
        if (!item) return "";
        const name = escapeHtml(item.item_name || "");
        const bonuses = formatWeaponBonuses(item);
        return `<div style="color:#f2f5ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}${escapeHtml(bonuses)}</div>`;
    }
 
    function renderDefenderPanel(loadout, submittedBy, savedAt) {
        if (!loadout || typeof loadout !== "object") return;
 
        const collapsed = (() => {
            try {
                return W.localStorage?.getItem(LS_KEY_COLLAPSED) === "1";
            } catch {
                return false;
            }
        })();
 
        let panel = W.document.getElementById(PANEL_ID);
        if (!panel) {
            panel = W.document.createElement("div");
            panel.id = PANEL_ID;
            panel.style.cssText =
                "position:fixed;top:12px;right:12px;z-index:999999;" +
                "background:rgba(0,0,0,0.70);color:#fff;" +
                "border:1px solid rgba(255,255,255,0.14);" +
                "border-radius:10px;overflow:hidden;" +
                "backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);";
 
            const btn = W.document.createElement("div");
            btn.id = PANEL_BTN_ID;
            btn.style.cssText =
                "display:flex;align-items:center;justify-content:space-between;" +
                "gap:10px;padding:10px 12px;" +
                "cursor:pointer;background:rgba(15,23,34,0.55);";
 
            const left = W.document.createElement("div");
            left.style.cssText = "font-weight:900;letter-spacing:0.2px;";
            left.textContent = "Defender Loadout";
 
            const right = W.document.createElement("div");
            right.style.cssText = "font-weight:900;color:#7bcf9a;";
            right.textContent = collapsed ? "▸" : "▾";
 
            btn.appendChild(left);
            btn.appendChild(right);
 
            const body = W.document.createElement("div");
            body.id = `${PANEL_ID}-body`;
            body.style.cssText = "padding:10px 12px;display:flex;flex-direction:column;gap:10px;";
 
            panel.appendChild(btn);
            panel.appendChild(body);
 
            W.document.body.appendChild(panel);
 
            btn.onclick = () => {
                try {
                    W.localStorage?.setItem(LS_KEY_COLLAPSED, panel.dataset.collapsed === "1" ? "0" : "1");
                } catch {}
                const nextCollapsed = panel.dataset.collapsed !== "1";
                panel.dataset.collapsed = nextCollapsed ? "1" : "0";
                body.style.display = nextCollapsed ? "none" : "flex";
                right.textContent = nextCollapsed ? "▸" : "▾";
            };
        }
 
        const body = W.document.getElementById(`${PANEL_ID}-body`);
        if (!body) return;
 
        const weaponsOrder = [1, 2, 3, 5];
        const armorOrder = [6, 4, 7, 8, 9];
 
        const weaponsHtml = weaponsOrder.map((slot) => buildWeaponRow(loadout?.[slot])).filter(Boolean).join("");
        const armorHtml = armorOrder.map((slot) => buildArmorRow(loadout?.[slot])).filter(Boolean).join("");

        const metaParts = [];
        const safeSubmitterId = submittedBy && /^\d{1,8}$/.test(String(submittedBy)) ? String(submittedBy) : null;
        if (safeSubmitterId) {
            metaParts.push(
                `<a href="${TORN_BASE}/profiles.php?XID=${safeSubmitterId}" target="_blank" rel="noopener" ` +
                `style="color:#f88;text-decoration:none;">shared by ${escapeHtml(safeSubmitterId)}</a>`
            );
        }
        if (savedAt != null && Number.isFinite(Number(savedAt))) {
            const ms = Number(savedAt) > 1e12 ? Number(savedAt) : Number(savedAt) * 1000;
            metaParts.push(`<span style="color:#aaa;">saved ${escapeHtml(relativeTime(Date.now() - ms))}</span>`);
        }
        const metaHtml = metaParts.length
            ? `<div style="display:flex;flex-direction:column;gap:4px;font-size:11px;font-weight:600;margin-bottom:4px;">${metaParts.join("<br>")}</div>`
            : "";

        body.innerHTML = `
            ${metaHtml}
            <div style="display:flex;flex-direction:column;gap:8px;">
                <div style="font-weight:900;color:#7bcf9a;letter-spacing:0.2px;text-transform:uppercase;font-size:11px;">Weapons</div>
                ${weaponsHtml || `<div style="color:rgba(255,255,255,0.7);font-size:12px;">(none)</div>`}
            </div>
            <div style="display:flex;flex-direction:column;gap:8px;">
                <div style="font-weight:900;color:#7bcf9a;letter-spacing:0.2px;text-transform:uppercase;font-size:11px;">Armor / Model</div>
                ${armorHtml || `<div style="color:rgba(255,255,255,0.7);font-size:12px;">(none)</div>`}
            </div>`;
 
        if (collapsed) {
            panel.dataset.collapsed = "1";
            body.style.display = "none";
            const btnRight = W.document.querySelector(`#${PANEL_BTN_ID} div:last-child`);
            if (btnRight) btnRight.textContent = "▸";
        } else {
            panel.dataset.collapsed = "0";
            body.style.display = "flex";
            const btnRight = W.document.querySelector(`#${PANEL_BTN_ID} div:last-child`);
            if (btnRight) btnRight.textContent = "▾";
        }
    }
 
    function getUrlTargetId() {
        try {
            const u = new URL(W.location.href);
            const v = u.searchParams.get("user2ID");
            if (v && /^\d+$/.test(v)) return v;
        } catch {}
        const m = String(W.location?.href || "").match(/(?:^|[?&])user2ID=(\d+)/);
        return m ? m[1] : null;
    }
 
    function getCacheKey(playerId, targetId) {
        return `${playerId}_${targetId}`;
    }
 
    function validateLoadout(loadout) {
        if (!loadout || typeof loadout !== "object") return false;
        const validSlots = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9]);
        for (const [k, item] of Object.entries(loadout)) {
            const slot = Number(k);
            if (!validSlots.has(slot) || !item || typeof item !== "object") return false;
            const id = item.item_id ?? item.ID ?? item.id;
            if (id == null || !Number.isInteger(Number(id)) || Number(id) < 1 || Number(id) > 999999) return false;
            const d = Number(item.damage ?? item.Damage ?? 0);
            const a = Number(item.accuracy ?? item.Accuracy ?? 0);
            if (!Number.isFinite(d) || d < 0 || d > 1e5) return false;
            if (!Number.isFinite(a) || a < 0 || a > 1e5) return false;
            if (item.mods != null && !Array.isArray(item.mods)) return false;
            if (item.bonuses != null && !Array.isArray(item.bonuses)) return false;
        }
        return Object.keys(loadout).length > 0;
    }
 
    function normalizeLoadout(loadout) {
        if (!loadout || typeof loadout !== "object") return loadout;
        // Strip slot 10 early so older Firebase payloads don't trip validation/rendering.
        if (Object.prototype.hasOwnProperty.call(loadout, 10)) delete loadout[10];
        for (const item of Object.values(loadout)) {
            if (!item || typeof item !== "object") continue;
            if (typeof item.mods === "string") {
                try { item.mods = JSON.parse(item.mods); } catch { item.mods = []; }
            }
            const b = item.bonuses ?? item.Bonuses ?? item.bonus;
            item.bonuses = parseBonuses(b);
            if (!Array.isArray(item.mods)) item.mods = [];
        }
        return loadout;
    }
 
    function firebaseRequest(method, path, body) {
        const base = CONFIG.firebaseUrl;
        if (!base) { log("firebaseRequest: no base URL"); return Promise.resolve(null); }
        return new Promise((resolve) => {
            const url = `${base}/${path}.json`;
            const headers = {};
            let bodyStr = null;
            if (body != null && method !== "GET") {
                bodyStr = JSON.stringify(body);
                headers["Content-Type"] = "application/json";
            }
            let settled = false;
            const timer = W.setTimeout(() => {
                if (settled) return;
                settled = true;
                reportError("firebase_request", "Timeout", { method, path });
                resolve(null);
            }, FIREBASE_FETCH_TIMEOUT_MS);
            const done = (v) => {
                if (settled) return;
                settled = true;
                W.clearTimeout(timer);
                resolve(v);
            };
            const parseResponse = (status, text) => {
                if (status >= 200 && status < 300) {
                    try { done(text ? JSON.parse(text) : null); } catch { done(null); }
                } else {
                    log("firebaseRequest: status", status, path);
                    reportError("firebase_request", `HTTP ${status}`, { method, path, status });
                    done(null);
                }
            };
            const runFetch = () => {
                if (typeof W.fetch !== "function") {
                    log("firebaseRequest: no transport available");
                    done(null);
                    return;
                }
                W.fetch(url, { method, headers, ...(bodyStr != null ? { body: bodyStr } : {}) })
                    .then(async (r) => parseResponse(r.status, await r.text()))
                    .catch((e) => {
                        log("firebaseRequest(fetch): error", path, e);
                        reportError("firebase_request", "Network error", { method, path, errType: e?.type ?? "unknown" });
                        done(null);
                    });
            };
            try {
                if (IS_PDA) {
                    waitForPdaBridge(800).then(async (bridge) => {
                        if (bridge?.callHandler) {
                            try {
                                const handler = method === "GET" ? "PDA_httpGet" : "PDA_httpPost";
                                const r = method === "GET"
                                    ? await bridge.callHandler(handler, url, headers)
                                    : await bridge.callHandler(handler, url, headers, bodyStr || "");
                                return parseResponse(Number(r?.status ?? 0), String(r?.responseText ?? ""));
                            } catch {}
                        }
                        runFetch();
                    });
                    return;
                }
                if (typeof GM_xmlhttpRequest === "function") {
                    GM_xmlhttpRequest({
                        method,
                        url,
                        timeout: FIREBASE_FETCH_TIMEOUT_MS,
                        anonymous: true,
                        headers,
                        data: bodyStr,
                        onload: (r) => parseResponse(r.status, r.responseText ?? ""),
                        onerror: (e) => {
                            log("firebaseRequest: onerror", path, e);
                            reportError("firebase_request", "Network error", { method, path, errType: e?.type ?? "unknown" });
                            done(null);
                        },
                    });
                    return;
                }
                runFetch();
            } catch (e) {
                log("firebaseRequest: exception", path, e);
                reportError("firebase_request", e, { method, path });
                done(null);
            }
        });
    }
 
    function reportError(ctx, error, extra = {}) {
        try {
            const errMsg = typeof error === "string" ? error : (error?.message ?? String(error));
            const payload = {
                ctx,
                err: errMsg,
                ts: Date.now(),
                path: (W.location?.pathname ?? "") + (W.location?.search ?? ""),
                user2ID: getUrlTargetId(),
                pid: STATE.attackData?.attackerUser?.userID ?? extra.playerId ?? null,
                tid: STATE.attackData?.defenderUser?.userID ?? extra.targetId ?? null,
            };
            if (extra.method) payload.req = `${extra.method} ${extra.path}`;
            if (extra.status) payload.status = extra.status;
            if (extra.errType) payload.errType = extra.errType;
            if (extra.hasRaw != null) payload.hasRaw = !!extra.hasRaw;
            if (extra.hasLoadoutJson != null) payload.hasLoadoutJson = !!extra.hasLoadoutJson;
            if (extra.loadoutKeys) payload.loadoutKeys = extra.loadoutKeys;
            if (extra.retryCount != null) payload.retryCount = Number(extra.retryCount) || 0;
            if (extra.retryDelayMs != null) payload.retryDelayMs = Number(extra.retryDelayMs) || 0;
            const stack = error?.stack;
            if (stack) payload.stack = String(stack).slice(0, 400);
            firebaseRequest("POST", "errors", payload);
        } catch (_) { /* avoid recursive failure */ }
    }
 
    async function putToFirebase(playerId, targetId, raw) {
        const key = getCacheKey(playerId, targetId);
        const payload = { raw, timestamp: Date.now(), playerId, targetId };
        const r1 = await firebaseRequest("PUT", `loadouts/${key}`, payload);
        const r2 = await firebaseRequest("PUT", `loadouts/by_target/${targetId}`, payload);
        const ok = r1 != null && r2 != null;
        if (!ok) {
            reportError("upload", "Firebase PUT returned null", { playerId, targetId });
        }
        return ok;
    }
 
    function getLoadoutItem(loadout, slot) {
        if (!loadout) return null;
        return loadout[slot] ?? loadout[String(slot)] ?? null;
    }

    function parseLoadoutSlots(loadoutNode) {
        if (loadoutNode == null) return {};
        if (Array.isArray(loadoutNode)) {
            const slots = {};
            loadoutNode.forEach((v, idx) => {
                if (v == null) return;
                const key = String(idx);
                if (typeof v === "string") {
                    try { slots[key] = JSON.parse(v); } catch { /* skip */ }
                } else if (typeof v === "object" && !v["#"]) {
                    slots[key] = v;
                }
            });
            return slots;
        }
        if (typeof loadoutNode !== "object") return {};
        const slots = {};
        for (const [k, v] of Object.entries(loadoutNode)) {
            if (k === "_") continue;
            if (typeof v === "string") {
                try { slots[k] = JSON.parse(v); } catch { /* skip */ }
            } else if (v && typeof v === "object" && !v["#"]) {
                slots[k] = v;
            }
        }
        return slots;
    }

    function mergeLoadoutSources(rawLoadout, jsonLoadout) {
        if (!rawLoadout) return jsonLoadout || null;
        if (!jsonLoadout) return rawLoadout;
        const merged = { ...jsonLoadout, ...rawLoadout };
        for (const slot of ARMOR_SLOTS) {
            const jsonItem = getLoadoutItem(jsonLoadout, slot);
            if (jsonItem?.item_id) merged[slot] = merged[String(slot)] = jsonItem;
        }
        for (const slot of WEAPON_SLOTS) {
            const rawItem = getLoadoutItem(rawLoadout, slot);
            if (rawItem?.item_id) merged[slot] = merged[String(slot)] = rawItem;
        }
        return merged;
    }

    function parseLoadoutJsonNode(lj) {
        if (lj == null) return null;
        let node = lj;
        if (typeof lj === "string") {
            try { node = JSON.parse(lj); } catch { return null; }
        }
        if (!node || typeof node !== "object" || node["#"]) return null;
        const loadout = parseLoadoutSlots(node);
        if (!loadout || Object.keys(loadout).length === 0) return null;
        normalizeLoadout(loadout);
        return validateLoadout(loadout) ? loadout : null;
    }

    function parsePayloadToLoadout(data) {
        if (!data || typeof data !== "object") return null;
        let fromRaw = null;
        const raw = data.raw;
        if (raw && typeof raw === "object") {
            const defenderItems = raw.defenderItems ?? raw.attackData?.defenderItems;
            const parsed = parseDefenderItemsToLoadout(defenderItems);
            if (parsed && validateLoadout(parsed)) {
                normalizeLoadout(parsed);
                fromRaw = parsed;
            }
        }
        const fromJson = parseLoadoutJsonNode(data.loadoutJson);
        if (fromRaw && fromJson) {
            const merged = mergeLoadoutSources(fromRaw, fromJson);
            return merged && validateLoadout(merged) ? merged : fromJson || fromRaw;
        }
        return fromJson || fromRaw;
    }
 
    async function getFromFirebase(playerId, targetId) {
        const key = getCacheKey(playerId, targetId);
        log("getFromFirebase", key);
        const data = await firebaseRequest("GET", `loadouts/${key}`);
        const loadout = parsePayloadToLoadout(data);
        log("getFromFirebase result", !!data, !!loadout);
        if (loadout) return { loadout, submittedBy: null, savedAt: data?.timestamp ?? null };
        if (data && typeof data === "object" && (data.raw || data.loadoutJson)) {
            reportError("parse", "parsePayloadToLoadout returned null", { playerId, targetId, hasRaw: !!data.raw, hasLoadoutJson: !!data.loadoutJson });
        }
        return null;
    }
 
    async function getFromFirebaseByTarget(targetId) {
        log("getFromFirebaseByTarget", targetId);
        const data = await firebaseRequest("GET", `loadouts/by_target/${targetId}`);
        const loadout = parsePayloadToLoadout(data);
        log("getFromFirebaseByTarget result", !!data, !!loadout);
        if (loadout) {
            const id = data?.playerId;
            const submittedBy = id != null && Number.isInteger(Number(id)) && Number(id) > 0 ? String(id) : null;
            return { loadout, submittedBy, savedAt: data?.timestamp ?? null };
        }
        if (data && typeof data === "object" && (data.raw || data.loadoutJson)) {
            reportError("parse_by_target", "parsePayloadToLoadout returned null", { targetId, hasRaw: !!data.raw, hasLoadoutJson: !!data.loadoutJson });
        }
        return null;
    }
 
    function parseJson(text) {
        try { return JSON.parse(text); } catch { return null; }
    }
 
    function waitForPdaBridge(timeoutMs = 4000) {
        return new Promise((resolve) => {
            const start = Date.now();
            const tick = () => {
                try {
                    if (W.flutter_inappwebview?.callHandler) return resolve(W.flutter_inappwebview);
                } catch {}
                if (Date.now() - start > timeoutMs) return resolve(null);
                W.setTimeout(tick, 200);
            };
            tick();
        });
    }

    function relativeTime(ms) {
        const fmt = (n, u) => `${n} ${u}${n > 1 ? "s" : ""} ago`;
        const mins = Math.floor(ms / 60000);
        if (mins < 1) return "just now";
        const hrs = Math.floor(ms / 3600000);
        if (hrs < 1) return fmt(mins, "minute");
        const days = Math.floor(ms / 86400000);
        if (days < 1) return fmt(hrs, "hour");
        const wks = Math.floor(days / 7);
        if (wks < 1) return fmt(days, "day");
        return fmt(Math.floor(days / 30), "month");
    }

    function getSpareMagCount(mods) {
        if (!Array.isArray(mods)) return 2;
        if (mods.some((m) => m?.name === "Extra Magazines x2")) return 4;
        if (mods.some((m) => m?.name === "Extra Magazine")) return 3;
        return 2;
    }

    function buildAmmoInner(item, slot) {
        if (slot === 3) return INFINITY_SVG;
        if (slot === 5) return `<span class="markerText___HdlDL standard___bW8M5">1</span>`;
        const clipSize = item?.clip_size;
        if (clipSize == null || clipSize === "") {
            return `<span class="markerText___HdlDL">—</span>`;
        }
        const spareMags = getSpareMagCount(item?.mods);
        const ammoType = String(item?.ammo_type || "").toLowerCase().replace(/\s+/g, "-");
        const color = ammoType ? `var(--attack-ammo-color-${ammoType}, #ddd)` : "#ddd";
        return `<span class="markerText___HdlDL" style="color:${color}">${clipSize}/${clipSize} (${spareMags})</span>`;
    }

    function queryFirstInDocs(selectors) {
        for (const doc of getDocAndIframes()) {
            const n = queryFirst(doc, selectors);
            if (n) return n;
        }
        return null;
    }

    function getWeaponArea(defenderArea) {
        const area = defenderArea || getDefenderArea();
        if (!IS_PDA) return area;
        const wraps = [];
        for (const doc of getDocAndIframes()) {
            wraps.push(...doc.querySelectorAll("[class*='armoursWrap']"));
        }
        if (wraps.length <= 1) return area;
        const defenderWrap = getArmoursWrap(area) || wraps[wraps.length - 1];
        const wrapIdx = wraps.indexOf(defenderWrap);
        const areas = [];
        for (const doc of getDocAndIframes()) {
            areas.push(...doc.querySelectorAll("[class*='playerArea']"));
        }
        if (wrapIdx >= 0 && areas[wrapIdx]) return areas[wrapIdx];
        return areas[areas.length - 1] ?? area;
    }

    function whenVisible(fn) {
        if (W.document.visibilityState === "visible") { fn(); return; }
        const handler = () => {
            if (W.document.visibilityState !== "visible") return;
            W.document.removeEventListener("visibilitychange", handler);
            fn();
        };
        W.document.addEventListener("visibilitychange", handler);
    }
 
    function escapeHtml(v) {
        return String(v ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
    }
 
    function formatFixed2(v) {
        const n = Number(v);
        return Number.isFinite(n) ? n.toFixed(2) : "-";
    }
 
    function queryFirst(root, selectors) {
        for (const s of selectors) {
            try {
                const n = root.querySelector(s);
                if (n) return n;
            } catch {}
        }
        return null;
    }
 
    function getItemFromSlot(slot) {
        if (!slot) return null;
        const item = slot?.item?.[0] ?? slot?.item;
        if (!item) return null;
        const id = item.ID ?? item.id ?? item.item_id;
        if (id == null || id === "") return null;
        return item;
    }
 
    function extractBonusesFromSlot(slot, item) {
        const from = item?.currentBonuses ?? item?.bonuses ?? item?.Bonuses ?? item?.bonus ?? item?.attachments ?? item?.bonus_attachments ?? item?.item_bonuses
            ?? slot?.bonuses ?? slot?.Bonus ?? slot?.bonus ?? slot?.attachments;
        if (Array.isArray(from) && from.length > 0) return from.slice(0, 2);
        if (from && typeof from === "object" && !Array.isArray(from)) {
            const arr = Object.values(from).filter(Boolean);
            if (arr.length > 0) return arr.slice(0, 2);
        }
        if (typeof from === "string") {
            try { const p = JSON.parse(from); return Array.isArray(p) ? p.slice(0, 2) : []; } catch { return []; }
        }
        return [];
    }
 
    function parseDefenderItemsToLoadout(defenderItems) {
        if (!defenderItems || typeof defenderItems !== "object") return null;
        const loadout = {};
        for (const slotId of [...WEAPON_SLOTS, ...ARMOR_SLOTS]) {
            const slot = defenderItems[slotId] ?? defenderItems[String(slotId)];
            const item = getItemFromSlot(slot);
            if (!item) continue;
 
            const id = item.ID ?? item.id ?? item.item_id;
            const idNum = Number(id);
            if (id == null || id === "" || !Number.isInteger(idNum) || idNum < 1 || idNum > 999999) continue;
            const name = item.name ?? item.item_name ?? item.Name ?? "";
            const damage = Number(item.dmg ?? item.damage ?? item.Damage ?? 0) || 0;
            const accuracy = Number(item.acc ?? item.accuracy ?? item.Accuracy ?? 0) || 0;
            const glowRarity = (item.glowClass || "").replace("glow-", "");
            const rarity = String(item.rarity || item.Rarity || glowRarity || "default").toLowerCase();
            const rawMods = item.currentUpgrades ?? item.mods ?? item.Mods;
            const modArr = Array.isArray(rawMods) ? rawMods : (rawMods && typeof rawMods === "object" ? Object.values(rawMods) : []);
            const mods = modArr.map((m) => (m && typeof m === "object") ? { icon: m.icon ?? m.key, name: m.name ?? m.title ?? "", description: m.description ?? m.desc ?? "" } : m).filter(Boolean);
            const rawBonuses = extractBonusesFromSlot(slot, item);
            const bonuses = rawBonuses.map((b) => (typeof b === "object" && b !== null)
                ? { bonus_key: b.bonus_key ?? b.icon ?? b.key, name: b.name ?? b.title ?? b.description ?? b.desc ?? "" }
                : { bonus_key: String(b), name: "" }).filter((b) => b.bonus_key || b.name);
 
            loadout[slotId] = {
                item_id: id,
                item_name: name,
                damage,
                accuracy,
                rarity,
                mods: mods.slice(0, 2),
                bonuses: bonuses.slice(0, 2),
                clip_size: item.clipSize ?? item.clip_size ?? item.clip ?? undefined,
                ammo_type: item.ammoType ?? item.ammo_type ?? item.ammo ?? "",
            };
        }
        return Object.keys(loadout).length > 0 ? loadout : null;
    }
 
    function hasNativeDefenderLoadout(defenderItems) {
        if (!defenderItems || typeof defenderItems !== "object") return false;
        const parsed = parseDefenderItemsToLoadout(defenderItems);
        return !!parsed && Object.keys(parsed).length > 0;
    }
 
    function getDocAndIframes() {
        const docs = [W.document];
        try {
            for (const iframe of W.document.querySelectorAll("iframe")) {
                try {
                    const doc = iframe.contentDocument || iframe.contentWindow?.document;
                    if (doc && doc !== W.document) docs.push(doc);
                } catch {}
            }
        } catch {}
        return docs;
    }
 
    const DEFENDER_MARKERS = ["#defender_Primary", "#defender_Secondary", "#defender_Melee", "#defender_Temporary"];
 
    function getDefenderArea() {
        for (const doc of getDocAndIframes()) {
            const marker = queryFirst(doc, DEFENDER_MARKERS);
            if (marker) {
                const owner = marker.closest("[class*='playerArea'], [class*='player___']");
                if (owner) {
                    dbg("getDefenderArea: via defender marker", marker.id);
                    return owner;
                }
            }
        }
        for (const doc of getDocAndIframes()) {
            const areas = doc.querySelectorAll("[class*='playerArea']");
            if (areas.length === 0) continue;
            if (areas.length === 1) {
                return areas[0];
            }
            // New Torn UI: both sides reuse ids like #weapon_main on the wrapper — do NOT use #weapon_main to pick attacker.
            const withDefender = [...areas].find((a) =>
                a.querySelector("[id^='defender_']")
            );
            if (withDefender) {
                dbg("getDefenderArea: playerArea with defender_* ids");
                return withDefender;
            }
            const withAttacker = [...areas].find((a) =>
                a.querySelector("#attacker_Primary, #attacker_Secondary, #attacker_Melee, #attacker_Temporary")
            );
            const chosen = withAttacker ?? areas[1] ?? areas[0];
            dbg("getDefenderArea: fallback two-area", !!withAttacker);
            return chosen;
        }
        return null;
    }
 
    const INFINITY_SVG = `<span class="eternity___QmjtV"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="10" viewBox="0 0 17 10"><g><path d="M 12.3399 1.5 C 10.6799 1.5 9.64995 2.76 8.50995 3.95 C 7.35995 2.76 6.33995 1.5 4.66995 1.5 C 2.89995 1.51 1.47995 2.95 1.48995 4.72 C 1.48995 4.81 1.48995 4.91 1.49995 5 C 1.32995 6.76 2.62995 8.32 4.38995 8.49 C 4.47995 8.49 4.57995 8.5 4.66995 8.5 C 6.32995 8.5 7.35995 7.24 8.49995 6.05 C 9.64995 7.24 10.67 8.5 12.33 8.5 C 14.0999 8.49 15.5199 7.05 15.5099 5.28 C 15.5099 5.19 15.5099 5.09 15.4999 5 C 15.6699 3.24 14.3799 1.68 12.6199 1.51 C 12.5299 1.51 12.4299 1.5 12.3399 1.5 Z M 4.66995 7.33 C 3.52995 7.33 2.61995 6.4 2.61995 5.26 C 2.61995 5.17 2.61995 5.09 2.63995 5 C 2.48995 3.87 3.27995 2.84 4.40995 2.69 C 4.49995 2.68 4.57995 2.67 4.66995 2.67 C 6.01995 2.67 6.83995 3.87 7.79995 5 C 6.83995 6.14 6.01995 7.33 4.66995 7.33 Z M 12.3399 7.33 C 10.99 7.33 10.17 6.13 9.20995 5 C 10.17 3.86 10.99 2.67 12.3399 2.67 C 13.48 2.67 14.3899 3.61 14.3899 4.74 C 14.3899 4.83 14.3899 4.91 14.3699 5 C 14.5199 6.13 13.7299 7.16 12.5999 7.31 C 12.5099 7.32 12.4299 7.33 12.3399 7.33 Z" stroke-width="0"></path></g></svg></span>`;
 
    function buildIconHtml(icon, title, desc, containerClass) {
        const raw = icon || "blank-bonus-25";
        const safeIcon = /^[a-zA-Z0-9_-]+$/.test(raw) ? raw : "blank-bonus-25";
        const tooltip = escapeHtml([title, desc].filter(Boolean).join(" - "));
        const container = containerClass || "container___LAqaj";
        return `<div class="${container}" title="${tooltip}"><i class="bonus-attachment-${safeIcon}" title="${tooltip}"></i></div>`;
    }

    function pickWeaponClass(el, prefix, fallback) {
        if (!el?.classList) return fallback;
        for (const c of el.classList) {
            if (c.startsWith(prefix)) return c;
        }
        return fallback;
    }

    function discoverWeaponSlotClasses(wrapper) {
        const top = queryFirst(wrapper, ["[class*='top___']"]);
        const propsEl = top?.querySelector("[class*='props___']");
        const containerEl = top?.querySelector("[class*='container___']");
        const topMarkerEl = top?.querySelector("[class*='topMarker___']");
        const markerTextEl = topMarkerEl?.querySelector("span");
        const bottom = queryFirst(wrapper, ["[class*='bottom___']"]);
        return {
            props: pickWeaponClass(propsEl, "props___", "props___oL_Cw"),
            container: pickWeaponClass(containerEl, "container___", "container___LAqaj"),
            topMarker: pickWeaponClass(topMarkerEl, "topMarker___", "topMarker___OjRyU"),
            markerText: pickWeaponClass(markerTextEl, "markerText___", "markerText___HdlDL"),
            bonusInfo: pickWeaponClass(bottom?.querySelector("[class*='bonusInfo___']"), "bonusInfo___", "bonusInfo___vyqlT"),
            bottomMarker: pickWeaponClass(bottom?.querySelector("[class*='bottomMarker___']"), "bottomMarker___", "bottomMarker___G1uDs"),
        };
    }
 
    function getBonusIconKey(bonus) {
        if (!bonus) return null;
        const raw = bonus.bonus_key ?? bonus.icon ?? "";
        return (BONUS_KEY_FIX[raw] ?? raw) || null;
    }
 
    function buildSlotIcons(arr, key, name, desc, isBonus, containerClass) {
        return [0, 1]
            .map((i) => {
                if (!arr?.[i]) return buildIconHtml(null, "", "", containerClass);
                const iconKey = isBonus ? getBonusIconKey(arr[i]) : (arr[i][key] ?? arr[i].icon);
                return buildIconHtml(iconKey, arr[i][name] ?? arr[i].description, arr[i][desc] ?? arr[i].description, containerClass);
            })
            .join("");
    }
 
    function getWeaponWrapper(marker) {
        if (!marker) return null;
        const cls = marker.className?.toString() || "";
        if (/weaponWrapper|weaponSlot/.test(cls)) return marker;
        return marker.closest("[class*='weaponWrapper'], [class*='weaponSlot']");
    }

    function stripPrefightEmptyStyles(wrapper) {
        if (!wrapper) return;
        wrapper.className = (wrapper.className?.toString() || "")
            .split(/\s+/)
            .filter((c) => c && !/^emptySlot/.test(c))
            .join(" ");
    }

    function stripBlankImgClasses(img) {
        if (!img) return;
        [...img.classList].forEach((c) => {
            if (/^blank___/.test(c)) img.classList.remove(c);
        });
    }

    function renderSlot(wrapper, item, slotLabel, includeLabel, slot) {
        if (!wrapper || !item) return;

        stripPrefightEmptyStyles(wrapper);

        const rarityGlow = { yellow: "glow-yellow", orange: "glow-orange", red: "glow-red" };
        const glow = rarityGlow[item.rarity] || "glow-default";
        wrapper.className = wrapper.className.split(/\s+/).filter((c) => c && !/^glow-/.test(c)).join(" ");
        wrapper.classList.add(glow);
 
        const border = queryFirst(wrapper, ["[class*='itemBorder']"]);
        if (border) border.className = `itemBorder___mJGqQ ${glow}-border`;
 
        const img = queryFirst(wrapper, ["[class*='weaponImage'] img", "img"]);
        if (img && item.item_id) {
            const base = `${CONFIG.imagesBase}/items/${item.item_id}/large`;
            const itemPath = `/items/${item.item_id}/`;
            const alreadyCorrect = img.src && String(img.src).includes(itemPath);
            if (!alreadyCorrect) {
                img.src = `${base}.png`;
                img.srcset = `${base}.png 1x, ${base}@2x.png 2x, ${base}@3x.png 3x, ${base}@4x.png 4x`;
            }
            img.alt = item.item_name || "";
            stripBlankImgClasses(img);
            img.style.objectFit = "contain";
        }
 
        const wc = discoverWeaponSlotClasses(wrapper);

        const top = queryFirst(wrapper, ["[class*='top___']"]);
        if (top) {
            const modIcons = buildSlotIcons(item.mods || [], "icon", "name", "description", false, wc.container);
            const bonusIcons = buildSlotIcons(item.bonuses || [], "bonus_key", "name", "description", true, wc.container);
            top.style.cssText = TOP_ROW_STYLE;
            top.innerHTML = includeLabel
                ? `<div class="${wc.props}" style="${PROPS_ROW_STYLE}">${modIcons}</div><div class="${wc.topMarker}"><span class="${wc.markerText}">${escapeHtml(slotLabel)}</span></div><div class="${wc.props}" style="${PROPS_ROW_STYLE}">${bonusIcons}</div>`
                : `<div class="${wc.props}" style="${PROPS_ROW_STYLE}">${modIcons}</div><div class="${wc.props}" style="${PROPS_ROW_STYLE}">${bonusIcons}</div>`;
        }

        const bottom = queryFirst(wrapper, ["[class*='bottom___']"]);
        if (bottom) {
            bottom.style.cssText = TOP_ROW_STYLE;
            bottom.innerHTML = `<div class="${wc.props}" style="${PROPS_ROW_STYLE}"><i class="bonus-attachment-item-damage-bonus" aria-label="Damage"></i><span class="${wc.bonusInfo}">${formatFixed2(item.damage)}</span></div><div class="${wc.bottomMarker}">${buildAmmoInner(item, slot)}</div><div class="${wc.props}" style="${PROPS_ROW_STYLE}"><i class="bonus-attachment-item-accuracy-bonus" aria-label="Accuracy"></i><span class="${wc.bonusInfo}">${formatFixed2(item.accuracy)}</span></div>`;
        }

        const xp = wrapper.querySelector(".tt-weapon-experience");
        if (xp) {
            xp.textContent = "";
            xp.style.display = "none";
        }
        wrapper.setAttribute("aria-label", item.item_name || "Unknown");
    }
 
    function clearSlot(wrapper, slotLabel, includeLabel, slot) {
        if (!wrapper) return;
        wrapper.className = wrapper.className.split(/\s+/).filter((c) => c && !/^glow-/.test(c)).join(" ");
        wrapper.classList.add("glow-default");
        const border = queryFirst(wrapper, ["[class*='itemBorder']"]);
        if (border) border.className = "itemBorder___mJGqQ glow-default-border";
        const img = queryFirst(wrapper, ["[class*='weaponImage'] img", "img"]);
        if (img) {
            const silhouette = SILHOUETTES[slot];
            if (silhouette) {
                img.src = `${TORN_BASE}/images/items/silhouettes/${silhouette}.svg`;
                img.removeAttribute("srcset");
                img.alt = "";
                img.classList.add("blank___RpGQA");
                img.style.objectFit = "";
            } else {
                img.removeAttribute("src");
                img.removeAttribute("srcset");
                img.alt = "";
                img.classList.add("blank___RpGQA");
            }
        }
        const wc = discoverWeaponSlotClasses(wrapper);
        const top = queryFirst(wrapper, ["[class*='top___']"]);
        if (top) {
            top.style.cssText = TOP_ROW_STYLE;
            top.innerHTML = includeLabel
                ? `<div class="${wc.props}" style="${PROPS_ROW_STYLE}"></div><div class="${wc.topMarker}"><span class="${wc.markerText}">${escapeHtml(slotLabel)}</span></div><div class="${wc.props}" style="${PROPS_ROW_STYLE}"></div>`
                : `<div class="${wc.props}" style="${PROPS_ROW_STYLE}"></div><div class="${wc.props}" style="${PROPS_ROW_STYLE}"></div>`;
        }
        const bottom = queryFirst(wrapper, ["[class*='bottom___']"]);
        if (bottom) {
            bottom.style.cssText = TOP_ROW_STYLE;
            const ammoInner = slot === 3 ? INFINITY_SVG : slot === 5 ? `<span class="${wc.markerText} standard___bW8M5">0</span>` : `<span class="${wc.markerText}">—</span>`;
            bottom.innerHTML = `<div class="${wc.props}" style="${PROPS_ROW_STYLE}"><i class="bonus-attachment-item-damage-bonus" aria-label="Damage"></i><span class="${wc.bonusInfo}">—</span></div><div class="${wc.bottomMarker}">${ammoInner}</div><div class="${wc.props}" style="${PROPS_ROW_STYLE}"><i class="bonus-attachment-item-accuracy-bonus" aria-label="Accuracy"></i><span class="${wc.bonusInfo}">—</span></div>`;
        }
        const xp = wrapper.querySelector(".tt-weapon-experience");
        if (xp) {
            xp.textContent = "";
            xp.style.display = "none";
        }
        wrapper.setAttribute("aria-label", slotLabel || "Empty");
    }
 
    function pickArmorClass(doc, prefix, exclude) {
        for (const el of doc.querySelectorAll("[class]")) {
            for (const c of el.classList) {
                if (!c.startsWith(prefix)) continue;
                if (exclude && exclude.test(c)) continue;
                return c;
            }
        }
        return "";
    }

    function discoverArmorDomClasses() {
        for (const doc of getDocAndIframes()) {
            const sample = doc.querySelector("[class*='armourContainer']");
            if (sample) {
                const armour = sample.querySelector("[class*='armour___']");
                const img = sample.querySelector("img[class*='itemImg']");
                const mask = sample.querySelector("[class*='mask___']");
                return {
                    container: String(sample.className || ""),
                    armour: String(armour?.className || ""),
                    img: String(img?.className || ""),
                    mask: String(mask?.className || ""),
                };
            }
            const container = pickArmorClass(doc, "armourContainer___");
            const armour = pickArmorClass(doc, "armour___", /Wrap|Mask/i);
            const img = pickArmorClass(doc, "itemImg___");
            const mask = pickArmorClass(doc, "mask___");
            if (container && armour && img) {
                return { container, armour, img, mask };
            }
        }
        return { ...FALLBACK_ARMOR_DOM };
    }

    function getArmoursWrap(defenderArea) {
        if (defenderArea) {
            const inArea = queryFirst(defenderArea, ["[class*='armoursWrap']"]);
            if (inArea) return inArea;
            const scope = defenderArea.closest("[class*='playerWindow'], [class*='player___']");
            if (scope) {
                const inScope = queryFirst(scope, ["[class*='armoursWrap']"]);
                if (inScope) return inScope;
            }
        }
        for (const doc of getDocAndIframes()) {
            const wraps = [...doc.querySelectorAll("[class*='armoursWrap']")];
            if (wraps.length === 0) continue;
            if (wraps.length === 1) return wraps[0];
            if (defenderArea) {
                for (const w of wraps) {
                    if (defenderArea.compareDocumentPosition(w) & Node.DOCUMENT_POSITION_FOLLOWING) {
                        return w;
                    }
                }
            }
            return wraps[wraps.length - 1];
        }
        return null;
    }

    function setModelImgSrc(img, basePath) {
        img.alt = "";
        img.src = `${basePath}.webp`;
        img.srcset = `${basePath}.webp 1x, ${basePath}@2x.webp 2x, ${basePath}@3x.webp 3x, ${basePath}@4x.webp 4x`;
    }

    function setModelMaskImgSrc(img, basePath) {
        img.alt = "";
        img.src = `${basePath}.webp`;
        img.removeAttribute("srcset");
    }

    function getArmorMaskBase(itemId, slot) {
        if (Number(slot) === 8) return `${TORN_BASE}/images/v2/user_model/masks/head-03m-mask-d`;
        return `${TORN_BASE}/images/v2/user_model/masks/${itemId}m-mask-d`;
    }

    function shouldRenderArmorMask(slot) {
        return ARMOR_MASK_SLOTS.has(Number(slot));
    }

    function getArmorSlotsKey(loadout) {
        if (!loadout) return "";
        return ARMOR_SLOTS.map((slot) => {
            const id = getLoadoutItem(loadout, slot)?.item_id;
            return id ? `${slot}:${id}` : "";
        }).filter(Boolean).join(",");
    }

    function countArmorApplied(armoursWrap) {
        return armoursWrap?.querySelectorAll("[data-loadout-slot]").length ?? 0;
    }

    function isArmorDomCurrent(armoursWrap, loadout) {
        const key = getArmorSlotsKey(loadout);
        if (!armoursWrap || !key) return false;
        if (armoursWrap.dataset.loadoutArmorKey !== key) return false;
        let need = 0;
        for (const slot of ARMOR_SLOTS) {
            const itemId = getLoadoutItem(loadout, slot)?.item_id;
            if (!itemId) continue;
            need++;
            const el = armoursWrap.querySelector(`[data-loadout-slot="${slot}"]`);
            if (!el || String(el.dataset.loadoutItemId) !== String(itemId)) return false;
        }
        return need > 0 && armoursWrap.querySelectorAll("[data-loadout-slot]").length >= need;
    }

    function allowDefenderModelInteraction(defenderArea) {
        const playerWindow = defenderArea?.closest("[class*='playerWindow']");
        if (!playerWindow) return;
        for (const modal of playerWindow.querySelectorAll("[class*='modal']")) {
            modal.style.pointerEvents = "none";
        }
        for (const el of playerWindow.querySelectorAll("[class*='effectsWrap'], [class*='iconsContainer']")) {
            el.style.pointerEvents = "none";
        }
    }

    function setArmorWrapPointerPassthrough(armoursWrap) {
        if (armoursWrap?.dataset.loadoutArmorKey) {
            armoursWrap.style.pointerEvents = "none";
        }
    }

    function pointInPoly(x, y, coordsStr) {
        const coords = coordsStr.split(",").map(Number);
        let inside = false;
        for (let i = 0, j = coords.length - 2; i < coords.length; j = i, i += 2) {
            const xi = coords[i];
            const yi = coords[i + 1];
            const xj = coords[j];
            const yj = coords[j + 1];
            if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)) {
                inside = !inside;
            }
        }
        return inside;
    }

    function getArmorNameAtPoint(x, y, loadout) {
        for (const slot of [4, 6, 7, 8, 9]) {
            const item = getLoadoutItem(loadout, slot);
            if (!item || !ARMOR_SLOT_AREAS[slot]) continue;
            for (const { coords } of ARMOR_SLOT_AREAS[slot]) {
                if (pointInPoly(x, y, coords)) return item.item_name || "";
            }
        }
        return "";
    }

    function getArmorHoverTip(doc) {
        let tip = doc.getElementById("loadout-armor-tip");
        if (!tip) {
            tip = doc.createElement("div");
            tip.id = "loadout-armor-tip";
            tip.style.cssText = "position:fixed;z-index:999999;display:none;pointer-events:none;padding:4px 8px;font-size:11px;font-weight:600;color:#ddd;background:rgba(0,0,0,0.85);border-radius:4px;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,0.35);";
            doc.body.appendChild(tip);
        }
        return tip;
    }

    function bindArmorCursorTooltip(defenderArea, bodyImg, loadout) {
        if (!bodyImg || !loadout) return;
        const scope = defenderArea?.closest("[class*='playerWindow']") ?? defenderArea ?? bodyImg;
        scope._loadoutHoverAbort?.abort();
        const ac = new AbortController();
        scope._loadoutHoverAbort = ac;
        const doc = scope.ownerDocument || W.document;
        const tip = getArmorHoverTip(doc);
        const onMove = (e) => {
            const rect = bodyImg.getBoundingClientRect();
            if (rect.width < 1 || rect.height < 1) {
                tip.style.display = "none";
                return;
            }
            if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) {
                tip.style.display = "none";
                return;
            }
            const x = ((e.clientX - rect.left) / rect.width) * 240;
            const y = ((e.clientY - rect.top) / rect.height) * 384;
            const name = getArmorNameAtPoint(x, y, loadout);
            if (!name) {
                tip.style.display = "none";
                return;
            }
            tip.textContent = name;
            tip.style.left = `${Math.min(e.clientX + 12, W.innerWidth - 160)}px`;
            tip.style.top = `${Math.min(e.clientY + 12, W.innerHeight - 30)}px`;
            tip.style.display = "block";
        };
        const onLeave = () => {
            tip.style.display = "none";
        };
        scope.addEventListener("mousemove", onMove, { signal: ac.signal, capture: true });
        scope.addEventListener("mouseleave", onLeave, { signal: ac.signal, capture: true });
    }

    function renderArmor(defenderArea, loadout) {
        if (!loadout) return 0;
        const armoursWrap = getArmoursWrap(defenderArea);
        if (!armoursWrap) return 0;

        const classes = discoverArmorDomClasses();
        if (!classes?.container || !classes.armour || !classes.img) return 0;

        if (isArmorDomCurrent(armoursWrap, loadout)) {
            setArmorWrapPointerPassthrough(armoursWrap);
            allowDefenderModelInteraction(defenderArea);
            renderArmorHoverMap(defenderArea, loadout);
            return countArmorApplied(armoursWrap);
        }

        const modelRoot = armoursWrap.closest("[class*='modelLayers'], [class*='playerWindow']") || armoursWrap.parentElement;
        const oldOverlay = modelRoot?.querySelector(".loadout-armor-overlay") ?? defenderArea?.querySelector(".loadout-armor-overlay");
        if (oldOverlay) oldOverlay.remove();

        const modelDoc = armoursWrap.ownerDocument || W.document;
        armoursWrap.replaceChildren();

        let applied = 0;
        for (const slot of ARMOR_SLOTS) {
            const item = getLoadoutItem(loadout, slot);
            const itemId = item?.item_id;
            if (!itemId) continue;

            const container = modelDoc.createElement("div");
            container.className = classes.container;
            container.style.zIndex = String(ARMOR_Z_INDEX[slot] ?? 14);
            container.dataset.loadoutSlot = String(slot);
            container.dataset.loadoutItemId = String(itemId);

            const itemName = item.item_name || "";
            container.title = itemName;
            container.dataset.loadoutItemName = itemName;

            if (shouldRenderArmorMask(slot) && classes.mask) {
                const maskWrap = modelDoc.createElement("div");
                maskWrap.className = classes.mask;
                const maskImg = modelDoc.createElement("img");
                maskImg.className = classes.img;
                maskImg.dataset.loadoutMask = "1";
                setModelMaskImgSrc(maskImg, getArmorMaskBase(itemId, slot));
                maskImg.addEventListener("error", () => maskWrap.remove(), { once: true });
                maskWrap.appendChild(maskImg);
                container.appendChild(maskWrap);
            }

            const armour = modelDoc.createElement("div");
            armour.className = classes.armour;
            armour.title = itemName;
            const itemImg = modelDoc.createElement("img");
            itemImg.className = classes.img;
            itemImg.alt = itemName;
            itemImg.title = itemName;
            setModelImgSrc(itemImg, `${TORN_BASE}/images/v2/user_model/items/${itemId}m`);
            armour.appendChild(itemImg);
            container.appendChild(armour);

            armoursWrap.appendChild(container);
            applied++;
        }
        if (applied > 0) {
            armoursWrap.dataset.loadoutArmorKey = getArmorSlotsKey(loadout);
            setArmorWrapPointerPassthrough(armoursWrap);
        }
        allowDefenderModelInteraction(defenderArea);
        renderArmorHoverMap(defenderArea, loadout);
        return applied;
    }

    function removeArmorHoverMap(defenderArea) {
        for (const doc of getDocAndIframes()) {
            doc.querySelector(`map[name="${ARMOR_MAP_NAME}"]`)?.remove();
            doc.querySelectorAll(".loadout-armor-overlay").forEach((el) => el.remove());
            for (const img of doc.querySelectorAll(`img[usemap="#${ARMOR_MAP_NAME}"]`)) {
                img.removeAttribute("usemap");
            }
            doc.getElementById("loadout-armor-tip")?.remove();
            for (const host of doc.querySelectorAll("[class*='playerWindow'], [class*='allLayers']")) {
                host._loadoutHoverAbort?.abort();
                delete host._loadoutHoverAbort;
            }
        }
    }

    function getBodyImageForHover(defenderArea) {
        const scope = defenderArea?.closest("[class*='playerWindow'], [class*='player___']") ?? defenderArea;
        const inScope = queryFirst(scope, ["[class*='bodyImage'] img", "img[src*='user_model/body']"]);
        if (inScope) return inScope;
        const wraps = scope ? [...scope.querySelectorAll("[class*='armoursWrap']")] : [];
        if (wraps.length) {
            const modelRoot = wraps[0].closest("[class*='modelLayers']");
            const inModel = queryFirst(modelRoot, ["[class*='bodyImage'] img", "img[src*='user_model/body']"]);
            if (inModel) return inModel;
        }
        return queryFirst(defenderArea, ["[class*='bodyImage'] img", "img[src*='user_model/body']"])
            ?? queryFirstInDocs(["[class*='bodyImage'] img", "img[src*='user_model/body']"]);
    }

    function renderArmorHoverMap(defenderArea, loadout) {
        if (!loadout) return;
        removeArmorHoverMap(defenderArea);
        const bodyImg = getBodyImageForHover(defenderArea);
        if (!bodyImg) return;
        bodyImg.removeAttribute("usemap");
        bindArmorCursorTooltip(defenderArea, bodyImg, loadout);
    }

    function buildSlotMappings(defenderArea) {
        if (defenderArea.querySelector("#defender_Primary")) {
            return {
                preFight: false,
                includeLabel: true,
                mappings: [
                    { selector: "#defender_Primary", slot: 1, label: "Primary" },
                    { selector: "#defender_Secondary", slot: 2, label: "Secondary" },
                    { selector: "#defender_Melee", slot: 3, label: "Melee" },
                    { selector: "#defender_Temporary", slot: 5, label: "Temporary" },
                ],
            };
        }
        const tempSelector = defenderArea.querySelector("#defender_Temporary") ? "#defender_Temporary" : "#weapon_temp";
        return {
            preFight: true,
            includeLabel: false,
            mappings: [
                { selector: "#weapon_main", slot: 1, label: "Primary" },
                { selector: "#weapon_second", slot: 2, label: "Secondary" },
                { selector: "#weapon_melee", slot: 3, label: "Melee" },
                { selector: tempSelector, slot: 5, label: "Temporary" },
            ],
        };
    }

    function applyLoadoutWeapons(defenderArea, loadout, config) {
        const weaponArea = getWeaponArea(defenderArea);
        let applied = 0;
        let expected = 0;
        for (const { selector, slot, label } of config.mappings) {
            expected++;
            const marker = queryFirst(weaponArea, [selector]) || queryFirstInDocs([selector]);
            const wrapper = getWeaponWrapper(marker);
            if (!wrapper) continue;
            const item = getLoadoutItem(loadout, slot);
            if (item) {
                renderSlot(wrapper, item, label, config.includeLabel, slot);
            } else {
                clearSlot(wrapper, label, config.includeLabel, slot);
            }
            applied++;
        }
        renderArmor(defenderArea, loadout);
        return { applied, expected };
    }

    function stopApplyTimer() {
        if (STATE.applyTimerId) {
            W.clearInterval(STATE.applyTimerId);
            STATE.applyTimerId = null;
        }
    }

    function renderLoadoutMeta(submittedBy, savedAt) {
        if (!IS_PDA) {
            W.document.querySelector(".loadout-shared-meta")?.remove();
            return;
        }
        let meta = W.document.querySelector(".loadout-shared-meta");
        if (!meta) {
            meta = W.document.createElement("div");
            meta.className = "loadout-shared-meta";
            W.document.body.appendChild(meta);
        }
        meta.style.cssText =
            "position:fixed;top:52px;left:12px;z-index:9999;display:flex;flex-direction:column;gap:4px;" +
            "font-size:11px;font-weight:600;color:#ddd;background:rgba(0,0,0,0.85);" +
            "padding:6px 10px;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,0.3);";
        const parts = [];
        const safeSubmitterId = submittedBy && /^\d{1,8}$/.test(String(submittedBy)) ? String(submittedBy) : null;
        if (safeSubmitterId) {
            parts.push(
                `<a href="${TORN_BASE}/profiles.php?XID=${safeSubmitterId}" target="_blank" rel="noopener" ` +
                `style="color:#f44;text-decoration:none;">shared by ${escapeHtml(safeSubmitterId)}</a>`
            );
        }
        if (savedAt != null && Number.isFinite(Number(savedAt))) {
            const ms = Number(savedAt) > 1e12 ? Number(savedAt) : Number(savedAt) * 1000;
            parts.push(`<span style="color:#aaa;">saved ${escapeHtml(relativeTime(Date.now() - ms))}</span>`);
        }
        if (!parts.length) {
            meta.remove();
            return;
        }
        meta.innerHTML = parts.join("<br>");
    }

    function renderLoadout(loadout, submittedBy, savedAt) {
        if (!loadout || STATE.loadoutRendered || STATE.applyTimerId) return;

        const startedAt = Date.now();
        let panelRendered = false;
        let badgeRendered = false;
        let modalAdjusted = false;

        STATE.applyTimerId = W.setInterval(() => {
            if (STATE.loadoutRendered) {
                stopApplyTimer();
                return;
            }
            const defenderArea = getDefenderArea();
            if (!defenderArea) return;

            const config = buildSlotMappings(defenderArea);
            const { applied, expected } = applyLoadoutWeapons(defenderArea, loadout, config);

            if (!badgeRendered) {
                if (IS_PDA) renderLoadoutMeta(submittedBy, savedAt);
                else W.document.querySelector(".loadout-shared-meta")?.remove();
                badgeRendered = true;
            }

            if (!panelRendered && !IS_PDA) {
                renderDefenderPanel(loadout, submittedBy, savedAt);
                panelRendered = true;
            } else if (!panelRendered) {
                panelRendered = true;
            }

            if (!modalAdjusted) {
                const modal = queryFirst(defenderArea, ["[class*='modal']"]);
                if (modal) {
                    modal.style.background = "transparent";
                    modal.style.backdropFilter = "none";
                    modal.style.webkitBackdropFilter = "none";
                    modalAdjusted = true;
                }
            }

            const elapsed = Date.now() - startedAt;
            const allApplied = expected > 0 && applied >= expected;
            const inFightDom = !config.preFight;
            const maxMs = config.preFight ? PREFIGHT_RENDER_MAX_MS : RENDER_MAX_MS;
            const timedOut = elapsed >= maxMs;
            const done = config.preFight ? inFightDom || timedOut : allApplied || inFightDom || timedOut;

            if (done) {
                dbg("renderLoadout: done", { applied, expected, preFight: config.preFight, inFightDom, timedOut });
                STATE.loadoutRendered = true;
                stopApplyTimer();
            }
        }, PREFIGHT_REAPPLY_MS);
    }
 
    function parseBonuses(b) {
        if (Array.isArray(b) && b.length > 0) return b;
        if (typeof b === "string") {
            try { const arr = JSON.parse(b); return Array.isArray(arr) ? arr : []; } catch { return []; }
        }
        return [];
    }
 
    async function uploadLoadoutData(raw) {
        try {
            const playerId = raw?.attackerUser?.userID;
            const targetId = raw?.defenderUser?.userID;
            if (!playerId || !targetId) return false;
            raw.timestamp = Math.floor(Date.now() / 1000);
            return await putToFirebase(playerId, targetId, raw);
        } catch (e) {
            reportError("upload", e, { playerId: raw?.attackerUser?.userID, targetId: raw?.defenderUser?.userID });
            return false;
        }
    }
 
    function scheduleUploadRetry(raw) {
        if (STATE.uploaded || STATE.uploadInProgress) return;
        STATE.uploadInProgress = true;
        whenVisible(async () => {
            const ok = await uploadLoadoutData(raw);
            if (ok) {
                STATE.uploaded = true;
                STATE.uploadInProgress = false;
                STATE.uploadRetryCount = 0;
                return;
            }
 
            const retryIndex = STATE.uploadRetryCount;
            const delay = UPLOAD_RETRY_DELAYS_MS[Math.min(retryIndex, UPLOAD_RETRY_DELAYS_MS.length - 1)];
            STATE.uploadInProgress = false;
            STATE.uploadRetryCount += 1;
            reportError("upload_retry", "Upload failed, scheduling retry", {
                playerId: raw?.attackerUser?.userID,
                targetId: raw?.defenderUser?.userID,
                retryCount: STATE.uploadRetryCount,
                retryDelayMs: delay,
            });
            W.setTimeout(() => {
                if (!STATE.uploaded && !STATE.uploadInProgress) scheduleUploadRetry(raw);
            }, delay);
        });
    }
 
    async function fetchAndRenderLoadout() {
        try {
            const playerId = STATE.attackData?.attackerUser?.userID;
            let targetId = STATE.attackData?.defenderUser?.userID ?? getUrlTargetId();
            const urlTargetId = getUrlTargetId();
 
            if (!targetId) return;
            if (urlTargetId && String(targetId) !== urlTargetId) return;
 
            let result = playerId ? await getFromFirebase(playerId, targetId) : null;
            if (!result) result = await getFromFirebaseByTarget(targetId);
            if (!result?.loadout) return;
            normalizeLoadout(result.loadout);
            if (!validateLoadout(result.loadout)) {
                reportError("validate", "validateLoadout failed", { playerId, targetId, loadoutKeys: Object.keys(result.loadout || {}).slice(0, 10) });
                return;
            }
            renderLoadout(result.loadout, result.submittedBy, result.savedAt);
        } catch (e) {
            reportError("fetch_render", e, { playerId: STATE.attackData?.attackerUser?.userID, targetId: STATE.attackData?.defenderUser?.userID });
        }
    }
 
    function processResponse(data) {
        if (!data || typeof data !== "object") return;
        const db = data.DB || data;
        if (!db || typeof db !== "object") return;
        const attacker = db.attackerUser ?? data.attackerUser;
        const defender = db.defenderUser ?? data.defenderUser;
        if (!attacker?.userID && !defender?.userID && !db.defenderItems) return;

        const isFirstData = !STATE.attackData;
        STATE.attackData = db;
 
        const hasNative = hasNativeDefenderLoadout(db.defenderItems);
        if (hasNative && !STATE.uploaded && !STATE.uploadInProgress) {
            scheduleUploadRetry(db);
        }
 
        if (hasNative) {
            const nativeLoadout = parseDefenderItemsToLoadout(db.defenderItems);
            dbg("processResponse: native", nativeLoadout ? "ok" : "null", "valid?", !!nativeLoadout && validateLoadout(nativeLoadout));
            if (nativeLoadout && validateLoadout(nativeLoadout)) {
                normalizeLoadout(nativeLoadout);
                renderLoadout(nativeLoadout, null, null);
            }
        } else if (isFirstData) {
            dbg("processResponse: fetchAndRenderLoadout");
            fetchAndRenderLoadout();
        }
    }
 
    if (typeof W.fetch === "function") {
        const origFetch = W.fetch;
        W.fetch = async function (...args) {
            const req = args[0];
            const url =
                typeof req === "string"
                    ? req
                    : req && typeof Request !== "undefined" && req instanceof Request
                      ? req.url
                      : req?.url || "";
            // Torn uses /loader.php?sid=attackData or /page.php?sid=attackData&mode=json (see builds/attack app).
            if (!url.includes("sid=attackData")) return origFetch.apply(this, args);

            const response = await origFetch.apply(this, args);
            if (response.ok) {
                try {
                    response.clone().text().then((text) => {
                        const parsed = parseJson(text);
                        if (parsed) processResponse(parsed);
                    });
                } catch {}
            }
            return response;
        };
    }
 
    function tryFetchByUrlTarget() {
        if (!getUrlTargetId() || STATE.loadoutRendered || STATE.attackData?.defenderUser?.userID) return;
        if (!getDefenderArea() && !getArmoursWrap(null)) return;
        fetchAndRenderLoadout();
    }

    whenVisible(() => {
        tryFetchByUrlTarget();
        FALLBACK_DELAYS_MS.forEach((ms) => W.setTimeout(tryFetchByUrlTarget, ms));
    });
 
})();