RW Cache Value

Adds estimated total cache value to Ranked News entries and Ranked War notification panels. Uses the highest of TornExchange market value / best-trader price per cache.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         RW Cache Value
// @namespace    torn-rw-cache-value
// @version      1.0
// @author       Tibit
// @description  Adds estimated total cache value to Ranked News entries and Ranked War notification panels. Uses the highest of TornExchange market value / best-trader price per cache.
// @match        https://www.torn.com/factions.php*
// @grant        GM_xmlhttpRequest
// @connect      tornexchange.com
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const CACHE_IDS = {
    "Heavy Arms Cache": 1122,
    "Medium Arms Cache": 1121,
    "Small Arms Cache": 1120,
    "Melee Cache": 1119,
    "Armor Cache": 1118,
  };

  const priceCache = new Map(); // itemId -> Promise<number|null>

  function fetchSellPrice(itemId) {
    if (priceCache.has(itemId)) return priceCache.get(itemId);
    const p = new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: `https://tornexchange.com/api/best_listing?item_id=${itemId}`,
        onload: (r) => {
          let highest = null;
          try {
            const d = JSON.parse(r.responseText);
            if (d?.status === "success" && d.data) {
              const candidates = [d.data.price, d.data.te_price].filter(
                (p) => typeof p === "number" && p > 0,
              );
              if (candidates.length) highest = Math.max(...candidates);
            }
          } catch {}
          resolve(highest);
        },
        onerror: () => resolve(null),
      });
    });
    priceCache.set(itemId, p);
    return p;
  }

  // Match patterns like "1x Heavy Arms Cache" or "11x Armor Cache"
  // Order matters: longer names first so "Small Arms Cache" wins over a hypothetical "Arms Cache"
  function extractCaches(text) {
    const found = [];
    const sortedNames = Object.keys(CACHE_IDS).sort(
      (a, b) => b.length - a.length,
    );
    for (const name of sortedNames) {
      const pattern = name.replace(/ /g, "\\s+");
      const re = new RegExp(`(\\d+)\\s*x\\s+${pattern}`, "gi");
      let m;
      while ((m = re.exec(text)) !== null) {
        found.push({ name, id: CACHE_IDS[name], qty: parseInt(m[1]) });
      }
    }
    return found;
  }

  const STYLE_ID = "oc-cache-value-style";
  function injectStyle() {
    if (document.getElementById(STYLE_ID)) return;
    const s = document.createElement("style");
    s.id = STYLE_ID;
    s.textContent = `
      .oc-cache-value-badge {
        position:relative;
        display:inline-flex;
        align-items:baseline;
        gap:6px;
        margin:4px 0 6px 8px;
        padding:3px 11px;
        background:rgba(103, 140, 0, 0.14);
        border:1px solid rgba(103, 140, 0, 0.55);
        border-radius:11px;
        font:11px/1.6 Arial, sans-serif;
        color:#2e3f00;
        cursor:pointer;
        vertical-align:middle;
        white-space:nowrap;
        letter-spacing:0.1px;
        user-select:none;
      }
      .oc-cache-value-badge.pinned {
        background:rgba(103, 140, 0, 0.22);
        border-color:rgba(103, 140, 0, 0.75);
        box-shadow:0 0 0 2px rgba(103, 140, 0, 0.18);
      }
      .oc-cache-value-badge.on-dark.pinned {
        background:rgba(140, 180, 50, 0.24);
        border-color:rgba(140, 180, 50, 0.75);
        box-shadow:0 0 0 2px rgba(140, 180, 50, 0.18);
      }
      .oc-cache-value-badge.loading {
        background:rgba(0,0,0,0.05);
        border-color:rgba(0,0,0,0.2);
        color:#666;
        font-style:italic;
      }
      .oc-cache-value-badge .oc-cv-label {
        color:#4a4a4a;
        font-weight:700;
        text-transform:uppercase;
        font-size:9px;
        letter-spacing:0.7px;
      }
      .oc-cache-value-badge .oc-cv-amount {
        color:#2e3f00;
        font-weight:800;
        font-size:13px;
      }
      .oc-cache-value-badge .oc-cv-warn {
        color:#8a3a00;
        font-weight:700;
        font-size:10px;
        padding-left:6px;
        border-left:1px solid rgba(0,0,0,0.2);
      }

      /* Dark-bg variant - for the ranked war notification panel */
      .oc-cache-value-badge.on-dark {
        background:rgba(140, 180, 50, 0.16);
        border-color:rgba(140, 180, 50, 0.55);
        color:#c8e886;
      }
      .oc-cache-value-badge.on-dark .oc-cv-label { color:#a8b497; }
      .oc-cache-value-badge.on-dark .oc-cv-amount { color:#d4f08a; }
      .oc-cache-value-badge.on-dark .oc-cv-warn {
        color:#e0a060;
        border-left-color:rgba(255,255,255,0.2);
      }
      .oc-cache-value-badge.on-dark.loading {
        background:rgba(255,255,255,0.06);
        border-color:rgba(255,255,255,0.18);
        color:#aaa;
      }

      /* Torn dark-mode (body.dark-mode) - apply lighter olive treatment to news-list badges */
      body.dark-mode .oc-cache-value-badge {
        background:rgba(140, 180, 50, 0.16);
        border-color:rgba(140, 180, 50, 0.55);
        color:#c8e886;
      }
      body.dark-mode .oc-cache-value-badge .oc-cv-label { color:#a8b497; }
      body.dark-mode .oc-cache-value-badge .oc-cv-amount { color:#d4f08a; }
      body.dark-mode .oc-cache-value-badge .oc-cv-warn {
        color:#e0a060;
        border-left-color:rgba(255,255,255,0.2);
      }
      body.dark-mode .oc-cache-value-badge.loading {
        background:rgba(255,255,255,0.06);
        border-color:rgba(255,255,255,0.18);
        color:#aaa;
      }
      body.dark-mode .oc-cache-value-badge.pinned {
        background:rgba(140, 180, 50, 0.24);
        border-color:rgba(140, 180, 50, 0.75);
        box-shadow:0 0 0 2px rgba(140, 180, 50, 0.18);
      }

      /* Custom tooltip */
      .oc-cv-tooltip {
        position:absolute;
        bottom:calc(100% + 8px);
        left:0;
        z-index:9999;
        display:none;
        background:#2a2a2a;
        color:#eaeaea;
        border:1px solid #444;
        border-radius:6px;
        padding:8px 10px;
        font:11px/1.5 Arial, sans-serif;
        letter-spacing:normal;
        text-transform:none;
        white-space:nowrap;
        box-shadow:0 4px 14px rgba(0,0,0,0.4);
        cursor:default;
        pointer-events:none;
      }
      .oc-cache-value-badge:hover .oc-cv-tooltip,
      .oc-cache-value-badge.pinned .oc-cv-tooltip {
        display:block;
      }
      .oc-cache-value-badge.pinned .oc-cv-tooltip {
        pointer-events:auto;
      }
      .oc-cv-tooltip::after {
        content:"";
        position:absolute;
        top:100%;
        left:22px;
        border:6px solid transparent;
        border-top-color:#2a2a2a;
      }
      .oc-cv-tooltip::before {
        content:"";
        position:absolute;
        top:100%;
        left:21px;
        border:7px solid transparent;
        border-top-color:#444;
        margin-top:1px;
        z-index:-1;
      }
      .oc-cv-tooltip table {
        border-collapse:collapse;
      }
      .oc-cv-tooltip td {
        padding:2px 0;
        vertical-align:baseline;
        font-variant-numeric:tabular-nums;
      }
      .oc-cv-tooltip td.oc-cv-q {
        color:#9bd060;
        text-align:right;
        padding-right:10px;
        font-weight:bold;
      }
      .oc-cv-tooltip td.oc-cv-n {
        color:#eaeaea;
        padding-right:18px;
      }
      .oc-cv-tooltip td.oc-cv-u {
        color:#999;
        text-align:right;
        padding-right:14px;
      }
      .oc-cv-tooltip td.oc-cv-s {
        color:#cfe89a;
        text-align:right;
        font-weight:bold;
      }
      .oc-cv-tooltip td.oc-cv-na {
        color:#c77;
        font-style:italic;
        text-align:right;
      }
      .oc-cv-tooltip tr.oc-cv-total td {
        border-top:1px solid #4a4a4a;
        padding-top:6px;
        padding-bottom:0;
        font-weight:bold;
      }
      .oc-cv-tooltip tr.oc-cv-total td.oc-cv-total-label {
        color:#aaa;
        text-transform:uppercase;
        font-size:10px;
        letter-spacing:0.6px;
      }
      .oc-cv-tooltip tr.oc-cv-total td.oc-cv-total-amount {
        color:#d4f08a;
        font-size:12px;
      }
      .oc-cv-tooltip tr[data-item-id] {
        cursor:pointer;
      }
      .oc-cv-tooltip tr[data-item-id]:hover td {
        background:rgba(255,255,255,0.06);
      }
      .oc-cv-tooltip tr[data-item-id]:hover td.oc-cv-n {
        color:#9bd060;
      }
    `;
    document.head.appendChild(s);
  }

  function makeBadge(onDark) {
    injectStyle();
    const badge = document.createElement("div");
    badge.className =
      "oc-cache-value-badge loading" + (onDark ? " on-dark" : "");
    return badge;
  }

  async function injectCacheBadge(target, caches, opts = {}) {
    if (!caches.length) return;
    if (target.dataset.cacheValueProcessed) return;
    target.dataset.cacheValueProcessed = "true";

    const badge = makeBadge(opts.onDark);
    badge.textContent = "Calculating cache value…";
    target.appendChild(badge);

    const results = await Promise.all(
      caches.map(async (c) => ({ ...c, price: await fetchSellPrice(c.id) })),
    );

    results.sort((a, b) => {
      const subA = a.price != null ? a.price * a.qty : -1;
      const subB = b.price != null ? b.price * b.qty : -1;
      return subB - subA;
    });

    let total = 0;
    let unknown = 0;
    const rows = [];
    for (const c of results) {
      const qtyCell = `<td class="oc-cv-q">${c.qty}×</td>`;
      const nameCell = `<td class="oc-cv-n">${c.name}</td>`;
      const trOpen = `<tr data-item-id="${c.id}" title="Open ${c.name} on Item Market">`;
      if (c.price == null) {
        unknown++;
        rows.push(
          `${trOpen}${qtyCell}${nameCell}<td class="oc-cv-na" colspan="2">price unavailable</td></tr>`,
        );
        continue;
      }
      const sub = c.price * c.qty;
      total += sub;
      rows.push(
        `${trOpen}${qtyCell}${nameCell}` +
          `<td class="oc-cv-u">@ $${Math.round(c.price).toLocaleString()}</td>` +
          `<td class="oc-cv-s">$${Math.round(sub).toLocaleString()}</td></tr>`,
      );
    }

    const totalAmount = `$${Math.round(total).toLocaleString()}`;
    const tooltip =
      `<div class="oc-cv-tooltip"><table>${rows.join("")}` +
      `<tr class="oc-cv-total">` +
      `<td class="oc-cv-total-label" colspan="3">Total</td>` +
      `<td class="oc-cv-total-amount">${totalAmount}</td>` +
      `</tr></table></div>`;

    badge.classList.remove("loading");
    const warn = unknown
      ? `<span class="oc-cv-warn">${unknown} unknown</span>`
      : "";
    badge.innerHTML =
      `<span class="oc-cv-label">Cache value</span>` +
      `<span class="oc-cv-amount">${totalAmount}</span>${warn}` +
      tooltip;

    badge.addEventListener("click", (e) => {
      const row = e.target.closest("tr[data-item-id]");
      if (row) {
        e.stopPropagation();
        const id = row.dataset.itemId;
        window.open(
          `https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${id}`,
          "_blank",
          "noopener",
        );
        return;
      }
      e.stopPropagation();
      document
        .querySelectorAll(".oc-cache-value-badge.pinned")
        .forEach((b) => b !== badge && b.classList.remove("pinned"));
      badge.classList.toggle("pinned");
    });
  }

  let outsideClickInstalled = false;
  function installOutsideClick() {
    if (outsideClickInstalled) return;
    outsideClickInstalled = true;
    document.addEventListener("click", (e) => {
      if (e.target.closest(".oc-cache-value-badge")) return;
      document
        .querySelectorAll(".oc-cache-value-badge.pinned")
        .forEach((b) => b.classList.remove("pinned"));
    });
  }

  function processEntry(entry) {
    const caches = extractCaches(entry.textContent);
    injectCacheBadge(entry, caches);
  }

  // Detect cache items by image - works for "RANKED WAR VICTORY" / "LOSS" notification panels
  function extractCachesFromImages(panel) {
    const found = [];
    const idToName = Object.fromEntries(
      Object.entries(CACHE_IDS).map(([n, id]) => [id, n]),
    );
    const imgs = panel.querySelectorAll('img[src*="/images/items/"]');
    for (const img of imgs) {
      const m = img.getAttribute("src")?.match(/\/items\/(\d+)\//);
      if (!m) continue;
      const id = parseInt(m[1]);
      const name = idToName[id];
      if (!name) continue;
      const tile = img.parentElement?.parentElement;
      const qtyMatch = tile?.textContent?.match(/\d+/);
      const qty = qtyMatch ? parseInt(qtyMatch[0]) : 0;
      if (qty > 0) found.push({ id, name, qty });
    }
    return found;
  }

  function processNotification(panel) {
    const caches = extractCachesFromImages(panel);
    injectCacheBadge(panel, caches, { onDark: true });
  }

  function scan() {
    document
      .querySelectorAll('li[class*="listItemWrapper___"]')
      .forEach(processEntry);
    document
      .querySelectorAll('[class*="notification___"]')
      .forEach(processNotification);
  }

  const observer = new MutationObserver(() => {
    clearTimeout(observer._t);
    observer._t = setTimeout(scan, 250);
  });
  observer.observe(document.body, { childList: true, subtree: true });

  installOutsideClick();
  setTimeout(scan, 500);
})();