Torn PDA Loadout Share

PDA-only: replaces defender weapons/armor on Torn attack pages using Firebase loadouts.

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.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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 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("<", "&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 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);
  })();
})();