Torn Loadout Share

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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));
    });
 
})();