Torn - Level Progress Shower

Show your level progress on sidebar.

// ==UserScript==
// @name         Torn - Level Progress Shower
// @namespace    http://tampermonkey.net/
// @version      1.01
// @description  Show your level progress on sidebar.
// @author       e7cf09 [3441977]
// @icon         https://editor.torn.com/33b77f1c-dcd9-4d96-867f-5b578631d137-3441977.png
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    let apiKey = GM_getValue('torn_api_key', '');
    let cachedData = GM_getValue('cached_level_data', null);
    let cacheTime = GM_getValue('cache_timestamp', 0);
    let lastCall = GM_getValue('last_api_call_time', 0);
    let callCount = GM_getValue('api_call_count', 0);
    let callTime = GM_getValue('api_call_timestamp', 0);
    let lastLevel = GM_getValue('last_seen_level', 0);

    const CACHE_DURATION = 24 * 60 * 60 * 1000;
    const INACTIVE_THRESHOLD = 365 * 24 * 60 * 60;
    const RATE_LIMIT = 60;
    const RATE_WINDOW = 60 * 1000;
    const MIN_INTERVAL = 1000; 

    let processing = false;
    let displayLevel = null;
    let status = '';

    function canCall() {
        const now = Date.now();

        if (now - callTime > RATE_WINDOW) {
            callCount = 0;
            callTime = now;
            GM_setValue('api_call_count', callCount);
            GM_setValue('api_call_timestamp', callTime);
        }

        if (callCount >= RATE_LIMIT) {
            return false;
        }

        if (now - lastCall < MIN_INTERVAL) {
            return false;
        }

        return true;
    }

    function recordCall() {
        const now = Date.now();
        callCount++;
        lastCall = now;

        GM_setValue('api_call_count', callCount);
        GM_setValue('last_api_call_time', lastCall);

        if (now - callTime > RATE_WINDOW) {
            callTime = now;
            GM_setValue('api_call_timestamp', callTime);
        }
    }

    function makeRequest(url) {
        return new Promise((resolve, reject) => {
            if (!canCall()) {
                reject(new Error('API rate limit exceeded. Please wait.'));
                return;
            }

            recordCall();

            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            if (data.error) {
                                reject(new Error(data.error.error || 'API Error'));
                            } else {
                                resolve(data);
                            }
                        } catch (e) {
                            reject(new Error('Invalid JSON response'));
                        }
                    } else {
                        reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
                    }
                },
                onerror: function(error) {
                    reject(new Error('Network error'));
                }
            });
        });
    }

    async function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function getUserLevel() {
        const url = `https://api.torn.com/v2/user/hof?key=${apiKey}`;
        const data = await makeRequest(url);
        return {
            level: data.hof.level.value,
            rank: data.hof.level.rank
        };
    }

    async function getHOF(limit = 100, offset = 0) {
        const url = `https://api.torn.com/v2/torn/hof?limit=${limit}&offset=${offset}&cat=level&key=${apiKey}`;
        const data = await makeRequest(url);
        return data.hof;
    }

    async function getUserHOF(userId) {
        const url = `https://api.torn.com/v2/user/${userId}/hof?key=${apiKey}`;
        const data = await makeRequest(url);
        return data.hof.level.rank;
    }

    async function findInactivePos(targetLevel) {
        const currentTime = Math.floor(Date.now() / 1000);
        
        const level1Pos = await getUserHOF(1364774);
        await sleep(MIN_INTERVAL);
        
        let left = 1;
        let right = level1Pos;
        let position = -1;
        
        while (left + 100 <= right) {
            const mid = Math.floor((left + right) / 2 - 50);
            
            try {
                const players = await getHOF(100, mid);
                await sleep(MIN_INTERVAL);
                
                let found = false;
                let higher = false;
                let lower = false;

                for (let i = 0; i < 100; i++) {
                    if (players[i].level === targetLevel) {
                        found = true;
                    }
                    if (players[i].level > targetLevel) {
                        higher = true;
                    } else if (players[i].level < targetLevel) {
                        lower = true;
                    }
                }
                
                if (higher && !found && !lower) {
                    left = mid + 100;
                } else if (higher && found && !lower) {
                    position = mid;
                    break;
                } else {
                    right = mid - 1;
                }
            } catch (error) {
                console.error('Error in binary search:', error);
                throw error;
            }
        }
        
        if (position === -1) {
            position = left;
        }
        
        let bestPos = -1;
        let bestId = null;
        let bestAction = null;
        let found = false;
        
        let currentOffset = position;
        while (1) {
            
            try {
                const players = await getHOF(100, currentOffset);
                await sleep(MIN_INTERVAL);
                
                for (let i = 0; i < players.length; i++) {
                    const player = players[i];
                        if (player.level === targetLevel) {
                        const daysSince = (currentTime - player.last_action) / (24 * 60 * 60);
                        if (daysSince > 365) {
                            bestPos = player.position;
                            bestId = player.id;
                            bestAction = player.last_action;
                            found = true;
                            break;
                        }
                    }
                }
                if (found) {
                    break;
                }
                currentOffset += 100;

            } catch (error) {
                console.error(`Error fetching data at offset ${currentOffset}:`, error);
                currentOffset += 100;
            }
        }
        
        if (!found) {
            return null;
        }
        
        return {
            position: bestPos,
            playerId: bestId,
            lastAction: bestAction,
            fetchTime: currentTime
        };
    }

    function validateInactive(playerData) {
        const currentTime = Math.floor(Date.now() / 1000);
        return playerData.lastAction < playerData.fetchTime;
    }

    async function calculateRelativePos() {
        try {
            const userLevel = await getUserLevel();
            await sleep(MIN_INTERVAL);

            GM_setValue('last_seen_level', userLevel.level);

            GM_setValue('process_step', 'user_level_fetched');
            GM_setValue('process_user_level', userLevel.level);
            GM_setValue('process_user_rank', userLevel.rank);
            GM_setValue('process_timestamp', Date.now());

            if (userLevel.level >= 100) {
                const result = {
                    level: userLevel.level,
                    rank: userLevel.rank,
                    relativePosition: 0,
                    finalLevel: 100.00,
                    lowerLevelInactiveData: null,
                    currentLevelInactiveData: null
                };

                GM_setValue('cached_level_data', result);
                GM_setValue('cache_timestamp', Date.now());
                GM_setValue('process_step', 'completed');

                return 100.00;
            }

            const lowerInactive = await findInactivePos(userLevel.level - 1);

            GM_setValue('process_step', 'lower_level_fetched');
            GM_setValue('process_lower_level_inactive_data', JSON.stringify(lowerInactive));

            const currentInactive = await findInactivePos(userLevel.level);

            GM_setValue('process_step', 'current_level_fetched');
            GM_setValue('process_current_level_inactive_data', JSON.stringify(currentInactive));

            if (!validateInactive(lowerInactive) || !validateInactive(currentInactive)) {
                throw new Error('Inactive player validation failed - need to refetch data');
            }

            if (lowerInactive.position === -1 || currentInactive.position === -1) {
                return userLevel.level;
            }

            const userRank = userLevel.rank;
            const lastCurrentPos = lowerInactive.position;
            const currentPos = currentInactive.position;

            const relativePos = (lastCurrentPos - userRank) / (lastCurrentPos - currentPos);
            let roundedRelative = Math.round(relativePos * 100) / 100;

            if (userLevel.level < 100) {
                roundedRelative = Math.min(roundedRelative, 0.99);
            }

            const finalLevel = userLevel.level + roundedRelative;

            const result = {
                level: userLevel.level,
                rank: userLevel.rank,
                relativePosition: roundedRelative,
                finalLevel: finalLevel,
                lowerLevelInactiveData: lowerInactive,
                currentLevelInactiveData: currentInactive
            };

            GM_setValue('cached_level_data', result);
            GM_setValue('cache_timestamp', Date.now());

            GM_setValue('process_step', 'completed');
            GM_setValue('process_final_level', finalLevel);

            return finalLevel;
        } catch (error) {

            GM_setValue('process_step', 'error');
            GM_setValue('process_error', error.message);

            if (error.message.includes('API') || error.message.includes('key')) {
                return null;
            }

            return 0;
        }
    }

    async function validateKey(key) {
        try {
            const url = `https://api.torn.com/v2/key/info?key=${key}`;
            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    onload: function(response) {
                        if (response.status === 200) {
                            try {
                                const data = JSON.parse(response.responseText);
                                resolve(data);
                            } catch (e) {
                                reject(new Error('Invalid JSON response'));
                            }
                        } else {
                            reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
                        }
                    },
                    onerror: function(error) {
                        reject(new Error('Network error'));
                    }
                });
            });

            if (response.info && response.info.access && response.info.access.level >= 1) {
                return { valid: true, level: response.info.access.level };
            } else {
                return { valid: false, error: 'incorrect key' };
            }
        } catch (error) {
            return { valid: false, error: error.message };
        }
    }

    function showConfig() {
        const overlay = document.createElement('div');
        overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:9999;';

        const modal = document.createElement('div');
        modal.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;padding:20px;border:2px solid #333;z-index:10000;border-radius:8px;box-shadow:0 4px 6px rgba(0,0,0,0.1);';
        modal.innerHTML = `
            <h3>Torn API Configuration</h3>
            <p>Enter your public Torn API key:</p>
            <input type="text" id="keyInput" value="${apiKey}" placeholder="e.g., HhmiKs9LjgBN2UV7" style="width:300px;padding:8px;margin:10px 0;border:1px solid #ccc;border-radius:4px;">
            <br>
            <div id="validMsg" style="margin:10px 0;color:red;font-size:12px;"></div>
            <button id="saveKey" style="background:#007bff;color:white;border:none;padding:8px 16px;margin:5px;border-radius:4px;cursor:pointer;">Validate & Save</button>
            <button id="cancelKey" style="background:#007bff;color:white;border:none;padding:8px 16px;margin:5px;border-radius:4px;cursor:pointer;">Cancel</button>
            <button id="clearCache" style="background:#007bff;color:white;border:none;padding:8px 16px;margin:5px;border-radius:4px;cursor:pointer;">Clear Cache</button>
        `;

        overlay.appendChild(modal);
        document.body.appendChild(overlay);

        document.getElementById('saveKey').onclick = async function() {
            const newKey = document.getElementById('keyInput').value.trim();
            const validDiv = document.getElementById('validMsg');
            const saveBtn = document.getElementById('saveKey');

            if (!newKey) {
                validDiv.textContent = 'Please enter an API key';
                validDiv.style.color = 'red';
                return;
            }

            validDiv.textContent = 'Validating API key...';
            validDiv.style.color = 'blue';
            saveBtn.disabled = true;
            saveBtn.textContent = 'Validating...';

            const validation = await validateKey(newKey);

            if (validation.valid) {
                validDiv.textContent = 'API key saved successfully!';
                validDiv.style.color = 'green';

                apiKey = newKey;
                GM_setValue('torn_api_key', apiKey);
                GM_setValue('needs_full_recalc', true);

                GM_setValue('cached_level_data', null);
                GM_setValue('cache_timestamp', 0);
                displayLevel = null;
                processing = false;

                setTimeout(() => {
                    document.body.removeChild(overlay);
                }, 1500);
            } else {
                validDiv.textContent = 'Invalid API key';
                validDiv.style.color = 'red';
                saveBtn.disabled = false;
                saveBtn.textContent = 'Validate & Save';
            }
        };

        document.getElementById('cancelKey').onclick = function() {
            document.body.removeChild(overlay);
        };

        document.getElementById('clearCache').onclick = function() {
            GM_setValue('cached_level_data', null);
            GM_setValue('cache_timestamp', 0);
            clearProcess();
            displayLevel = null;
            processing = false;
        };

        overlay.onclick = function(e) {
            if (e.target === overlay) {
                document.body.removeChild(overlay);
            }
        };
    }

    async function checkNeedsRecalc(cached) {
        try {
            const userLevel = await getUserLevel();
            await sleep(MIN_INTERVAL);
            
            if (userLevel.level !== cached.level) {
                return true;
            }
            
            if (cached.lowerLevelInactiveData && cached.currentLevelInactiveData) {
                const currentTime = Math.floor(Date.now() / 1000);
                
                if (cached.lowerLevelInactiveData.lastAction > cached.lowerLevelInactiveData.fetchTime ||
                    cached.currentLevelInactiveData.lastAction > cached.currentLevelInactiveData.fetchTime) {
                    return true;
                }
            }
            
            return false;
        } catch (error) {
            console.error('Error checking if full recalculation needed:', error);
            return true;
        }
    }

    async function updateRank(cached) {
        try {
            
            const userLevel = await getUserLevel();
            await sleep(MIN_INTERVAL);
            
            const userRank = userLevel.rank;
            const lastCurrentPos = cached.lowerLevelInactiveData.position;
            const currentPos = cached.currentLevelInactiveData.position;
            
            const relativePos = (lastCurrentPos - userRank) / (lastCurrentPos - currentPos);
            let roundedRelative = Math.round(relativePos * 100) / 100;
            
            if (cached.level < 100) {
                roundedRelative = Math.min(roundedRelative, 0.99);
            }
            
            const finalLevel = cached.level + roundedRelative;
            
            cached.rank = userRank;
            cached.relativePosition = roundedRelative;
            cached.finalLevel = finalLevel;
            
            GM_setValue('cached_level_data', cached);
            GM_setValue('cache_timestamp', Date.now());
            
            return finalLevel;
        } catch (error) {
            console.error('Error updating with current rank:', error);
            return cached.finalLevel;
        }
    }

    async function getDisplayLevel() {
        if (!apiKey) {
            return null;
        }

        if (processing) {
            return null;
        }

        const needsRecalc = GM_getValue('needs_full_recalc', false);
        if (needsRecalc) {
            GM_setValue('needs_full_recalc', false);
            GM_setValue('cached_level_data', null);
            GM_setValue('cache_timestamp', 0);
        }

        const now = Date.now();
        if (now - lastCall < RATE_WINDOW && cachedData) {
            return cachedData.finalLevel;
        }

        if (cachedData && (now - cacheTime < CACHE_DURATION)) {
            const needsRecalc = await checkNeedsRecalc(cachedData);
            
            if (!needsRecalc) {
                return await updateRank(cachedData);
            }
        }

        const processStep = GM_getValue('process_step', '');
        const processTime = GM_getValue('process_timestamp', 0);

        if (processStep && (now - processTime < 5 * 60 * 1000)) {
            try {
                if (processStep === 'completed') {
                    const finalLevel = GM_getValue('process_final_level', 0);
                    if (finalLevel > 0) {
                        clearProcess();
                        return finalLevel;
                    }
                } else if (processStep === 'current_level_fetched') {
                    const userLevel = GM_getValue('process_user_level', 0);
                    const userRank = GM_getValue('process_user_rank', 0);
                    const lowerInactive = JSON.parse(GM_getValue('process_lower_level_inactive_data', '{}'));
                    const currentInactive = JSON.parse(GM_getValue('process_current_level_inactive_data', '{}'));

                    if (userLevel > 0 && lowerInactive.position && currentInactive.position) {

                        if (!validateInactive(lowerInactive) || !validateInactive(currentInactive)) {
                            throw new Error('Inactive player validation failed - need to refetch data');
                        }

                        const relativePos = (userRank - currentInactive.position) / (lowerInactive.position - currentInactive.position);
                        let roundedRelative = Math.round(relativePos * 100) / 100;

                        if (userLevel < 100) {
                            roundedRelative = Math.min(roundedRelative, 0.99);
                        }

                        const finalLevel = userLevel + roundedRelative;

                        const result = {
                            level: userLevel,
                            rank: userRank,
                            relativePosition: roundedRelative,
                            finalLevel: finalLevel,
                            lowerLevelInactiveData: lowerInactive,
                            currentLevelInactiveData: currentInactive
                        };

                        GM_setValue('cached_level_data', result);
                        GM_setValue('cache_timestamp', Date.now());

                        clearProcess();

                        return finalLevel;
                    }
                }
            } catch (error) {
                console.error('Error resuming from saved progress:', error);
                clearProcess();
            }
        }

        if (processStep && (now - processTime >= 5 * 60 * 1000)) {
            clearProcess();
        }

        processing = true;

        try {
            const result = await calculateRelativePos();
            if (result === null) {
                processing = false;
                return null;
            }
            displayLevel = result;
            processing = false;
            return result;
        } catch (error) {
            processing = false;
            return null;
        }
    }

    function clearProcess() {
        GM_setValue('process_step', '');
        GM_setValue('process_user_level', 0);
        GM_setValue('process_user_rank', 0);
        GM_setValue('process_lower_level_inactive_data', '');
        GM_setValue('process_current_level_inactive_data', '');
        GM_setValue('process_timestamp', 0);
        GM_setValue('process_final_level', 0);
        GM_setValue('process_error', '');
    }

    let hasUpdatedThisSession = false;

    async function optimizedChange() {
        const needsRecalc = GM_getValue('needs_full_recalc', false);
        if (hasUpdatedThisSession && !needsRecalc) {
            return;
        }

        const blocks = document.querySelectorAll('.point-block___rQyUK');

        for (let block of blocks) {
            const nameSpan = block.querySelector('.name___ChDL3');
            const valueSpan = block.querySelector('.value___mHNGb');

            if (nameSpan && nameSpan.textContent.trim() === 'Level:' && valueSpan) {
                const originalLevel = parseInt(valueSpan.textContent);

                if (originalLevel !== lastLevel && lastLevel !== 0) {
                    GM_setValue('cached_level_data', null);
                    GM_setValue('cache_timestamp', 0);
                    displayLevel = null;
                    processing = false;
                    GM_setValue('last_seen_level', originalLevel);
                    GM_setValue('needs_full_recalc', true);
                    hasUpdatedThisSession = false;
                }

                if (!hasUpdatedThisSession || needsRecalc) {
                    const level = await getDisplayLevel();

                    if (level !== null) {
                        valueSpan.textContent = level.toFixed(2);
                    }
                    hasUpdatedThisSession = true;
                    GM_setValue('needs_full_recalc', false);
                }

                break;
            }
        }
    }

    document.addEventListener('keydown', function(e) {
        if (e.ctrlKey && e.shiftKey && e.key === 'T') {
            showConfig();
        }
    });

    if (typeof GM_registerMenuCommand !== 'undefined') {
        GM_registerMenuCommand('Configure API Key', showConfig);
    }

    if (apiKey) {
        const firstRun = GM_getValue('first_run_completed', false);
        if (!firstRun) {
            GM_setValue('needs_full_recalc', true);
            GM_setValue('first_run_completed', true);
        }

        const processStep = GM_getValue('process_step', '');
        const processTime = GM_getValue('process_timestamp', 0);
        const now = Date.now();

        if (processStep && processStep !== 'completed' && processStep !== 'error' &&
            (now - processTime < 5 * 60 * 1000)) {
        }
    }

    optimizedChange();

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

    const observer = new MutationObserver(function(mutations) {
        let shouldCheck = false;
        mutations.forEach(function(mutation) {
            if (mutation.addedNodes.length > 0) {
                for (let node of mutation.addedNodes) {
                    if (node.nodeType === 1 && (node.classList.contains('point-block___rQyUK') || node.querySelector('.point-block___rQyUK'))) {
                        shouldCheck = true;
                        break;
                    }
                }
            }
        });

        if (shouldCheck) {
            optimizedChange();
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: false,
        characterData: false
    });
})();