Level Progress Estimation

level progress logic with a modern status bar toggle.

2026-04-20 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Level Progress Estimation 
// @namespace    http://tampermonkey.net/
// @version      13.6
// @description  level progress logic with a modern status bar toggle.
// @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";
    
    let isVisible = localStorage.getItem(STORAGE_KEY) !== 'false';
    let lastKnownValue = localStorage.getItem(VAL_KEY) || "--";

    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; }
    `;
    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:");
            if (key) await GM_setValue("torn_api_key", key.trim());
        }
        return key ? key.trim() : null;
    }

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

    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 "100.00";

            const offset = Math.max(0, myRank - 400);
            const hofData = await fetchTorn(`https://api.torn.com/v2/torn/hof?limit=800&offset=${offset}&cat=level&key=${key}`);
            const players = hofData.hof || [];

            const currentLevelPos = players.filter(p => p.level === level).map(p => p.position);
            const prevLevelPos = players.filter(p => p.level === (level - 1)).map(p => p.position);

            if (currentLevelPos.length === 0) return level.toFixed(2);

            const topOfLevel = Math.min(...currentLevelPos);
            let bottomOfLevel = prevLevelPos.length > 0 ? Math.min(...prevLevelPos) : Math.max(...currentLevelPos);

            const range = bottomOfLevel - topOfLevel;
            const distFromTop = myRank - topOfLevel;

            let fraction = distFromTop / range;
            if (isNaN(fraction) || fraction <= 0) fraction = 0.01;
            if (fraction >= 1) fraction = 0.99;

            return (level + fraction).toFixed(2);
        } catch (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 injectUI(val) {
        if (val && val !== "--") {
            lastKnownValue = val;
            localStorage.setItem(VAL_KEY, val);
        }

        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.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.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; z-index: 999;";
                newPill.onclick = () => window.location.href = "/halloffame.php#/type=level";
                newPill.innerHTML = `<span style="color: #85b200; font-size: 10px; font-weight: bold; margin-right: 4px; font-family: sans-serif; pointer-events: none;">LV</span>
                                     <span id="acc-lvl-val" style="color: #fff; font-size: 11px; font-family: 'Courier New', monospace; font-weight: bold; pointer-events: none;">${lastKnownValue}</span>`;
                tray.prepend(newPill);
            }
        } else {
            const valSpan = document.getElementById('acc-lvl-val');
            if (valSpan && lastKnownValue !== "--") {
                valSpan.textContent = lastKnownValue;
            }
        }
        syncUI();
    }

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

    refresh();
    setInterval(refresh, 600000);
    setInterval(() => injectUI(), 2000);

    const observer = new MutationObserver(() => injectUI());
    const sidebar = document.querySelector('#sidebarroot');
    if (sidebar) {
        observer.observe(sidebar, { subtree: true, characterData: true, childList: true });
    }
})();