BSP Spy Tooltip

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.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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';
        }
    });
})();