Level Progress Estimation

Accurate level progress via HoF rank interpolation + XP/hour tracking

2026/05/24のページです。最新版はこちら

スクリプトをインストールするには、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 Level Progress Estimation
// @namespace http://tampermonkey.net/
// @version 14.3
// @description Accurate level progress via HoF rank interpolation + XP/hour tracking
// @author Pint-Shot-Riot
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @license MIT
// ==/UserScript==

(function () {
    "use strict";

    const STORAGE_KEY = "pda_level_visible";
    const VAL_KEY = "pda_last_lvl_val";
    const TIME_KEY = "pda_last_lvl_time";
    const OLD_VAL_KEY = "pda_prev_lvl_int";
    const HISTORY_KEY = "pda_lvl_history";

    let isVisible = localStorage.getItem(STORAGE_KEY)!== 'false';
    let lastKnownValue = localStorage.getItem(VAL_KEY) || "--";
    let lastUpdate = parseInt(localStorage.getItem(TIME_KEY) || "0");
    let history = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
    let clickState = 0;

    const styleSheet = document.createElement("style");
    styleSheet.innerText = `
   .pda-lvl-status-icon {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 24px;
            height: 24px;
            cursor: pointer;
            font-size: 16px;
            opacity: 0.75;
            transition: opacity 0.15s, transform 0.15s;
            filter: grayscale(1);
        }
   .pda-lvl-status-icon:hover { opacity: 1; transform: scale(1.15); }
   .pda-lvl-active { filter: grayscale(0)!important; opacity: 1!important; }
        #acc-lvl-pill {
            transition: box-shadow 0.3s, background 0.3s;
            position: relative;
            user-select: none;
        }
   .lvl-up-flash {
            background: #2d4a1d!important;
            box-shadow: 0 0 8px #85b200!important;
        }
   .rank-up { color: #85b200!important; }
   .rank-down { color: #ff4444!important; }
    `;
    document.head.appendChild(styleSheet);

    async function getApiKey() {
        let key = localStorage.getItem("APIKey") || await GM_getValue("torn_api_key", "");
        if (!key || key.length < 10) {
            key = prompt("Please enter your Torn API Key with 'hof' access:");
            if (key) {
                key = key.trim();
                localStorage.setItem("APIKey", key);
                await GM_setValue("torn_api_key", key);
            }
        }
        return key? key.trim() : null;
    }

    async function fetchTorn(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                timeout: 10000,
                onload: (res) => {
                    try {
                        const data = JSON.parse(res.responseText);
                        if (data.error) reject(data.error.error);
                        else resolve(data);
                    } catch (e) { reject("JSON Parse Error"); }
                },
                onerror: () => reject("Network Error"),
                ontimeout: () => reject("Timeout")
            });
        });
    }

    function updateHistory(level, rank, fraction) {
        const now = Date.now();
        history.push({ level, rank, fraction, time: now });
        history = history.filter(h => now - h.time < 86400000);
        localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
    }

    function calculateXPRate(currentLevel, currentFraction) {
        if (history.length < 2) return null;
        const sameLevel = history.filter(h => h.level === currentLevel);
        if (sameLevel.length < 2) return null;
        const oldest = sameLevel[0];
        const newest = sameLevel[sameLevel.length - 1];
        const timeHours = (newest.time - oldest.time) / 3600000;
        if (timeHours < 0.5) return null;
        const fractionGain = newest.fraction - oldest.fraction;
        const ratePerHour = fractionGain / timeHours;
        if (ratePerHour <= 0) return { rate: 0, eta: null, trend: "down" };
        const remainingFraction = 1 - currentFraction;
        const hoursToLevel = remainingFraction / ratePerHour;
        return {
            rate: ratePerHour,
            eta: hoursToLevel,
            trend: ratePerHour > 0? "up" : "down"
        };
    }

    function formatETA(hours) {
        if (!hours || hours <= 0) return "∞";
        if (hours < 1) return `${Math.round(hours * 60)}m`;
        if (hours < 24) return `${hours.toFixed(1)}h`;
        const days = Math.floor(hours / 24);
        const hrs = Math.round(hours % 24);
        return `${days}d ${hrs}h`;
    }

    async function getAccurateLevel() {
        const key = await getApiKey();
        if (!key) return null;
        try {
            const userHof = await fetchTorn(`https://api.torn.com/v2/user/hof?key=${key}`);
            const level = userHof.hof.level.value;
            const myRank = userHof.hof.level.rank;
            if (level >= 100) return { value: "100.00", confident: true, level, rank: myRank, fraction: 1 };
            const offset = Math.max(1, myRank - 500);
            const hofData = await fetchTorn(`https://api.torn.com/v2/torn/hof?limit=1000&offset=${offset}&cat=level&key=${key}`);
            const players = hofData.hof || [];
            const currentLevelPlayers = players.filter(p => p.level === level);
            if (currentLevelPlayers.length === 0) return { value: level.toFixed(2), confident: false, level, rank: myRank, fraction: 0 };
            const positions = currentLevelPlayers.map(p => p.position);
            const topOfLevel = Math.min(...positions);
            let bottomOfLevel = Math.max(...positions);
            const nextLevelPlayers = players.filter(p => p.level === level + 1);
            if (nextLevelPlayers.length > 0) {
                bottomOfLevel = Math.min(...nextLevelPlayers.map(p => p.position)) - 1;
            }
            const range = bottomOfLevel - topOfLevel + 1;
            if (range <= 1) return { value: level.toFixed(2), confident: false, level, rank: myRank, fraction: 0 };
            const distFromTop = myRank - topOfLevel;
            let fraction = distFromTop / range;
            fraction = Math.pow(fraction, 1.25);
            if (fraction <= 0) fraction = 0.01;
            if (fraction >= 1) fraction = 0.99;
            updateHistory(level, myRank, fraction);
            const confident = range >= 50;
            const value = (level + fraction).toFixed(2);
            return { value, confident, level, rank: myRank, fraction, range };
        } catch (e) {
            console.error("[PDA Level] Calc error:", e);
            return null;
        }
    }

    function syncUI() {
        const pill = document.getElementById('acc-lvl-pill');
        const icon = document.getElementById('pda-lvl-status-btn');
        if (pill) pill.style.display = isVisible? 'inline-flex' : 'none';
        if (icon) isVisible? icon.classList.add('pda-lvl-active') : icon.classList.remove('pda-lvl-active');
    }

    function handleToggle() {
        isVisible =!isVisible;
        localStorage.setItem(STORAGE_KEY, isVisible);
        syncUI();
    }

    function flashLevelUp() {
        const pill = document.getElementById('acc-lvl-pill');
        if (!pill) return;
        pill.classList.add('lvl-up-flash');
        setTimeout(() => pill.classList.remove('lvl-up-flash'), 3000);
    }

    function handlePillClick(data) {
        clickState = (clickState + 1) % 3;
        const valSpan = document.getElementById('acc-lvl-val');
        if (!valSpan ||!data) return;

        if (clickState === 0) {
            valSpan.textContent = (data.confident? "" : "~") + data.value;
        } else if (clickState === 1) {
            const xpRate = calculateXPRate(data.level, data.fraction);
            if (xpRate && xpRate.rate > 0) {
                valSpan.textContent = `+${(xpRate.rate * 100).toFixed(2)}%/h`;
            } else {
                valSpan.textContent = "No rate data";
                clickState = 0;
            }
        } else if (clickState === 2) {
            window.location.href = "/halloffame.php#/type=level";
            clickState = 0;
        }
    }

    function injectUI(data) {
        const oldLevelInt = parseInt(localStorage.getItem(OLD_VAL_KEY) || "0");
        let tooltip = "Click: show rate | Click again: HoF";
        let deltaIndicator = "";

        if (data && data.value!== "--") {
            const newLevelInt = Math.floor(parseFloat(data.value));
            if (oldLevelInt && newLevelInt > oldLevelInt) flashLevelUp();

            if (history.length >= 2) {
                const prev = history[history.length - 2];
                if (prev.level === data.level) {
                    const rankDelta = prev.rank - data.rank;
                    if (rankDelta > 0) deltaIndicator = ` <span class="rank-up">↑${rankDelta}</span>`;
                    else if (rankDelta < 0) deltaIndicator = ` <span class="rank-down">↓${Math.abs(rankDelta)}</span>`;
                }
            }

            const xpRate = calculateXPRate(data.level, data.fraction);
            if (xpRate && xpRate.rate > 0) {
                tooltip = `Level ${data.value} | +${(xpRate.rate * 100).toFixed(2)}%/hr | ETA ${formatETA(xpRate.eta)}\nClick to cycle: Level → Rate → HoF`;
            } else {
                tooltip = `Level ${data.value} | Range: ${data.range} players\nClick to cycle: Level → Rate → HoF`;
            }

            lastKnownValue = (data.confident? "" : "~") + data.value;
            localStorage.setItem(VAL_KEY, lastKnownValue);
            localStorage.setItem(OLD_VAL_KEY, newLevelInt.toString());
            localStorage.setItem(TIME_KEY, Date.now().toString());
        }

        const statusUl = document.querySelector('ul[class*="status-icons"]');
        if (statusUl &&!document.getElementById('pda-lvl-status-btn')) {
            const li = document.createElement('li');
            li.style.display = "inline-flex";
            const a = document.createElement('a');
            a.id = 'pda-lvl-status-btn';
            a.className = 'pda-lvl-status-icon';
            a.title = 'Toggle Level Progress';
            a.innerHTML = '📈';
            a.onclick = (e) => { e.preventDefault(); handleToggle(); };
            li.appendChild(a);
            statusUl.appendChild(li);
        }

        const pill = document.getElementById('acc-lvl-pill');
        if (!pill) {
            const header = document.querySelector('#header-root') || document.querySelector('.header-wrapper');
            const tray = header?.querySelector('[class*="right_"]') || header?.querySelector('[class*="header-buttons"]');
            if (tray) {
                const newPill = document.createElement('div');
                newPill.id = 'acc-lvl-pill';
                newPill.title = tooltip;
                newPill.style = "display: inline-flex; align-items: center; background: #333; border: 1px solid #444; border-radius: 10px; padding: 2px 8px; margin: 0 4px; height: 22px; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.5); flex-shrink: 0;";
                newPill.onclick = (e) => { e.preventDefault(); handlePillClick(data); };
                newPill.innerHTML = `<span style="color: #85b200; font-size: 10px; font-weight: bold; margin-right: 4px; font-family: sans-serif;">LV</span>
                                     <span id="acc-lvl-val" style="color: #fff; font-size: 11px; font-family: 'Courier New', monospace; font-weight: bold;">${lastKnownValue}</span>
                                     <span id="acc-lvl-delta" style="font-size: 9px; margin-left: 3px;">${deltaIndicator}</span>`;
                tray.prepend(newPill);
            }
        } else {
            const valSpan = document.getElementById('acc-lvl-val');
            const deltaSpan = document.getElementById('acc-lvl-delta');
            if (valSpan && clickState === 0 && lastKnownValue!== "--") valSpan.textContent = lastKnownValue;
            if (deltaSpan) deltaSpan.innerHTML = deltaIndicator;
            pill.title = tooltip;
        }
        syncUI();
    }

    async function refresh() {
        const data = await getAccurateLevel();
        if (data) injectUI(data);
    }

    function scheduleNextRefresh() {
        const now = new Date();
        const nextHour = new Date(now);
        nextHour.setUTCHours(now.getUTCHours() + 1, 0, 10, 0);
        const msUntil = nextHour - now;
        setTimeout(async () => {
            await refresh();
            scheduleNextRefresh();
        }, msUntil);
    }

    injectUI(null);
    if (Date.now() - lastUpdate > 3600000) refresh();
    scheduleNextRefresh();
    const observer = new MutationObserver(() => injectUI(null));
    const target = document.querySelector('#header-root') || document.body;
    observer.observe(target, { subtree: true, childList: true });
})();