Torn Level Progress+

Estimate progress to your next Torn level.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn Level Progress+
// @namespace    https://github.com/deltacharl1e/torn-level-progress
// @version      2.0.3
// @description  Estimate progress to your next Torn level.
// @author       Danny
// @match        https://www.torn.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      api.torn.com
// @run-at       document-idle
// ==/UserScript==

(function () {
"use strict";

/********************************************************************
 * CONFIGURATION
 ********************************************************************/

const VERSION = "2.0.3";

const CONFIG = {

    CACHE_TIME: 24 * 60 * 60 * 1000,

    RATE_LIMIT: 60,

    RATE_WINDOW: 60000,

    MIN_INTERVAL: 1000,

    INACTIVE_DAYS: 365,

    DEBUG: false

};

/********************************************************************
 * LOGGER
 ********************************************************************/

const Log = {

    info(...msg){

        if(CONFIG.DEBUG)
            console.log("[TLP]",...msg);

    },

    error(...msg){

        console.error("[TLP]",...msg);

    }

};

/********************************************************************
 * CACHE
 ********************************************************************/

class Cache{

    static load(){

        return GM_getValue("tlp_cache",null);

    }

    static save(data){

        GM_setValue("tlp_cache",data);

    }

    static clear(){

        GM_setValue("tlp_cache",null);

    }

    static valid(){

        const cache=this.load();

        if(!cache)
            return false;

        return (Date.now()-cache.updated)<CONFIG.CACHE_TIME;

    }

}

/********************************************************************
 * API
 ********************************************************************/

class TornAPI{

    constructor(){

        this.key=GM_getValue("tlp_api_key","");

        this.lastCall=0;

        this.calls=[];

    }

    hasKey(){

        return this.key.length>0;

    }

    saveKey(key){

        this.key=key.trim();

        GM_setValue("tlp_api_key",this.key);

    }

    async wait(){

        const diff=Date.now()-this.lastCall;

        if(diff<CONFIG.MIN_INTERVAL){

            await new Promise(r=>setTimeout(
                r,
                CONFIG.MIN_INTERVAL-diff
            ));

        }

    }

    cleanCalls(){

        const cutoff=Date.now()-CONFIG.RATE_WINDOW;

        this.calls=this.calls.filter(
            t=>t>cutoff
        );

    }

    async request(endpoint){

        if(!this.hasKey())
            throw new Error("No API Key");

        this.cleanCalls();

        if(this.calls.length>=CONFIG.RATE_LIMIT){

            throw new Error("API rate limit reached");

        }

        await this.wait();

        this.lastCall=Date.now();

        this.calls.push(this.lastCall);

        Log.info("GET",endpoint);

        const separator = endpoint.includes("?") ? "&" : "?";

        return new Promise((resolve,reject)=>{

            GM_xmlhttpRequest({

                method:"GET",

                url:`https://api.torn.com/v2/${endpoint}${separator}key=${encodeURIComponent(this.key)}`,

                onload:r=>{

                    try{

                        const json=JSON.parse(r.responseText);

                        if(json.error){

                            reject(new Error(
                                `Torn API error ${json.error.code}: ${json.error.error}`
                            ));

                            return;

                        }

                        resolve(json);

                    }

                    catch(e){

                        reject(e);

                    }

                },

                onerror: r => {

                    reject(new Error(
                        "Network error contacting Torn API"
                    ));

                }

            });

        });

    }

    async validate(){

        try{

            await this.request("key/info");

            return true;

        }

        catch(e){

            return false;

        }

    }

    async userHOF(){

        const data=await this.request("user/hof");

        return {
    value: data.hof.level.value,
    rank: data.hof.level.rank
};

    }

    async hallOfFame(offset=0,limit=100){

        const data=await this.request(
            `torn/hof?cat=level&offset=${offset}&limit=${limit}`
        );

        return data.hof;

    }

    async playerRank(id){

        const data=await this.request(
            `user/${id}/hof`
        );

        return data.hof.level.rank;

    }

}

const API=new TornAPI();

/********************************************************************
 * MENU
 ********************************************************************/

GM_registerMenuCommand(
    "Configure API Key",
    async()=>{

        const key=prompt(
            "Enter your Torn API Key",
            API.key
        );

        if(!key)
            return;

        API.saveKey(key);

        const ok=await API.validate();

        if(ok){

            alert("API key saved.");

        }else{

            alert("Invalid API key.");

        }

    }
);

GM_registerMenuCommand(
    "Clear Cache",
    ()=>{

        Cache.clear();

        alert("Cache cleared.");

    }
);

/********************************************************************
 * STARTUP
 ********************************************************************/

Log.info("Version",VERSION);

if(!API.hasKey()){

    Log.info("No API key configured.");

}else{

    Log.info("API key loaded.");

}

/********************************************************************
 * HALL OF FAME SEARCH ENGINE
 ********************************************************************/

class HallOfFameSearch {

    constructor(api) {
        this.api = api;
    }

    async getLevelOnePosition() {
        // Player #1364774 is still used as the level 1 anchor,
        // exactly like the original script.
        return await this.api.playerRank(1364774);
    }

    async findStartPage(targetLevel) {

        const levelOneRank = await this.getLevelOnePosition();

        let left = 0;
        let right = levelOneRank;
        let guess = 0;

        while ((right - left) > 100) {

            guess = Math.floor((left + right) / 2);

            const page = await this.api.hallOfFame(guess, 100);

            let highest = -1;
            let lowest = 999;

            for (const player of page) {

                highest = Math.max(highest, player.level);
                lowest = Math.min(lowest, player.level);

            }

            if (highest > targetLevel && lowest > targetLevel) {

                left = guess + 100;
                continue;

            }

            if (highest < targetLevel && lowest < targetLevel) {

                right = guess - 100;
                continue;

            }

            return guess;

        }

        return left;

    }

    async findInactivePlayer(targetLevel) {

        Log.info("Searching Level", targetLevel);

        let offset = await this.findStartPage(targetLevel);

        let seenTarget = false;

        while (true) {

            const page = await this.api.hallOfFame(offset, 100);

            if (!page.length)
                return null;

            let pageHasTarget = false;

            for (const player of page) {

                if (player.level !== targetLevel)
                    continue;

                pageHasTarget = true;

                const inactiveDays =
                    (Date.now() / 1000 - player.last_action) / 86400;

                if (inactiveDays >= CONFIG.INACTIVE_DAYS) {

                    Log.info(
                        "Inactive marker",
                        player.name,
                        player.position
                    );

                    return {

                        id: player.id,

                        name: player.name,

                        position: player.position,

                        lastAction: player.last_action,

                        fetched: Math.floor(Date.now() / 1000)

                    };

                }

            }

            if (pageHasTarget) {

                seenTarget = true;

            }
            else if (seenTarget) {

                // We've already scanned the block of players at
                // targetLevel and have now moved past it without
                // finding an inactive marker — no point scanning
                // the rest of the Hall of Fame.
                Log.info(
                    "Passed target level block, no inactive marker found",
                    targetLevel
                );

                return null;

            }

            offset += 100;

        }

    }

    validate(marker) {

        if (!marker)
            return false;

        return marker.lastAction <= marker.fetched;

    }

}

const HOF = new HallOfFameSearch(API);

/********************************************************************
 * PROGRESS ENGINE
 ********************************************************************/

class ProgressEngine {

    constructor(api, hof) {
        this.api = api;
        this.hof = hof;
    }

    calculatePercentage(rank, lowerPos, upperPos) {

        if (lowerPos <= upperPos)
            return 0;

        let value = ((lowerPos - rank) / (lowerPos - upperPos)) * 100;

        value = Math.max(0, Math.min(99.99, value));

        return Number(value.toFixed(2));
    }

    async refresh(force = false) {

        let cache = Cache.load();

        if (!force && Cache.valid() && cache) {

            const user = await this.api.userHOF();

            if (user.value !== cache.level) {

                // Level has changed since this cache was built —
                // the cached lower/upper markers belong to the old
                // level and are no longer valid. Force a full
                // recalculation instead of reusing them.
                Log.info(
                    "Level changed since last cache, forcing recalculation",
                    cache.level, "->", user.value
                );

                return this.refresh(true);

            }

            if (user.value >= 100) {

                cache.rank = user.rank;
                cache.percent = 100;
                cache.updated = Date.now();

                Cache.save(cache);

                return cache;

            }

            cache.rank = user.rank;
            cache.percent = this.calculatePercentage(
                user.rank,
                cache.lower.position,
                cache.upper.position
            );

            cache.updated = Date.now();

            Cache.save(cache);

            return cache;
        }

        Log.info("Performing full calculation...");

        const user = await this.api.userHOF();

        if (user.value >= 100) {

            cache = {
                updated: Date.now(),
                level: 100,
                rank: user.rank,
                percent: 100,
                lower: null,
                upper: null
            };

            Cache.save(cache);

            return cache;
        }

        const lower = await this.hof.findInactivePlayer(user.value - 1);
        const upper = await this.hof.findInactivePlayer(user.value);

        if (!this.hof.validate(lower) || !this.hof.validate(upper)) {
            throw new Error("Inactive player validation failed.");
        }

        cache = {

            updated: Date.now(),

            level: user.value,

            rank: user.rank,

            lower,

            upper,

            percent: this.calculatePercentage(
                user.rank,
                lower.position,
                upper.position
            )

        };

        Cache.save(cache);

        return cache;

    }

}

const Progress = new ProgressEngine(API, HOF);

/********************************************************************
 * HISTORY MANAGER
 ********************************************************************/

class HistoryManager {

    constructor() {

        this.key = "tlp_history";

        this.maxEntries = 100;

    }


    load() {

        return GM_getValue(
            this.key,
            []
        );

    }


    save(history) {

        GM_setValue(
            this.key,
            history
        );

    }


    add(data) {

        let history = this.load();


        const entry = {

            time: Date.now(),

            level: data.level,

            percent: data.percent,

            rank: data.rank

        };


        // Avoid duplicate entries
        const last = history[history.length - 1];


        if (
            last &&
            last.level === entry.level &&
            Math.abs(last.percent - entry.percent) < 0.01
        ) {

            return;

        }


        history.push(entry);


        if (history.length > this.maxEntries) {

            history =
                history.slice(
                    history.length - this.maxEntries
                );

        }


        this.save(history);

    }


    getRate() {

        const history = this.load();


        if (history.length < 2)
            return null;


        const first =
            history[0];


        const last =
            history[history.length - 1];


        const percentGain =
            last.percent - first.percent;


        const days =
            (last.time - first.time)
            /
            (1000 * 60 * 60 * 24);


        if (days <= 0)
            return null;


        return percentGain / days;

    }


    daysRemaining(currentPercent) {

        const rate =
            this.getRate();


        if (!rate || rate <= 0)
            return null;


        const remaining =
            100 - currentPercent;


        return Math.ceil(
            remaining / rate
        );

    }


}


const History = new HistoryManager();


/********************************************************************
 * SIDEBAR WIDGET
 ********************************************************************/

class SidebarWidget {

    constructor() {
        this.element = null;
    }

    create() {

        if (this.element)
            return this.element;

        const card = document.createElement("div");

        card.id = "tlp-widget";

        card.style.cssText = `
            margin:10px;
            padding:10px;
            background:#222;
            border:1px solid #444;
            border-radius:6px;
            color:#ddd;
            font-size:12px;
            line-height:1.5;
        `;

        card.innerHTML = `
            <div style="font-weight:bold;font-size:14px;margin-bottom:8px;">
                Level Progress
            </div>

            <div id="tlp-level">
                Waiting...
            </div>

            <div style="margin-top:8px;">
                <div style="background:#555;height:10px;border-radius:5px;overflow:hidden;">
                    <div id="tlp-bar"
                        style="
                            width:0%;
                            height:100%;
                            background:#2ecc71;
                            transition:width .4s, background-color .4s;
                        ">
                    </div>
                </div>
            </div>

            <div id="tlp-percent"
                 style="margin-top:8px;font-size:18px;font-weight:bold;">
                 0%
            </div>

            <div id="tlp-status">
                Initialising...
            </div>

            <div id="tlp-days"
                 style="margin-top:6px;color:#999;">
            </div>
        `;

        this.element = card;

        return card;

    }

    attach() {

        if (document.getElementById("tlp-widget"))
            return;

        this.findStatsPanel(0);

    }

    findStatsPanel(attempt) {

        const maxAttempts = 15;

        const levelStat = document.querySelector(
            'li[aria-label^="Level:"]'
        );

        const statsPanel =
            levelStat && levelStat.closest(".cont-gray");

        if (statsPanel) {

            const widget = this.create();

            statsPanel.insertAdjacentElement("afterend", widget);

            Log.info(
                "Level Progress widget attached next to level stats"
            );

            return;

        }

        if (attempt >= maxAttempts) {

            if (!GM_getValue("tlp_floating_fallback", true)) {

                Log.info(
                    "Stats panel not found and floating fallback is disabled — widget not shown"
                );

                return;

            }

            // Stats panel never showed up — fall back to a
            // floating panel so the widget is still visible.
            const widget = this.create();

            widget.style.position = "fixed";
            widget.style.top = "80px";
            widget.style.left = "20px";
            widget.style.width = "200px";
            widget.style.zIndex = "99999";
            widget.style.boxShadow = "0 2px 10px rgba(0,0,0,.5)";

            document.body.appendChild(widget);

            Log.info(
                "Level Progress widget attached as floating panel (stats panel not found)"
            );

            return;

        }

        setTimeout(
            ()=>this.findStatsPanel(attempt + 1),
            500
        );

    }

    update(data) {

        if (!this.element)
            return;

        const percent = Number(data.percent);

        document.getElementById("tlp-level").textContent =
            `Level ${data.level} → ${data.level + 1}`;

        document.getElementById("tlp-percent").textContent =
            percent.toFixed(2) + "%";

        document.getElementById("tlp-bar").style.width =
            percent + "%";

        // Smoothly interpolate hue from red (0%) to green (100%)
        // instead of jumping between fixed colours at thresholds.
        const hue = (percent / 100) * 120;
        const colour = `hsl(${hue}, 70%, 45%)`;

        let status = "Just Started";

        if (percent >= 75) {
            status = "Excellent Progress";
        }
        else if (percent >= 50) {
            status = "Over Halfway";
        }
        else if (percent >= 25) {
            status = "Making Progress";
        }

        document.getElementById("tlp-bar").style.background = colour;

        const statusEl = document.getElementById("tlp-status");
        statusEl.textContent = status;
        statusEl.style.color = "";


// Estimate time remaining
const days = History.daysRemaining(percent);

const daysElement =
    document.getElementById("tlp-days");


if (days) {

    daysElement.textContent =
        `≈ ${days} days remaining`;

}
else {

    daysElement.textContent =
        "Collecting progress history...";

}

    }

    status(message) {

        if (!this.element)
            return;

        const status = document.getElementById("tlp-status");

        status.textContent = message;
        status.style.color = "";

    }

    error(message) {

        if (!this.element)
            return;

        const status = document.getElementById("tlp-status");

        status.textContent = message;
        status.style.color = "#e74c3c";

    }

}

const Widget = new SidebarWidget();


/********************************************************************
 * APPLICATION CONTROLLER
 ********************************************************************/

class TornLevelProgressApp {

    constructor() {

        this.running = false;

    }


    async start() {

        if (this.running)
            return;

        this.running = true;

        Log.info("Starting Torn Level Progress+");


        Widget.attach();


        if (!API.hasKey()) {

            Widget.error(
                "No API key. Use Tampermonkey menu."
            );

            this.running = false;

            return;

        }


        try {

            Widget.status(
                "Calculating progress..."
            );


            const data = await Progress.refresh();


            if (!data) {

                throw new Error(
                    "No progress data returned"
                );

            }


            History.add(data);

Widget.update(data);


            Log.info(
                "Progress loaded",
                data
            );


        }
        catch(error) {

            Log.error(
                "Application error",
                error
            );


            Widget.error(
                "Error: " + error.message
            );

        }


        this.running = false;

    }


    watchInterval() {

        setInterval(()=>{

            Log.info("Periodic refresh (1 minute)");

            this.start();

        }, 60000);

    }

    watchNavigation() {

        let lastURL = location.href;


        setInterval(()=>{


            if(location.href !== lastURL) {


                lastURL = location.href;


                Log.info(
                    "Page changed, refreshing"
                );


                setTimeout(()=>{

                    this.start();

                },1500);


            }


        },1000);

    }

}


const App = new TornLevelProgressApp();

/********************************************************************
 * SETTINGS PANEL
 ********************************************************************/

class SettingsPanel {


    open() {


        if (document.getElementById("tlp-settings"))
            return;


        const overlay =
            document.createElement("div");


        overlay.id = "tlp-settings";


        overlay.style.cssText = `

            position:fixed;
            top:0;
            left:0;
            width:100%;
            height:100%;
            background:rgba(0,0,0,.6);
            z-index:99999;
            display:flex;
            align-items:center;
            justify-content:center;

        `;


        const box =
            document.createElement("div");


        box.style.cssText = `

            background:#222;
            color:white;
            padding:20px;
            border-radius:8px;
            width:350px;
            box-shadow:0 0 20px #000;

        `;


        box.innerHTML = `

            <h3>
                Torn Level Progress+
            </h3>


            <div>
                Version ${VERSION}
            </div>


            <hr>


            <label>
                Torn API Key
            </label>


            <input
                id="tlp-key"
                type="text"
                value="${API.key}"
                style="
                    width:100%;
                    margin-top:8px;
                    padding:6px;
                "
            >


            <br><br>


            <label>
                <input
                    id="tlp-floating-toggle"
                    type="checkbox"
                    ${GM_getValue("tlp_floating_fallback", true) ? "checked" : ""}
                >
                Show floating panel if stats panel isn't found
            </label>


            <br><br>


            <button id="tlp-save">
                Save Key
            </button>


            <button id="tlp-clear">
                Clear Cache
            </button>


            <button id="tlp-history">
                Clear History
            </button>


            <br><br>


            <button id="tlp-close">
                Close
            </button>


            <div
                id="tlp-message"
                style="
                    margin-top:10px;
                    color:#aaa;
                "
            ></div>

        `;


        overlay.appendChild(box);

        document.body.appendChild(overlay);



        box.querySelector("#tlp-floating-toggle")
        .onchange = (e)=>{

            GM_setValue(
                "tlp_floating_fallback",
                e.target.checked
            );

        };



        box.querySelector("#tlp-save")
        .onclick = async()=>{


            const key =
                box.querySelector("#tlp-key")
                .value
                .trim();


            const msg =
                box.querySelector("#tlp-message");


            if(!key){

                msg.textContent =
                    "Enter an API key.";

                return;

            }


            msg.textContent =
                "Checking key...";


            API.saveKey(key);


            const valid =
                await API.validate();


            if(valid){

                msg.textContent =
                    "API key saved.";

                setTimeout(
                    ()=>{
                        overlay.remove();
                        App.start();
                    },
                    1000
                );

            }
            else{

                msg.textContent =
                    "Invalid API key.";

            }


        };



        box.querySelector("#tlp-clear")
        .onclick = ()=>{


            Cache.clear();


            box.querySelector("#tlp-message")
            .textContent =
                "Cache cleared.";

        };



        box.querySelector("#tlp-history")
        .onclick = ()=>{


            GM_setValue(
                "tlp_history",
                []
            );


            box.querySelector("#tlp-message")
            .textContent =
                "History cleared.";

        };



        box.querySelector("#tlp-close")
        .onclick = ()=>{

            overlay.remove();

        };


    }

}


const Settings = new SettingsPanel();



/********************************************************************
 * SETTINGS HOTKEY
 ********************************************************************/

document.addEventListener(
    "keydown",
    e=>{


        if(
            e.ctrlKey &&
            e.shiftKey &&
            e.key === "L"
        ){

            Settings.open();

        }


    }
);



GM_registerMenuCommand(
    "Open Level Progress Settings",
    ()=>Settings.open()
);

/********************************************************************
 * START APPLICATION
 ********************************************************************/


App.start();

App.watchNavigation();

App.watchInterval();
})();