Torn Loadout Share

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

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

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.

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

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

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      2.0.0
// @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 = { loadoutRendered: false, lastNativeTargetId: null, lastRenderedTargetId: null, resolvingTargetId: null, processingTargetId: null };
    const FALLBACK_CACHE = {}; // in-memory: only fetch from Firebase once per target per page load

    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 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 [];
    }

    function mergeBonusesIntoNative(native, fallback) {
        if (!native || !fallback?.loadout) return native;
        const out = { ...native };
        for (const slot of [...WEAPON_SLOTS, ...ARMOR_SLOTS]) {
            const n = native[slot];
            const f = fallback.loadout[slot] ?? fallback.loadout[String(slot)];
            const fBonuses = getItemBonuses(f);
            if (n && fBonuses.length > 0) {
                const nBonuses = getItemBonuses(n);
                if (nBonuses.length === 0) out[slot] = { ...n, bonuses: fBonuses };
            }
        }
        return out;
    }

    async function getFallbackLoadout(playerId, targetId) {
        const key = getCacheKey(playerId, targetId);
        if (key in FALLBACK_CACHE) return FALLBACK_CACHE[key];
        dbg("fallback: trying Firebase", key);
        const fromFirebase = await getFromFirebase(playerId, targetId);
        if (fromFirebase) {
            normalizeLoadout(fromFirebase.loadout);
            dbg("fallback: Firebase hit (player_target)", Object.keys(fromFirebase.loadout || {}).length, "slots");
            FALLBACK_CACHE[key] = fromFirebase;
            return fromFirebase;
        }
        dbg("fallback: Firebase player_target miss");
        const fromByTarget = await getFromFirebaseByTarget(targetId);
        if (fromByTarget) {
            normalizeLoadout(fromByTarget.loadout);
            dbg("fallback: Firebase by_target hit", Object.keys(fromByTarget.loadout || {}).length, "slots");
            FALLBACK_CACHE[key] = fromByTarget;
            return fromByTarget;
        }
        dbg("fallback: Firebase by_target miss");
        FALLBACK_CACHE[key] = null;
        return null;
    }

    async function resolveLoadout(db, playerId, targetId) {
        // Flow: pre-fight = Firebase only (once per page; refresh refetches).
        // Fight started = native only, upload to Firebase.
        const hasNative = hasNativeDefenderLoadout(db.defenderItems);
        dbg("resolve: target=", targetId, "player=", playerId, "hasNative=", hasNative);

        const nativeLoadout = hasNative ? parseDefenderItemsToLoadout(db.defenderItems) : null;
        if (nativeLoadout) {
            dbg("resolve: native slots", Object.keys(nativeLoadout), "bonuses:", JSON.stringify(
                Object.fromEntries(
                    Object.entries(nativeLoadout).map(([s, i]) => [s, (i?.bonuses || []).map((b) => b?.name || b?.bonus_key)])
                )
            ));
        }

        if (nativeLoadout) {
            const fallback = await getFallbackLoadout(playerId, targetId);
            const loadout = mergeBonusesIntoNative(nativeLoadout, fallback);
            dbg("resolve: native + bonus merge from fallback");
            return { loadout, submittedBy: fallback?.submittedBy ?? null, fromNative: true };
        }

        const fallback = await getFallbackLoadout(playerId, targetId);
        if (fallback) {
            const loadout = fallback.loadout;
            dbg("resolve: using fallback only, slots", Object.keys(loadout || {}), "bonuses:", JSON.stringify(
                Object.fromEntries(
                    Object.entries(loadout || {}).map(([s, i]) => [s, (i?.bonuses || []).map((b) => b?.name || b?.bonus_key)])
                )
            ));
            return { ...fallback, loadout, fromNative: false };
        }
        dbg("resolve: no loadout found");
        return null;
    }

    function debugLog(loadout, source, targetId, playerId, submittedBy) {
        if (LOG_LEVEL < 1) return;
        const slots = [1, 2, 3, 5];
        const summary = slots.map((s) => {
            const item = loadout?.[s];
            if (!item) return `  ${s}: (empty)`;
            const bonuses = (item.bonuses || []).map((b) => b?.name || b?.bonus_key || "?").join(", ");
            return `  ${s}: ${item.item_name || "?"} | bonuses: [${bonuses || "none"}]`;
        }).join("\n");
        W.console.log(
            "[Loadout] source=%s target=%s player=%s submittedBy=%s\n" +
            "Expected weapons & bonuses:\n%s",
            source, targetId, playerId, submittedBy ?? "-", summary
        );
    }

    async function processResponse(data) {
        if (!data || typeof data !== "object") return;
        const db = data.DB ?? data;
        if (!db.attackerUser && !data.attackerUser) return;

        const playerId = db.attackerUser?.userID;
        const targetId = db.defenderUser?.userID;
        if (!playerId || !targetId) return;

        const urlTargetId = (W.location?.href?.match(/user2ID=(\d+)/) || [])[1];
        if (urlTargetId && String(targetId) !== urlTargetId) {
            dbg("processResponse: ignoring response for target", targetId, "(page is", urlTargetId, ")");
            return;
        }

        const tId = String(targetId);
        if (STATE.processingTargetId === tId) return;
        STATE.processingTargetId = tId;

        const defenderItems = db.defenderItems ?? db.attackData?.defenderItems ?? (db.DB && (db.DB.defenderItems ?? db.DB.attackData?.defenderItems));
        const hasNative = hasNativeDefenderLoadout(defenderItems);
        const fightStarted = (() => {
            const ad = data?.attackData ?? data?.DB?.attackData ?? db?.attackData ?? db;
            const round = ad?.round;
            if (round != null && Number(round) >= 1) return true;
            const actions = ad?.attackActions ?? ad?.actions;
            if (Array.isArray(actions) && actions.length > 0) return true;
            return false;
        })();
        dbg("processResponse: target=", targetId, "player=", playerId, "hasNative=", hasNative, "fightStarted=", fightStarted);

        if (tId !== String(STATE.lastRenderedTargetId || "")) { STATE.loadoutRendered = false; STATE.lastRenderedTargetId = null; }
        if (hasNative && fightStarted && String(STATE.lastNativeTargetId || "") === tId) {
            STATE.processingTargetId = null;
            return;
        }

        const dbForResolve = defenderItems != null ? { ...db, defenderItems } : db;
        const result = await resolveLoadout(dbForResolve, playerId, targetId);
        if (!result) {
            dbg("processResponse: no result, skipping render");
            STATE.processingTargetId = null;
            return;
        }

        const { loadout: rawLoadout, submittedBy } = result;
        const loadout = normalizeLoadout(rawLoadout);
        if (!validateLoadout(loadout)) {
            dbg("processResponse: validateLoadout FAILED", loadout);
            STATE.processingTargetId = null;
            return;
        }

        if (result.fromNative && fightStarted) {
            putToFirebase(playerId, targetId, loadout);
            STATE.lastNativeTargetId = tId;
            dbg("processResponse: fight started, native only, uploaded to Firebase, not rendering (Torn shows it)");
            STATE.processingTargetId = null;
            return;
        }
        if (result.fromNative && !fightStarted) {
            dbg("processResponse: pre-fight, native present but fight not started – render shared loadout (no upload)");
            STATE.lastRenderedTargetId = tId;
            renderLoadout(loadout, result.submittedBy);
            STATE.processingTargetId = null;
            return;
        }

        const source = "shared";
        dbg("processResponse: rendering source=", source, "slots=", Object.keys(loadout));
        debugLog(loadout, source, targetId, playerId, submittedBy);

        STATE.lastRenderedTargetId = tId;
        STATE.lastNativeTargetId = null;
        renderLoadout(loadout, submittedBy);
        STATE.processingTargetId = null;
    }

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

    async function preFightFetch() {
        const m = location.href.match(/user2ID=(\d+)/);
        const targetId = m ? m[1] : null;
        if (!targetId) return;

        const preKey = "pre_" + targetId;
        if (preKey in FALLBACK_CACHE) return;
        FALLBACK_CACHE[preKey] = true;

        const result = await getFromFirebaseByTarget(targetId);
        if (!result?.loadout || !validateLoadout(result.loadout)) return;
        normalizeLoadout(result.loadout);
        dbg("preFight: rendering from Firebase target=", targetId);
        renderLoadout(result.loadout, result.submittedBy);
    }

    preFightFetch();
})();