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.
// ==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);
})();