Torn Loadout Share

Shows shared defender loadouts on Torn attack pages. Firebase backend, no registration.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn Loadout Share
// @namespace    loadout
// @version      1.0.7
// @description  Shows shared defender loadouts on Torn attack pages. Firebase backend, 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/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;
    // Desktop-only build: all PDA-specific debug/transport removed.
    // 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 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 STATE = {
        uploaded: false,
        uploadInProgress: false,
        uploadRetryCount: 0,
        attackData: null,
        loadoutRendered: false,
        weaponsReapplied: false,
    };
 
    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) {
        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("");
 
        body.innerHTML = `
            <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() {
        return (W.location?.href?.match(/user2ID=(\d+)/) || [])[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);
            };
            try {
                if (typeof GM_xmlhttpRequest === "function") {
                    GM_xmlhttpRequest({
                        method,
                        url,
                        timeout: FIREBASE_FETCH_TIMEOUT_MS,
                        anonymous: true,
                        headers,
                        data: bodyStr,
                        onload: (r) => {
                            if (r.status >= 200 && r.status < 300) {
                                const text = r.responseText ?? "";
                                try { done(text ? JSON.parse(text) : null); } catch { done(null); }
                            } else {
                                log("firebaseRequest: status", r.status, path);
                                reportError("firebase_request", `HTTP ${r.status}`, { method, path, status: r.status });
                                done(null);
                            }
                        },
                        onerror: (e) => {
                            log("firebaseRequest: onerror", path, e);
                            reportError("firebase_request", "Network error", { method, path, errType: e?.type ?? "unknown" });
                            done(null);
                        },
                    });
                    return;
                }
 
                if (typeof W.fetch === "function") {
                    const fetchOpts = { method, headers, ...(bodyStr != null ? { body: bodyStr } : {}) };
                    W.fetch(url, fetchOpts)
                        .then(async (r) => {
                            const status = r.status;
                            const text = await r.text();
                            if (status >= 200 && status < 300) {
                                try { done(text ? JSON.parse(text) : null); } catch { done(null); }
                            } else {
                                log("firebaseRequest(fetch): status", status, path);
                                reportError("firebase_request", `HTTP ${status}`, { method, path, status });
                                done(null);
                            }
                        })
                        .catch((e) => {
                            log("firebaseRequest(fetch): error", path, e);
                            reportError("firebase_request", "Network error", { method, path, errType: e?.type ?? "unknown" });
                            done(null);
                        });
                    return;
                }
 
                log("firebaseRequest: no transport available");
                done(null);
            } 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 parseLoadoutSlots(loadoutNode) {
        if (!loadoutNode || 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 parsePayloadToLoadout(data) {
        if (!data || typeof data !== "object") return null;
        const raw = data.raw;
        if (raw && typeof raw === "object") {
            const defenderItems = raw.defenderItems ?? raw.attackData?.defenderItems;
            const loadout = parseDefenderItemsToLoadout(defenderItems);
            if (loadout && validateLoadout(loadout)) {
                normalizeLoadout(loadout);
                return loadout;
            }
        }
        const lj = data.loadoutJson;
        if (typeof lj === "string") {
            try {
                const parsed = JSON.parse(lj);
                const loadout = parseLoadoutSlots(parsed);
                if (loadout && Object.keys(loadout).length > 0) return loadout;
            } catch {}
        }
        if (lj && typeof lj === "object" && !lj["#"]) {
            const loadout = parseLoadoutSlots(lj);
            if (loadout && Object.keys(loadout).length > 0) return loadout;
        }
        return null;
    }
 
    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 };
        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 };
        }
        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 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),
            };
        }
        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) {
                const a = areas[0];
                return a;
            }
            const withAttacker = [...areas].find((a) => a.querySelector("#attacker_Primary, #weapon_main"));
            const chosen = withAttacker ?? areas[1] ?? areas[0];
            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) {
        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(" - "));
        return `<div class="container___LAqaj" title="${tooltip}"><i class="bonus-attachment-${safeIcon}" title="${tooltip}"></i></div>`;
    }
 
    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) {
        return [0, 1]
            .map((i) => {
                if (!arr?.[i]) return buildIconHtml(null, "", "");
                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);
            })
            .join("");
    }
 
    function renderSlot(wrapper, item, slotLabel, includeLabel, slot) {
        if (!wrapper || !item) return;
 
        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 alreadyCorrect = img.src && String(img.src).includes(`/items/${item.item_id}/`);
            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 || "";
            img.classList.remove("blank___RpGQA");
            img.style.objectFit = "contain";
        }
 
        const top = queryFirst(wrapper, ["[class*='top___']"]);
        if (top) {
            const modIcons = buildSlotIcons(item.mods || [], "icon", "name", "description", false);
            const bonusIcons = buildSlotIcons(item.bonuses || [], "bonus_key", "name", "description", true);
            top.innerHTML = includeLabel
                ? `<div class="props___oL_Cw">${modIcons}</div><div class="topMarker___OjRyU"><span class="markerText___HdlDL">${escapeHtml(slotLabel)}</span></div><div class="props___oL_Cw">${bonusIcons}</div>`
                : `<div class="props___oL_Cw">${modIcons}</div><div class="props___oL_Cw">${bonusIcons}</div>`;
        }
 
        const bottom = queryFirst(wrapper, ["[class*='bottom___']"]);
        if (bottom) {
            const ammoInner =
                slot === 3
                    ? INFINITY_SVG
                    : slot === 5
                      ? `<span class="markerText___HdlDL standard___bW8M5">1</span>`
                      : `<span class="markerText___HdlDL">Unknown</span>`;
            bottom.innerHTML = `<div class="props___oL_Cw"><i class="bonus-attachment-item-damage-bonus" aria-label="Damage"></i><span class="bonusInfo___vyqlT">${formatFixed2(item.damage)}</span></div><div class="bottomMarker___G1uDs">${ammoInner}</div><div class="props___oL_Cw"><i class="bonus-attachment-item-accuracy-bonus" aria-label="Accuracy"></i><span class="bonusInfo___vyqlT">${formatFixed2(item.accuracy)}</span></div>`;
        }
 
        let xp = wrapper.querySelector(".tt-weapon-experience");
        if (!xp) {
            xp = W.document.createElement("div");
            xp.className = "tt-weapon-experience";
            wrapper.appendChild(xp);
        }
        xp.textContent = item.item_name || "";
        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) {
            img.removeAttribute("src");
            img.removeAttribute("srcset");
            img.alt = "";
            img.classList.add("blank___RpGQA");
        }
        const top = queryFirst(wrapper, ["[class*='top___']"]);
        if (top) {
            top.innerHTML = includeLabel
                ? `<div class="props___oL_Cw"></div><div class="topMarker___OjRyU"><span class="markerText___HdlDL">${escapeHtml(slotLabel)}</span></div><div class="props___oL_Cw"></div>`
                : `<div class="props___oL_Cw"></div><div class="props___oL_Cw"></div>`;
        }
        const bottom = queryFirst(wrapper, ["[class*='bottom___']"]);
        if (bottom) {
            const ammoInner = slot === 3 ? INFINITY_SVG : slot === 5 ? `<span class="markerText___HdlDL standard___bW8M5">0</span>` : `<span class="markerText___HdlDL">—</span>`;
            bottom.innerHTML = `<div class="props___oL_Cw"><i class="bonus-attachment-item-damage-bonus" aria-label="Damage"></i><span class="bonusInfo___vyqlT">—</span></div><div class="bottomMarker___G1uDs">${ammoInner}</div><div class="props___oL_Cw"><i class="bonus-attachment-item-accuracy-bonus" aria-label="Accuracy"></i><span class="bonusInfo___vyqlT">—</span></div>`;
        }
        const xp = wrapper.querySelector(".tt-weapon-experience");
        if (xp) xp.textContent = "";
        wrapper.setAttribute("aria-label", slotLabel || "Empty");
    }
 
    function renderArmor(defenderArea, loadout) {
        const modelLayers = queryFirst(defenderArea, ["[class*='modelLayers']"]);
        const parent = modelLayers || defenderArea;
        if (!parent) return;
 
        let overlay = parent.querySelector(".loadout-armor-overlay");
        if (overlay) overlay.remove();
 
        overlay = W.document.createElement("div");
        overlay.className = "loadout-armor-overlay";
        // Visual-only overlay; never block Torn native usemap hover.
        overlay.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:4;transform:translateY(20px);";
 
        // Render only actual armor model slots (skip slot 10 body cosmetics like Coconut Bra).
        for (const slot of [8, 7, 9, 6, 4]) {
            const item = loadout?.[slot];
            if (!item) continue;
 
            const container = W.document.createElement("div");
            container.className = "armourContainer___zL52C";
            container.style.zIndex = String(ARMOR_Z_INDEX[slot] ?? 14);
 
            const armor = W.document.createElement("div");
            armor.className = "armour___fLnYY";
 
            const img = W.document.createElement("img");
            img.className = "itemImg___B8FMH";
            img.src = `${CONFIG.imagesBase}/v2/items/model-items/${item.item_id}m.png`;
            img.alt = item.item_name || "";
            img.title = "";
            img.style.pointerEvents = "none";
 
            armor.appendChild(img);
            container.appendChild(armor);
            overlay.appendChild(container);
        }
 
        parent.appendChild(overlay);
    }
 
    function renderLoadout(loadout, submittedBy) {
        if (!loadout || STATE.loadoutRendered) return;
 
        const timer = W.setInterval(() => {
            if (STATE.loadoutRendered) {
                W.clearInterval(timer);
                return;
            }
            const defenderArea = getDefenderArea();
            if (!defenderArea) return;
 
            W.clearInterval(timer);
            dbg("renderLoadout: area found, applying");
 
            // Torn's attack UI is React-driven and may partially re-render the weapon header.
            // A single short re-apply fixes cases where the first mod icon is missing.
            const shouldReapplyWeapons = !STATE.weaponsReapplied;

            const safeSubmitterId = submittedBy && /^\d{1,8}$/.test(String(submittedBy)) ? String(submittedBy) : null;
            if (safeSubmitterId) {
                let badge = W.document.querySelector(".loadout-shared-badge");
                if (!badge) {
                    badge = W.document.createElement("span");
                    badge.className = "loadout-shared-badge";
                    badge.style.cssText = "position:fixed;top:12px;right:12px;z-index:9999;font-size:13px;font-weight:600;color:#c00;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 link = W.document.createElement("a");
                    link.href = `${TORN_BASE}/profiles.php?XID=${safeSubmitterId}`;
                    link.target = "_blank";
                    link.rel = "noopener";
                    link.style.cssText = "color:#f44;text-decoration:none;";
                    link.textContent = `(shared by ${safeSubmitterId})`;
                    link.title = "Loadout shared – view submitter";
                    badge.appendChild(link);
                    W.document.body.appendChild(badge);
                } else {
                    const link = badge.querySelector("a");
                    if (link) {
                        link.href = `${TORN_BASE}/profiles.php?XID=${safeSubmitterId}`;
                        link.textContent = `(shared by ${safeSubmitterId})`;
                    }
                }
            }
 
            // Detect which weapon set is mounted.
            const defenderPrimaryPresent = !!defenderArea.querySelector("#defender_Primary");
            const attackerPrimaryPresent = !!defenderArea.querySelector("#attacker_Primary");
            const attackerSecondaryPresent = !!defenderArea.querySelector("#attacker_Secondary");
            const attackerMeleePresent = !!defenderArea.querySelector("#attacker_Melee");
            const attackerTempPresent = !!defenderArea.querySelector("#attacker_Temporary");
            const genericWeaponMainPresent = !!defenderArea.querySelector("#weapon_main");
 
            const hasDefender = defenderPrimaryPresent;
            const hasAttacker = attackerPrimaryPresent || genericWeaponMainPresent;
            const includeLabel = hasDefender || hasAttacker;
            const defenderReplacesAttacker = hasAttacker && !hasDefender;
 
            const slotMappings = [
                { selector: hasDefender ? "#defender_Primary" : attackerPrimaryPresent ? "#attacker_Primary" : "#weapon_main", slot: 1, label: "Primary" },
                { selector: hasDefender ? "#defender_Secondary" : attackerSecondaryPresent ? "#attacker_Secondary" : "#weapon_second", slot: 2, label: "Secondary" },
                { selector: hasDefender ? "#defender_Melee" : attackerMeleePresent ? "#attacker_Melee" : "#weapon_melee", slot: 3, label: "Melee" },
                { selector: hasDefender ? "#defender_Temporary" : attackerTempPresent ? "#attacker_Temporary" : "#weapon_temp", slot: 5, label: "Temporary" },
            ];
 
            const doApplyWeaponsAndArmor = () => {
                for (const { selector, slot, label } of slotMappings) {
                    const marker = defenderArea.querySelector(selector);
                    const wrapper = marker?.closest("[class*='weaponWrapper'], [class*='weaponSlot'], [class*='weapon']");
                    if (!wrapper) continue;
 
                    if (loadout?.[slot]) {
                        renderSlot(wrapper, loadout[slot], label, includeLabel, slot);
                    } else if (defenderReplacesAttacker) {
                        clearSlot(wrapper, label, includeLabel, slot);
                    }
                }
                renderArmor(defenderArea, loadout);
            };
 
            doApplyWeaponsAndArmor();

            if (shouldReapplyWeapons) {
                STATE.weaponsReapplied = true;
                W.setTimeout(() => {
                    // Guard: page may have changed; only reapply if attack area still exists.
                    if (STATE.loadoutRendered) return;
                    const area = getDefenderArea();
                    if (!area) return;
                    try {
                        doApplyWeaponsAndArmor();
                    } catch {}
                }, 350);
            }
 
            const modal = queryFirst(defenderArea, ["[class*='modal']"]);
            if (modal) {
                modal.style.background = "transparent";
                modal.style.backdropFilter = "none";
                modal.style.webkitBackdropFilter = "none";
            }
 
            renderDefenderPanel(loadout);
            STATE.loadoutRendered = true;
        }, DOM_POLL_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);
        } 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;
        if (!data.attackerUser && !data.DB?.attackerUser) return;
 
        const db = data.DB || data;
        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);
            }
        } else if (isFirstData) {
            dbg("processResponse: fetchAndRenderLoadout");
            fetchAndRenderLoadout();
        }
    }
 
    if (typeof W.fetch === "function") {
        const origFetch = W.fetch;
        W.fetch = async function (...args) {
            const url = typeof args[0] === "string" ? args[0] : args[0]?.url || "";
            if (!url.includes("sid=attackData")) return origFetch.apply(this, args);
 
            const response = await origFetch.apply(this, args);
            try { response.clone().text().then((text) => processResponse(parseJson(text))); } catch {}
            return response;
        };
    }
 
    function tryFetchByUrlTarget() {
        if (!getUrlTargetId() || STATE.loadoutRendered || STATE.attackData?.defenderUser?.userID || !getDefenderArea()) return;
        fetchAndRenderLoadout();
    }
 
    whenVisible(() => {
        FALLBACK_DELAYS_MS.forEach((ms) => W.setTimeout(tryFetchByUrlTarget, ms));
    });
 
})();