Hover over Battle Stats Predictor's colored stat boxes to see the full spy breakdown (STR/DEF/SPD/DEX/Total + spy age), read straight from BSP's TornStats/YATA cache. No API key needed.
// ==UserScript==
// @name BSP Spy Tooltip
// @namespace bsp.spy.tooltip
// @version 1.0.0
// @description Hover over Battle Stats Predictor's colored stat boxes to see the full spy breakdown (STR/DEF/SPD/DEX/Total + spy age), read straight from BSP's TornStats/YATA cache. No API key needed.
// @author Louis
// @match https://www.torn.com/*
// @run-at document-end
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ---- BSP localStorage cache keys (must match BSP's StorageKey values) ----
const KEY_TORNSTATS = 'tdup.battleStatsPredictor.cache.spy_v2.tornstats_';
const KEY_YATA = 'tdup.battleStatsPredictor.cache.spy_v2.yata_';
const KEY_PREDICT = 'tdup.battleStatsPredictor.cache.prediction.';
// ---- Tooltip element (one shared instance) ----
const tip = document.createElement('div');
tip.id = 'bsp-spy-tooltip';
tip.style.cssText = [
'position:fixed',
'z-index:2147483647',
'display:none',
'background:#fff',
'color:#222',
'border:1px solid #bbb',
'border-radius:6px',
'box-shadow:0 3px 12px rgba(0,0,0,0.35)',
'padding:8px 12px',
'font:12px/1.5 Verdana, Arial, sans-serif',
'pointer-events:none',
'white-space:nowrap'
].join(';');
document.body.appendChild(tip);
function readJson(key) {
try {
const raw = localStorage.getItem(key);
if (!raw || raw === '[object Object]') return null;
return JSON.parse(raw);
} catch (e) {
return null;
}
}
// Returns the most recent spy between TornStats and YATA caches
function getSpy(playerId) {
const ts = readJson(KEY_TORNSTATS + playerId);
const yata = readJson(KEY_YATA + playerId);
if (ts && yata) {
return (ts.timestamp >= yata.timestamp)
? { ...ts, source: 'TornStats' }
: { ...yata, source: 'YATA' };
}
if (ts) return { ...ts, source: 'TornStats' };
if (yata) return { ...yata, source: 'YATA' };
return null;
}
function fmt(n) {
if (n === undefined || n === null) return '?';
if (n === 0) return 'Unknown';
return Number(n).toLocaleString('en-US');
}
function ageString(unixSeconds) {
if (!unixSeconds) return 'unknown';
const days = Math.floor((Date.now() / 1000 - unixSeconds) / 86400);
if (days <= 0) return 'today';
if (days === 1) return '1 day ago';
if (days < 60) return days + ' days ago';
const months = Math.floor(days / 30);
return months + ' months ago';
}
function row(label, value, bold) {
const w = bold ? 'font-weight:bold;' : '';
return '<div style="display:flex;justify-content:space-between;gap:18px;' + w + '">' +
'<span>' + label + '</span><span>' + value + '</span></div>';
}
function buildTooltipHTML(playerId) {
const spy = getSpy(playerId);
if (spy) {
const total = spy.total && spy.total > 0
? spy.total
: (spy.str + spy.def + spy.spd + spy.dex);
return '<div style="font-weight:bold;text-decoration:underline;margin-bottom:4px;">Battle Stats</div>' +
row('STR:', fmt(spy.str)) +
row('DEF:', fmt(spy.def)) +
row('SPD:', fmt(spy.spd)) +
row('DEX:', fmt(spy.dex)) +
row('TOTAL:', fmt(total), true) +
'<div style="margin-top:4px;border-top:1px solid #ddd;padding-top:3px;">' +
row('Last Spy:', ageString(spy.timestamp)) +
row('Source:', spy.source) +
'</div>';
}
// Fallback: no spy in cache -> show BSP prediction info if available
const pred = readJson(KEY_PREDICT + playerId);
if (pred && pred.TBS !== undefined) {
return '<div style="font-weight:bold;text-decoration:underline;margin-bottom:4px;">No spy in cache</div>' +
row('Predicted TBS:', fmt(Math.round(pred.TBS)), true) +
'<div style="margin-top:4px;color:#777;">BSP model estimate only</div>';
}
return '<div style="color:#777;">No spy or prediction cached for this player</div>';
}
function findPlayerId(statBox) {
// BSP wraps its stat box in an <a href="...user2ID=XXXX">
const a = statBox.closest('a[href*="user2ID="]');
if (!a) return null;
const m = a.href.match(/user2ID=(\d+)/);
return m ? m[1] : null;
}
function positionTip(evt) {
const pad = 14;
let x = evt.clientX + pad;
let y = evt.clientY + pad;
const r = tip.getBoundingClientRect();
if (x + r.width > window.innerWidth - 4) x = evt.clientX - r.width - pad;
if (y + r.height > window.innerHeight - 4) y = evt.clientY - r.height - pad;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
}
// Event delegation: works even for rows BSP injects later
document.addEventListener('mouseover', function (evt) {
const box = evt.target.closest('.iconStats, .iconStatsAttack');
if (!box) return;
const playerId = findPlayerId(box);
if (!playerId) return;
tip.innerHTML = buildTooltipHTML(playerId);
tip.style.display = 'block';
positionTip(evt);
});
document.addEventListener('mousemove', function (evt) {
if (tip.style.display === 'block') positionTip(evt);
});
document.addEventListener('mouseout', function (evt) {
if (evt.target.closest && evt.target.closest('.iconStats, .iconStatsAttack')) {
tip.style.display = 'none';
}
});
})();