Torn Level Progress+

Estimate progress to your next Torn level.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Вам потребуется установить расширение, например Tampermonkey или Violentmonkey, чтобы установить этот скрипт.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например 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();
})();