Level Progress Estimation

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

Versione datata 24/05/2026. Vedi la nuova versione l'ultima versione.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name Level Progress Estimation
// @namespace http://tampermonkey.net/
// @version 15.00
// @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 pillInjected = false;

    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() {
        // Nuke all existing pills first
        document.querySelectorAll('#acc-lvl-pill').forEach(el => el.remove());
        
        const header = document.querySelector('#header-root') || document.querySelector('.header-wrapper');
        const tray = header?.querySelector('[class*="right_"]') || header?.querySelector('[class*="header-buttons"]');
        if (!tray) return false;

        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);
        }
        return true;
    }

    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 DOM rebuilds
    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();
    }

    // Only watch for header being rebuilt, not every change
    const observer = new MutationObserver(() => {
        if (!document.getElementById('acc-lvl-pill')) {
            injectPill();
            updateUI(currentData);
        }
    });
    const target = document.querySelector('#header-root') || document.body;
    observer.observe(target, { childList: true, subtree: false });
})();