Greasy Fork is available in English.

Torn Loadout Share

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

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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

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

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

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

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Torn Loadout Share
// @namespace    loadout
// @version      1.0.10
// @description  Shows shared defender loadouts on Torn attack pages. Firebase backend, no registration.
// @license      MIT
// @supportURL   https://greasyfork.org/en/scripts/570121/feedback
// @homepageURL  https://greasyfork.org/en/scripts/570121-torn-loadout-share
// @match        https://www.torn.com/loader.php?sid=attack&user2ID=*
// @connect      *.firebasedatabase.app
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// ==/UserScript==

(function () {
    "use strict";

    const W = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
    // Torn-PDA exposes a Flutter bridge. Some builds may not have `callHandler` ready at document-start,
    // so we detect PDA just by bridge presence.
    const IS_PDA = typeof W.flutter_inappwebview !== "undefined";
    // LOG_LEVEL: 0 = none, 1 = log, 2 = debug (also writes to Firebase `debug/`)
    const LOG_LEVEL = 2;
    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);
    }
    const DEBUG = EFFECTIVE_LOG_LEVEL >= 2;
    const PDA_HEARTBEAT = true; // PDA probes: write to RTDB `errors/` and show a local on-screen marker.
    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];
    const ARMOR_SLOTS = [8, 7, 9, 6, 4, 10];
    const SLOT_MAPPINGS = [
        { slot: 1, label: "Primary", def: "#defender_Primary", atk: "#attacker_Primary", fallback: "#weapon_main" },
        { slot: 2, label: "Secondary", def: "#defender_Secondary", atk: "#attacker_Secondary", fallback: "#weapon_second" },
        { slot: 3, label: "Melee", def: "#defender_Melee", atk: "#attacker_Melee", fallback: "#weapon_melee" },
        { slot: 5, label: "Temporary", def: "#defender_Temporary", atk: "#attacker_Temporary", fallback: "#weapon_temp" },
    ];
    const DOM_POLL_MS = 100;
    const FALLBACK_DELAYS_MS = [1500, 3000, 5000];
    const ARMOR_Z_INDEX = { 8: 10, 7: 11, 9: 12, 6: 13, 4: 14 };

    const STATE = { uploaded: false, attackData: null, loadoutRendered: false };

    function getUrlTargetId() {
        return (W.location?.href?.match(/user2ID=(\d+)/) || [])[1] ?? null;
    }

    let pdaProbeEl = null;
    function pdaProbe(label, data = {}) {
        if (!PDA_HEARTBEAT || !IS_PDA) return;
        try {
            // Always show a local marker so we can confirm the script runs on PDA.
            try {
                if (!pdaProbeEl) {
                    pdaProbeEl = W.document.createElement("div");
                    pdaProbeEl.id = "loadout-pda-probe";
                    pdaProbeEl.style.cssText =
                        "position:fixed;left:10px;bottom:10px;z-index:2147483647;background:rgba(0,0,0,0.8);color:#fff;font-size:11px;padding:6px 8px;border-radius:8px;max-width:70vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
                    W.document.documentElement.appendChild(pdaProbeEl);
                }
                const tId = getUrlTargetId();
                pdaProbeEl.textContent = `Loadout PDA: ${label} user2ID=${tId ?? "-"}`;
            } catch {}

            // Write probes to `errors/` since `debug/` may be blocked by RTDB rules.
            firebaseRequest("POST", "errors", { ctx: "pda_probe", label, ts: Date.now(), user2ID: getUrlTargetId(), ...data });
        } catch (_) {}
    }

    // Confirm script execution on PDA
    pdaProbe("pda_start", { href: String(W.location?.href ?? "").slice(0, 120) });

    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, 10]);
        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;
        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;
                }

                // Torn-PDA bridge support (matches S&R behaviour)
                const bridge = W.flutter_inappwebview;
                const tryPda = () => {
                    if (settled) return;
                    if (!bridge?.callHandler) return false;
                    const handler = method === "GET" ? "PDA_httpGet" : "PDA_httpPost";
                    const call = method === "GET"
                        ? bridge.callHandler(handler, url, headers)
                        : bridge.callHandler(handler, url, headers, bodyStr ?? "");
                    call
                        .then((r) => {
                            const status = Number(r?.status ?? 0);
                            const text = String(r?.responseText ?? "");
                            if (status >= 200 && status < 300) {
                                try { done(text ? JSON.parse(text) : null); } catch { done(null); }
                            } else {
                                log("firebaseRequest(PDA): status", status, path);
                                reportError("firebase_request", "HTTP", { method, path, status });
                                done(null);
                            }
                        })
                        .catch((e) => {
                            log("firebaseRequest(PDA): exception", path, e);
                            reportError("firebase_request", "PDA bridge exception", { method, path });
                            done(null);
                        });
                    return true;
                };

                if (tryPda()) return;

                // On some PDA builds the flutter bridge appears slightly after document-start.
                if (IS_PDA && bridge) {
                    const start = Date.now();
                    const retry = () => {
                        if (settled) return;
                        if (tryPda()) return;
                        if (Date.now() - start < 2500) W.setTimeout(retry, 200);
                        else done(null);
                    };
                    retry();
                    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 dbgToFirebase(label, data) {
        if (!DEBUG || !CONFIG.firebaseUrl) return;
        try {
            firebaseRequest("POST", "debug", { label, ...data, ts: Date.now(), IS_PDA, user2ID: getUrlTargetId() });
        } catch (_) {}
    }

    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;
            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);
        if (r1 == null || r2 == null) {
            reportError("upload", "Firebase PUT returned null", { playerId, targetId });
        }
    }

    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 isElementVisible(el) {
        if (!el) return false;
        try {
            const s = W.getComputedStyle(el);
            if (s.display === "none") return false;
            if (s.visibility === "hidden") return false;
            if (s.opacity === "0") return false;
            if (el.offsetParent != null) return true;
            if (s.position === "fixed") return true;
            // Some mobile layouts can yield offsetParent=null even when visible.
            if (typeof el.getClientRects === "function") return el.getClientRects().length > 0;
            return true;
        } catch {
            return true;
        }
    }

    function queryFirstInDocs(selectors) {
        for (const doc of getDocAndIframes()) {
            const n = queryFirst(doc, selectors);
            if (n) return n;
        }
        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;
        return Object.entries(defenderItems).some(([key, slot]) => {
            const k = Number(key);
            return k >= 1 && k <= 9 && slot?.item?.[0]?.ID;
        });
    }

    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() {
        if (IS_PDA) {
            const marker = queryFirst(W.document, DEFENDER_MARKERS);
            if (marker) {
                const owner = marker.closest("[class*='playerArea'], [class*='player___']");
                if (owner) return owner;
            }
            const areas = W.document.querySelectorAll("[class*='playerArea']");
            return (areas.length > 1 ? areas[1] : areas[0]) || null;
        }

        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);
                    dbgToFirebase("getDefenderArea", { via: "defender_marker", markerId: 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];
                dbgToFirebase("getDefenderArea", { via: "single_area", atk: !!a.querySelector("#attacker_Primary"), wep: !!a.querySelector("#weapon_main"), model: !!a.querySelector("[class*='modelLayers']") });
                return a;
            }
            const withAttacker = [...areas].find((a) => a.querySelector("#attacker_Primary, #weapon_main"));
            const chosen = withAttacker ?? areas[1] ?? areas[0];
            dbgToFirebase("getDefenderArea", { via: "multi", areas: areas.length, chose: withAttacker ? "withAttacker" : "fallback", atk: !!chosen?.querySelector("#attacker_Primary"), wep: !!chosen?.querySelector("#weapon_main"), model: !!chosen?.querySelector("[class*='modelLayers']") });
            return chosen;
        }
        dbgToFirebase("getDefenderArea", { via: "null" });
        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 applyWeaponLoadoutToSlots(defenderArea, loadout, hasDefender, hasAttacker, defenderReplacesAttacker) {
        const includeLabel = hasDefender || hasAttacker;
        const slotsApplied = [];
        for (const { slot, label, def, atk, fallback } of SLOT_MAPPINGS) {
            const selectors = defenderReplacesAttacker ? [def, atk, fallback] : (hasDefender ? [def] : hasAttacker ? [atk] : [fallback]);
            let marker = null;
            let usedSel = null;
            for (const sel of selectors) {
                const m = queryFirstInDocs([sel]);
                if (!m) continue;
                // Prefer the currently visible tab/view marker.
                if (!marker && !usedSel) { marker = m; usedSel = sel; }
                if (isElementVisible(m)) { marker = m; usedSel = sel; break; }
            }
            const wrapper = marker?.closest("[class*='weaponWrapper'], [class*='weaponSlot'], [class*='weapon']");
            slotsApplied.push({ slot, label, marker: !!marker, usedSel, wrapper: !!wrapper, applied: !!wrapper && (!!loadout?.[slot] || defenderReplacesAttacker) });
            if (!wrapper) continue;
            if (loadout?.[slot]) {
                renderSlot(wrapper, loadout[slot], label, includeLabel, slot);
            } else if (defenderReplacesAttacker) {
                clearSlot(wrapper, label, includeLabel, slot);
            }
        }
        dbgToFirebase("applyWeaponLoadoutToSlots", { hasDefender, hasAttacker, defenderReplacesAttacker, slotsApplied });
    }

    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";
        overlay.style.cssText = `position:absolute;inset:0;pointer-events:none;z-index:4;transform:translateY(${IS_PDA ? "10px" : "20px"});`;

        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]);

            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 = "";
            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");
            pdaProbe("renderLoadout_enter", { isPda: IS_PDA, hasArea: !!defenderArea, href: String(W.location?.href ?? "").slice(0, 80) });

            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})`;
                    }
                }
            }

            // PDA/React view differences: detect which weapon set is currently mounted in this area.
            const hasDefender = !!defenderArea.querySelector("#defender_Primary");
            const hasAttacker = !!defenderArea.querySelector("#attacker_Primary");
            const includeLabel = hasDefender || hasAttacker;
            const defenderReplacesAttacker = hasAttacker && !hasDefender;

            const slotMappings = [
                { selector: hasDefender ? "#defender_Primary" : hasAttacker ? "#attacker_Primary" : "#weapon_main", slot: 1, label: "Primary" },
                { selector: hasDefender ? "#defender_Secondary" : hasAttacker ? "#attacker_Secondary" : "#weapon_second", slot: 2, label: "Secondary" },
                { selector: hasDefender ? "#defender_Melee" : hasAttacker ? "#attacker_Melee" : "#weapon_melee", slot: 3, label: "Melee" },
                { selector: hasDefender ? "#defender_Temporary" : hasAttacker ? "#attacker_Temporary" : "#weapon_temp", slot: 5, label: "Temporary" },
            ];

            const doApplyWeaponsAndArmor = () => {
                const weaponDebug = [];
                for (const { selector, slot, label } of slotMappings) {
                    const marker = IS_PDA ? queryFirstInDocs([selector]) : defenderArea.querySelector(selector);
                    const wrapper = marker?.closest("[class*='weaponWrapper'], [class*='weapon']");
                    const hasItem = !!loadout?.[slot];
                    weaponDebug.push({ slot, selector, marker: !!marker, wrapper: !!wrapper, hasItem });
                    if (wrapper && hasItem) {
                        renderSlot(wrapper, loadout[slot], label, includeLabel, slot);
                    } else if (wrapper && defenderReplacesAttacker) {
                        clearSlot(wrapper, label, includeLabel, slot);
                    }
                }
                dbg("renderLoadout", { IS_PDA, hasDefender, hasAttacker, defenderReplacesAttacker, includeLabel, slotDebug: weaponDebug });
                dbgToFirebase("renderLoadout_weapons", { IS_PDA, hasDefender, hasAttacker, defenderReplacesAttacker, includeLabel, slotDebug: weaponDebug });

                renderArmor(defenderArea, loadout);
            };

            doApplyWeaponsAndArmor();
            if (IS_PDA && defenderReplacesAttacker) {
                dbg("renderLoadout: PDA delayed re-apply weapons+armor at 300,600,1000ms");
                [300, 600, 1000, 1600].forEach((ms) => W.setTimeout(() => { dbgToFirebase("renderLoadout_weapons", { reapply: ms }); doApplyWeaponsAndArmor(); }, ms));
            }

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

            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;
            raw.timestamp = Math.floor(Date.now() / 1000);
            await putToFirebase(playerId, targetId, raw);
        } catch (e) {
            reportError("upload", e, { playerId: raw?.attackerUser?.userID, targetId: raw?.defenderUser?.userID });
        }
    }

    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;
            if (!validateLoadout(result.loadout)) {
                reportError("validate", "validateLoadout failed", { playerId, targetId, loadoutKeys: Object.keys(result.loadout || {}).slice(0, 10) });
                return;
            }
            normalizeLoadout(result.loadout);
            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);
        pdaProbe("processResponse", { isFirstData, hasNative, hasDefenderItems: !!db.defenderItems });
        dbgToFirebase("processResponse", { hasNative, isFirstData, hasDefenderItems: !!db.defenderItems });
        if (hasNative && !STATE.uploaded) {
            STATE.uploaded = true;
            whenVisible(() => uploadLoadoutData(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);
            pdaProbe("fetch_attackData", { url: String(url).slice(0, 120) });

            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;
        pdaProbe("tryFetchByUrlTarget", { targetId: getUrlTargetId() });
        fetchAndRenderLoadout();
    }

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

    if (DEBUG) dbg("DEBUG on", { IS_PDA, url: W.location?.href?.slice(0, 60) });

})();