PDA-only: replaces defender weapons/armor on Torn attack pages using Firebase loadouts.
// ==UserScript==
// @name Torn PDA Loadout Share
// @namespace loadout
// @version 1.0.1
// @description PDA-only: replaces defender weapons/armor on Torn attack pages using Firebase loadouts.
// @match https://www.torn.com/loader.php?sid=attack&user2ID=*
// @supportURL https://greasyfork.org/en/scripts/570408/feedback
// @homepageURL https://greasyfork.org/en/scripts/570408-torn-pda-loadout-share
// @license MIT
// @run-at document-start
// @grant unsafeWindow
// @connect torn-loadout-share-default-rtdb.europe-west1.firebasedatabase.app
// ==/UserScript==
(function () {
"use strict";
const W = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
const CONFIG = {
firebaseUrl: "https://torn-loadout-share-default-rtdb.europe-west1.firebasedatabase.app",
};
const WEAPON_SLOTS = [1, 2, 3, 5];
// Slot 10 is alternate helmet/mask, but Torn treats it as body cosmetics on many pages.
// We intentionally ignore it to avoid Coconut Bra-like body cosmetics.
const ARMOR_SLOTS = [8, 7, 9, 6, 4];
const ARMOR_Z_INDEX = { 8: 10, 7: 11, 9: 12, 6: 13, 4: 14 };
const BONUS_KEY_FIX = { hazarfouse: "hazardous" };
const TORN_BASE = "https://www.torn.com";
function waitForPdaBridge(timeoutMs = 4000) {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
try {
const bridge = W.flutter_inappwebview;
if (bridge?.callHandler) return resolve(bridge);
} catch {}
if (Date.now() - start > timeoutMs) return resolve(null);
W.setTimeout(tick, 200);
};
tick();
});
}
function getUrlTargetId() {
return (W.location?.href?.match(/user2ID=(\d+)/) || [])[1] ?? 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 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;
}
function getDefenderArea() {
// PDA-style: use marker owner or fall back to playerArea selection.
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;
}
function queryFirstInDocs(selectors) {
const docs = getDocAndIframes();
for (const doc of docs) {
const n = queryFirst(doc, selectors);
if (n) return n;
}
return null;
}
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 normalizeLoadout(loadout) {
if (!loadout || typeof loadout !== "object") return loadout;
// Backwards compatibility: older Firebase payloads may include slot 10.
if (Object.prototype.hasOwnProperty.call(loadout, 10)) delete loadout[10];
for (const item of Object.values(loadout)) {
if (!item || typeof item !== "object") continue;
if (typeof item.mods === "string") {
try {
item.mods = JSON.parse(item.mods);
} catch {
item.mods = [];
}
}
const b = item.bonuses ?? item.Bonuses ?? item.bonus;
item.bonuses = parseBonuses(b);
if (!Array.isArray(item.mods)) item.mods = [];
}
return loadout;
}
// PDA-only Firebase GET via Flutter bridge.
async function firebaseGet(path) {
const base = CONFIG.firebaseUrl;
if (!base) return null;
const bridge = await waitForPdaBridge();
if (!bridge?.callHandler) return null;
const url = `${base}/${path}.json`;
const headers = {};
try {
const r = await bridge.callHandler("PDA_httpGet", url, headers);
const status = Number(r?.status ?? 0);
const text = String(r?.responseText ?? "");
if (!(status >= 200 && status < 300)) return null;
try {
return text ? JSON.parse(text) : null;
} catch {
return null;
}
} catch {
return null;
}
}
function getItemFromSlot(slot) {
if (!slot) return null;
const item = slot?.item?.[0] ?? slot?.item;
if (!item) return null;
const id = item.ID ?? item.id ?? item.item_id;
if (id == null || id === "") return null;
return item;
}
function extractBonusesFromSlot(slot, item) {
const from =
item?.currentBonuses ??
item?.bonuses ??
item?.Bonuses ??
item?.bonus ??
item?.item_bonuses ??
slot?.bonuses ??
slot?.Bonus ??
slot?.bonus;
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);
}
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 validateLoadout(loadout) {
if (!loadout || typeof loadout !== "object") return false;
const validSlots = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9]);
for (const [k, item] of Object.entries(loadout)) {
const slot = Number(k);
if (!validSlots.has(slot) || !item || typeof item !== "object") return false;
const id = item.item_id ?? item.ID ?? item.id;
if (id == null || !Number.isInteger(Number(id)) || Number(id) < 1 || Number(id) > 999999) return false;
const d = Number(item.damage ?? item.Damage ?? 0);
const a = Number(item.accuracy ?? item.Accuracy ?? 0);
if (!Number.isFinite(d) || d < 0 || d > 1e5) return false;
if (!Number.isFinite(a) || a < 0 || a > 1e5) return false;
if (item.mods != null && !Array.isArray(item.mods)) return false;
if (item.bonuses != null && !Array.isArray(item.bonuses)) return false;
}
return Object.keys(loadout).length > 0;
}
function getBonusIconKey(bonus) {
if (!bonus) return null;
const raw = bonus.bonus_key ?? bonus.icon ?? "";
return BONUS_KEY_FIX[raw] ?? raw;
}
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 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];
return buildIconHtml(iconKey, arr[i][name] ?? arr[i].description, arr[i][desc] ?? arr[i].description);
})
.join("");
}
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 renderSlot(wrapper, item, slotLabel, includeLabel = true, slot = 0) {
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 = `${TORN_BASE}/images/items/${item.item_id}/large`;
const alreadyCorrect = img.src && String(img.src).includes(`/items/${item.item_id}/`);
if (!alreadyCorrect) {
img.src = `${base}.png`;
img.srcset = `${base}.png 1x, ${base}@2x.png 2x, ${base}@3x.png 3x, ${base}@4x.png 4x`;
}
img.alt = item.item_name || "";
img.classList.remove("blank___RpGQA");
img.style.objectFit = "contain";
}
const top = queryFirst(wrapper, ["[class*='top___']"]);
if (top) {
const modIcons = buildSlotIcons(item.mods || [], "icon", "name", "description", false);
const bonusIcons = buildSlotIcons(item.bonuses || [], "bonus_key", "name", "description", true);
top.innerHTML = includeLabel
? `<div class="props___oL_Cw">${modIcons}</div><div class="topMarker___OjRyU"><span class="markerText___HdlDL">${escapeHtml(slotLabel)}</span></div><div class="props___oL_Cw">${bonusIcons}</div>`
: `<div class="props___oL_Cw">${modIcons}</div><div class="props___oL_Cw">${bonusIcons}</div>`;
}
const bottom = queryFirst(wrapper, ["[class*='bottom___']"]);
if (bottom) {
const ammoInner = slot === 3 ? INFINITY_SVG : slot === 5 ? `<span class="markerText___HdlDL standard___bW8M5">1</span>` : `<span class="markerText___HdlDL">Unknown</span>`;
bottom.innerHTML = `<div class="props___oL_Cw"><i class="bonus-attachment-item-damage-bonus" aria-label="Damage"></i><span class="bonusInfo___vyqlT">${formatFixed2(item.damage)}</span></div><div class="bottomMarker___G1uDs">${ammoInner}</div><div class="props___oL_Cw"><i class="bonus-attachment-item-accuracy-bonus" aria-label="Accuracy"></i><span class="bonusInfo___vyqlT">${formatFixed2(item.accuracy)}</span></div>`;
}
let xp = wrapper.querySelector(".tt-weapon-experience");
if (!xp) {
xp = W.document.createElement("div");
xp.className = "tt-weapon-experience";
wrapper.appendChild(xp);
}
xp.textContent = item.item_name || "";
wrapper.setAttribute("aria-label", item.item_name || "Unknown");
}
function clearSlot(wrapper, slotLabel, includeLabel, slot) {
if (!wrapper) return;
wrapper.className = wrapper.className
.split(/\s+/)
.filter((c) => c && !/^glow-/.test(c))
.join(" ");
wrapper.classList.add("glow-default");
const border = queryFirst(wrapper, ["[class*='itemBorder']"]);
if (border) border.className = "itemBorder___mJGqQ glow-default-border";
const img = queryFirst(wrapper, ["[class*='weaponImage'] img", "img"]);
if (img) {
img.removeAttribute("src");
img.removeAttribute("srcset");
img.alt = "";
img.classList.add("blank___RpGQA");
}
const top = queryFirst(wrapper, ["[class*='top___']"]);
if (top) {
top.innerHTML = includeLabel
? `<div class="props___oL_Cw"></div><div class="topMarker___OjRyU"><span class="markerText___HdlDL">${escapeHtml(slotLabel)}</span></div><div class="props___oL_Cw"></div>`
: `<div class="props___oL_Cw"></div><div class="props___oL_Cw"></div>`;
}
const bottom = queryFirst(wrapper, ["[class*='bottom___']"]);
if (bottom) {
const ammoInner = slot === 3 ? INFINITY_SVG : slot === 5 ? `<span class="markerText___HdlDL standard___bW8M5">0</span>` : `<span class="markerText___HdlDL">—</span>`;
bottom.innerHTML = `<div class="props___oL_Cw"><i class="bonus-attachment-item-damage-bonus" aria-label="Damage"></i><span class="bonusInfo___vyqlT">—</span></div><div class="bottomMarker___G1uDs">${ammoInner}</div><div class="props___oL_Cw"><i class="bonus-attachment-item-accuracy-bonus" aria-label="Accuracy"></i><span class="bonusInfo___vyqlT">—</span></div>`;
}
const xp = wrapper.querySelector(".tt-weapon-experience");
if (xp) xp.textContent = "";
wrapper.setAttribute("aria-label", slotLabel || "Empty");
}
function renderArmor(defenderArea, loadout) {
const modelLayers = queryFirst(defenderArea, ["[class*='modelLayers']"]);
const parent = modelLayers || defenderArea;
if (!parent) return;
let overlay = parent.querySelector(".loadout-armor-overlay");
if (overlay) overlay.remove();
overlay = W.document.createElement("div");
overlay.className = "loadout-armor-overlay";
// Visual-only overlay; never block Torn native usemap hover.
overlay.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:4;transform:translateY(20px);";
for (const slot of [8, 7, 9, 6, 4]) {
const item = loadout?.[slot];
if (!item) continue;
const container = W.document.createElement("div");
container.className = "armourContainer___zL52C";
container.style.zIndex = String(ARMOR_Z_INDEX[slot] ?? 14);
const armor = W.document.createElement("div");
armor.className = "armour___fLnYY";
const img = W.document.createElement("img");
img.className = "itemImg___B8FMH";
img.src = `${TORN_BASE}/images/v2/items/model-items/${item.item_id}m.png`;
img.alt = item.item_name || "";
img.title = "";
img.style.pointerEvents = "none";
armor.appendChild(img);
container.appendChild(armor);
overlay.appendChild(container);
}
parent.appendChild(overlay);
}
function 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 {}
} 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);
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 fetchLoadoutFromFirebase(targetId) {
const data = await firebaseGet(`loadouts/by_target/${targetId}`);
if (!data || typeof data !== "object") return null;
const loadout = parsePayloadToLoadout(data);
if (!loadout) return null;
normalizeLoadout(loadout);
return validateLoadout(loadout) ? loadout : null;
}
function renderLoadout(loadout) {
if (!loadout) return;
const DOM_POLL_MS = 100;
const timer = W.setInterval(() => {
const defenderArea = getDefenderArea();
if (!defenderArea) return;
W.clearInterval(timer);
const defenderPrimaryPresent = !!queryFirstInDocs(["#defender_Primary"]);
const attackerPrimaryPresent = !!queryFirstInDocs(["#attacker_Primary"]);
const attackerSecondaryPresent = !!queryFirstInDocs(["#attacker_Secondary"]);
const attackerMeleePresent = !!queryFirstInDocs(["#attacker_Melee"]);
const attackerTempPresent = !!queryFirstInDocs(["#attacker_Temporary"]);
const genericWeaponMainPresent = !!queryFirstInDocs(["#weapon_main"]);
const hasDefender = defenderPrimaryPresent;
const hasAttacker = attackerPrimaryPresent || genericWeaponMainPresent;
// On PDA we don't need weapon slot labels (Primary/Secondary/etc).
// Torn's icons (mods/bonuses) already indicate the slots.
const includeLabel = false;
const defenderReplacesAttacker = hasAttacker && !hasDefender;
const slotMappings = [
{ selector: hasDefender ? "#defender_Primary" : attackerPrimaryPresent ? "#attacker_Primary" : "#weapon_main", slot: 1, label: "Primary" },
{ selector: hasDefender ? "#defender_Secondary" : attackerSecondaryPresent ? "#attacker_Secondary" : "#weapon_second", slot: 2, label: "Secondary" },
{ selector: hasDefender ? "#defender_Melee" : attackerMeleePresent ? "#attacker_Melee" : "#weapon_melee", slot: 3, label: "Melee" },
{ selector: hasDefender ? "#defender_Temporary" : attackerTempPresent ? "#attacker_Temporary" : "#weapon_temp", slot: 5, label: "Temporary" },
];
const doApplyWeaponsAndArmor = () => {
for (const { selector, slot, label } of slotMappings) {
const marker = queryFirstInDocs([selector]);
const wrapper = marker?.closest("[class*='weaponWrapper'], [class*='weaponSlot'], [class*='weapon']");
if (!wrapper) continue;
if (loadout?.[slot]) {
renderSlot(wrapper, loadout[slot], label, includeLabel, slot);
} else if (defenderReplacesAttacker) {
clearSlot(wrapper, label, includeLabel, slot);
}
}
renderArmor(defenderArea, loadout);
};
doApplyWeaponsAndArmor();
if (defenderReplacesAttacker) {
const endAt = Date.now() + 9000;
const interval = W.setInterval(() => {
if (Date.now() > endAt) {
W.clearInterval(interval);
return;
}
doApplyWeaponsAndArmor();
}, 250);
}
}, DOM_POLL_MS);
}
const targetId = getUrlTargetId();
if (!targetId) {
return;
}
(async () => {
const loadout = await fetchLoadoutFromFirebase(targetId);
if (!loadout) {
return;
}
renderLoadout(loadout);
})();
})();