Torn Loadout Share

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      2.0.1
// @description  Shared defender loadouts on Torn attack pages. Firebase backend, no registration.
// @license      MIT
// @homepageURL  https://greasyfork.org/en/scripts/570037-torn-loadout-share
// @match        https://www.torn.com/loader.php?sid=attack&user2ID=*
// @connect      *.firebasedatabase.app
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
    "use strict";

    const W = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
    const LOG_LEVEL = 0;
    function dbg(...args) { if (LOG_LEVEL >= 2 && W.console?.log) W.console.log("[Loadout]", ...args); }
    function log(minLevel, ...args) { if (LOG_LEVEL >= minLevel && W.console?.log) W.console.log("[Loadout]", ...args); }
    const BONUS_KEY_FIX = { hazarfouse: "hazardous" }; // S&R API typos -> Torn CSS class
    const FIREBASE_FETCH_TIMEOUT_MS = 5000;
    const CONFIG = { firebaseUrl: "https://torn-loadout-share-default-rtdb.europe-west1.firebasedatabase.app" };
    const WEAPON_SLOTS = [1, 2, 3, 5];
    const ARMOR_SLOTS = [8, 7, 9, 6, 4, 10];
    const ARMOR_LAYER_ORDER = { 8: 10, 7: 11, 9: 12, 6: 13, 4: 14, 10: 15 };
    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 STATE = { uploaded: false, attackData: null, loadoutRendered: false };

    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 prepareLoadoutForGun(loadout) {
        if (!loadout || typeof loadout !== "object") return loadout;
        const out = {};
        for (const [k, item] of Object.entries(loadout)) {
            if (!item || typeof item !== "object") continue;
            const obj = {
                item_id: item.item_id,
                item_name: item.item_name,
                damage: item.damage,
                accuracy: item.accuracy,
                rarity: item.rarity,
                mods: Array.isArray(item.mods) ? item.mods : (item.mods ? [].concat(item.mods) : []),
                bonuses: Array.isArray(item.bonuses) ? item.bonuses : (item.bonuses ? [].concat(item.bonuses) : []),
            };
            out[k] = JSON.stringify(obj);
        }
        return out;
    }

    function getItemBonuses(item) {
        if (!item || typeof item !== "object") return [];
        const b = item.bonuses ?? item.Bonuses ?? item.bonus;
        return parseBonuses(b);
    }

    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) { dbg("firebaseRequest: no base URL"); return Promise.resolve(null); }
        return new Promise((resolve) => {
            const url = `${base}/${path}.json`;
            const opts = { method, url };
            if (body != null && method !== "GET") opts.data = JSON.stringify(body);
            let settled = false;
            const timer = W.setTimeout(() => {
                if (settled) return;
                settled = true;
                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") {
                    dbg("firebaseRequest: GM_xmlhttpRequest not available");
                    done(null);
                    return;
                }
                GM_xmlhttpRequest({
                    ...opts,
                    timeout: FIREBASE_FETCH_TIMEOUT_MS,
                    anonymous: true,
                    onload: (r) => {
                        if (r.status >= 200 && r.status < 300) {
                            const text = r.responseText ?? "";
                            try { done(text ? JSON.parse(text) : null); } catch { done(null); }
                        } else {
                            dbg("firebaseRequest: status", r.status, path);
                            done(null);
                        }
                    },
                    onerror: (e) => {
                        dbg("firebaseRequest: onerror", path, e);
                        done(null);
                    },
                });
            } catch (e) {
                dbg("firebaseRequest: exception", path, e);
                done(null);
            }
        });
    }

    async function putToFirebase(playerId, targetId, loadout) {
        const key = getCacheKey(playerId, targetId);
        const gunLoadout = prepareLoadoutForGun(loadout);
        const loadoutJson = JSON.stringify(gunLoadout);
        const payload = { loadoutJson, timestamp: Date.now(), playerId, targetId };
        await firebaseRequest("PUT", `loadouts/${key}`, payload);
        await firebaseRequest("PUT", `loadouts/by_target/${targetId}`, payload);
    }

    function parseLoadoutSlots(loadoutNode) {
        if (!loadoutNode || typeof loadoutNode !== "object") return {};
        const slots = {};
        const entries = Object.entries(loadoutNode).filter(([k]) => k !== "_");
        for (const [k, v] of entries) {
            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 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);
        dbg("getFromFirebase: player_target", key);
        const data = await firebaseRequest("GET", `loadouts/${key}`);
        const loadout = parsePayloadToLoadout(data);
        if (loadout) return { loadout, submittedBy: null };
        return null;
    }

    async function getFromFirebaseByTarget(targetId) {
        dbg("getFromFirebaseByTarget:", targetId);
        const data = await firebaseRequest("GET", `loadouts/by_target/${targetId}`);
        const loadout = parsePayloadToLoadout(data);
        if (loadout) {
            const id = data?.playerId;
            const submittedBy = id != null && Number.isInteger(Number(id)) && Number(id) > 0 ? String(id) : null;
            return { loadout, submittedBy };
        }
        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?.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 name = item.name ?? item.item_name ?? item.Name ?? "";
            const damage = Number(item.damage ?? item.Damage ?? 0) || 0;
            const accuracy = Number(item.accuracy ?? item.Accuracy ?? 0) || 0;
            const rarity = String(item.rarity ?? item.Rarity ?? "").toLowerCase() || "default";
            const mods = Array.isArray(item.mods) ? item.mods : (item.Mods ? [].concat(item.Mods) : []);
            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.description ?? "" }
                : { 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 <= 10 && getItemFromSlot(slot) !== null;
        });
    }

    function getDefenderArea() {
        const marker = queryFirst(W.document, [
            "#defender_Primary",
            "#defender_Secondary",
            "#defender_Melee",
            "#defender_Temporary",
        ]);
        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;
    }

    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 = `https://www.torn.com/images/items/${item.item_id}/large`;
            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 renderArmor(defenderArea, loadout) {
        const modelLayers = queryFirst(defenderArea, ["[class*='modelLayers']"]);
        const armorWrap = modelLayers ? queryFirst(modelLayers, ["[class*='armoursWrap']"]) : null;
        if (!armorWrap) return;

        armorWrap.innerHTML = "";
        armorWrap.style.cssText =
            "position:absolute;inset:0;pointer-events:none;z-index:4;transform:translateY(20px);";

        for (const slot of ARMOR_SLOTS) {
            const item = loadout[slot];
            if (!item) continue;

            const container = W.document.createElement("div");
            container.className = "armourContainer___zL52C";
            container.style.zIndex = String(ARMOR_LAYER_ORDER[slot]);

            const armor = W.document.createElement("div");
            armor.className = "armour___fLnYY";
            const img = W.document.createElement("img");
            img.className = "itemImg___B8FMH";
            img.src = `https://www.torn.com/images/v2/items/model-items/${item.item_id}m.png`;
            img.alt = "";
            armor.appendChild(img);
            container.appendChild(armor);
            armorWrap.appendChild(container);
        }
    }

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

            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 = `https://www.torn.com/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 = `https://www.torn.com/profiles.php?XID=${safeSubmitterId}`;
                        link.textContent = `(shared by ${safeSubmitterId})`;
                    }
                }
            }

            const hasDefender = !!defenderArea.querySelector("#defender_Primary");
            const hasAttacker = !!defenderArea.querySelector("#attacker_Primary");
            const includeLabel = hasDefender || hasAttacker;

            for (const { slot, label, def, atk, fallback } of SLOT_MAPPINGS) {
                const selector = hasDefender ? def : hasAttacker ? atk : fallback;
                const marker = defenderArea.querySelector(selector);
                const wrapper = marker?.closest("[class*='weaponWrapper'], [class*='weapon']");
                if (wrapper && loadout[slot]) {
                    renderSlot(wrapper, loadout[slot], label, includeLabel, slot);
                }
            }

            renderArmor(defenderArea, loadout);

            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(db) {
        const playerId = db?.attackerUser?.userID;
        const targetId = db?.defenderUser?.userID;
        if (!playerId || !targetId) return;
        const defenderItems = db.defenderItems ?? db.attackData?.defenderItems;
        const loadout = parseDefenderItemsToLoadout(defenderItems);
        if (!loadout || !validateLoadout(loadout)) return;
        normalizeLoadout(loadout);
        await putToFirebase(playerId, targetId, loadout);
    }

    async function fetchAndRenderLoadout() {
        const playerId = STATE.attackData?.attackerUser?.userID;
        const targetId = STATE.attackData?.defenderUser?.userID;
        if (!playerId || !targetId) return;

        const urlTargetId = (W.location?.href?.match(/user2ID=(\d+)/) || [])[1];
        if (urlTargetId && String(targetId) !== urlTargetId) return;

        let result = await getFromFirebase(playerId, targetId);
        if (!result) result = await getFromFirebaseByTarget(targetId);
        if (!result?.loadout || !validateLoadout(result.loadout)) return;
        normalizeLoadout(result.loadout);
        renderLoadout(result.loadout, result.submittedBy);
    }

    function processResponse(data) {
        if (!data || typeof data !== "object") return;
        const db = data.DB ?? data.attackData ?? data;
        if (!db?.attackerUser && !data?.attackerUser) return;
        const targetId = db?.defenderUser?.userID;
        const urlTargetId = (W.location?.href?.match(/user2ID=(\d+)/) || [])[1];
        if (urlTargetId && targetId != null && String(targetId) !== urlTargetId) return;
        const prevTargetId = STATE.attackData?.defenderUser?.userID;
        const targetChanged = prevTargetId != null && String(targetId) !== String(prevTargetId);
        if (targetChanged) {
            STATE.uploaded = false;
            STATE.loadoutRendered = false;
        }
        const isFirstData = !STATE.attackData || targetChanged;
        STATE.attackData = db;

        const defenderItems = db.defenderItems ?? db.attackData?.defenderItems;
        if (hasNativeDefenderLoadout(defenderItems) && !STATE.uploaded) {
            STATE.uploaded = true;
            whenVisible(() => uploadLoadoutData(db));
        } else if (isFirstData) {
            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 {
                const clone = response.clone();
                clone.text().then((text) => processResponse(parseJson(text))).catch(() => {});
            } catch {}
            return response;
        };
    }

})();