Level Progress Estimation

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

Per 24-05-2026. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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 });
})();