Level Progress Estimation

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

Verze ze dne 24. 05. 2026. Zobrazit nejnovější verzi.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

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

    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;
    }
    #acc-lvl-pill:hover { background: #3a3a3a; }
.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 handleToggle(e) {
        e.stopPropagation();
        e.preventDefault();
        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(e) {
        e.stopPropagation();
        e.preventDefault();
        showRate =!showRate;
        updatePillDisplay();
    }

    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 data";
                showRate = false;
            }
        } else {
            valSpan.textContent = (currentData.confident? "" : "~") + currentData.value;
        }
    }

    function injectUI(data) {
        if (data) currentData = data;
        const oldLevelInt = parseInt(localStorage.getItem(OLD_VAL_KEY) || "0");
        let tooltip = "Click 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)}\nClick to toggle`;
            } else {
                tooltip = `Level ${data.value} | Range: ${data.range} players\nClick 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 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 = handleToggle;
            li.appendChild(a);
            statusUl.appendChild(li);
        }

        const header = document.querySelector('#header-root') || document.querySelector('.header-wrapper');
        const tray = header?.querySelector('[class*="right_"]') || header?.querySelector('[class*="header-buttons"]');
        const pill = document.getElementById('acc-lvl-pill');

        if (tray &&!pill) {
            const newPill = document.createElement('div');
            newPill.id = 'acc-lvl-pill';
            newPill.title = tooltip;
            newPill.onclick = handlePillClick;
            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 if (pill) {
            const deltaSpan = document.getElementById('acc-lvl-delta');
            if (deltaSpan) deltaSpan.innerHTML = deltaIndicator;
            pill.title = tooltip;
            updatePillDisplay();
        }
        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 });
})();