Level Progress Estimation

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

目前為 2026-05-24 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name Level Progress Estimation
// @namespace http://tampermonkey.net/
// @version 14.9
// @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 showRate = false;
    let currentData = null;
    let injectTimeout = null;

    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 {
        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;
        transition: background 0.2s;
        position: relative;
        -webkit-tap-highlight-color: transparent;
    }
    #acc-lvl-pill:active { background: #2a2a2a; }
.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 };
        const remainingFraction = 1 - currentFraction;
        const hoursToLevel = remainingFraction / ratePerHour;
        return {
            rate: ratePerHour,
            eta: hoursToLevel
        };
    }

    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 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 updatePillDisplay() {
        const valSpan = document.getElementById('acc-lvl-val');
        if (!valSpan ||!currentData) return;

        if (showRate) {
            const xpRate = calculateXPRate(currentData.level, currentData.fraction);
            if (xpRate && xpRate.rate > 0) {
                valSpan.textContent = `+${(xpRate.rate * 100).toFixed(2)}%/h`;
            } else {
                valSpan.textContent = "No rate yet";
            }
        } else {
            valSpan.textContent = (currentData.confident? "" : "~") + currentData.value;
        }
    }

    function updateUI(data) {
        if (data) currentData = data;
        const oldLevelInt = parseInt(localStorage.getItem(OLD_VAL_KEY) || "0");
        let tooltip = "Tap to toggle level/rate";
        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)}\nTap to toggle`;
            } else {
                tooltip = `Level ${data.value} | Range: ${data.range} players | Need 2+ hours for rate\nTap to toggle`;
            }

            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 deltaSpan = document.getElementById('acc-lvl-delta');
        const pill = document.getElementById('acc-lvl-pill');
        if (deltaSpan) deltaSpan.innerHTML = deltaIndicator;
        if (pill) pill.title = tooltip;
        updatePillDisplay();
        syncUI();
    }

    function injectPill() {
        // Remove any duplicates first
        document.querySelectorAll('#acc-lvl-pill').forEach((el, i) => {
            if (i > 0) el.remove();
        });

        if (document.getElementById('acc-lvl-pill')) return;

        const header = document.querySelector('#header-root') || document.querySelector('.header-wrapper');
        const tray = header?.querySelector('[class*="right_"]') || header?.querySelector('[class*="header-buttons"]');
        if (!tray) return;

        const newPill = document.createElement('div');
        newPill.id = 'acc-lvl-pill';
        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;"></span>`;
        tray.prepend(newPill);

        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 = '📈';
            li.appendChild(a);
            statusUl.appendChild(li);
        }
    }

    async function refresh() {
        const data = await getAccurateLevel();
        if (data) updateUI(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);
    }

    // Event delegation - survives redraws
    document.addEventListener('click', function(e) {
        const pill = e.target.closest('#acc-lvl-pill');
        if (pill) {
            e.preventDefault();
            e.stopImmediatePropagation();
            showRate =!showRate;
            updatePillDisplay();
            return false;
        }
        const icon = e.target.closest('#pda-lvl-status-btn');
        if (icon) {
            e.preventDefault();
            e.stopImmediatePropagation();
            isVisible =!isVisible;
            localStorage.setItem(STORAGE_KEY, isVisible);
            syncUI();
            return false;
        }
    }, true);

    function init() {
        injectPill();
        refresh();
        scheduleNextRefresh();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // Debounced observer - only inject if pill is actually missing
    const observer = new MutationObserver(() => {
        clearTimeout(injectTimeout);
        injectTimeout = setTimeout(() => {
            if (!document.getElementById('acc-lvl-pill')) {
                injectPill();
                updateUI(currentData);
            }
        }, 100);
    });
    const target = document.querySelector('#header-root') || document.body;
    observer.observe(target, { subtree: true, childList: true });
})();