Shared defender loadouts on Torn attack pages. Firebase backend, no registration.
// ==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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
}
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();
})();