Level Progress Estimation

Fractional level estimation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Level Progress Estimation 
// @namespace    http://tampermonkey.net/
// @version      13.0.1
// @description  Fractional level estimation 
// @author       Pint-Shot-Riot
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      api.torn.com
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const STORAGE = {
    apiKey: "torn_api_key",
    cache: "level_progress_cache_v2",
    rateWindowStart: "level_progress_rate_window_start_v2",
    rateCount: "level_progress_rate_count_v2",
    lastCallAt: "level_progress_last_call_at_v2",
  };

  const PAGE_SIZE = 100;
  const CACHE_DURATION_MS = 12 * 60 * 60 * 1000;
  const INACTIVE_THRESHOLD_SECONDS = 365 * 24 * 60 * 60;
  const RATE_LIMIT = 60;
  const RATE_WINDOW_MS = 60 * 1000;
  const MIN_INTERVAL_MS = 1100;
  const MAX_SCAN_PAGES = 20;

  async function getApiKey() {
    let key = localStorage.getItem("APIKey") || await GM_getValue(STORAGE.apiKey, "");
    if (!key || key.length < 10) {
      key = prompt("Please enter your Torn API Key (Full Access preferred):");
      if (key) await GM_setValue(STORAGE.apiKey, key.trim());
    }
    return key ? key.trim() : null;
  }

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

  async function waitForApiSlot() {
    while (true) {
      const now = Date.now();
      let windowStart = await GM_getValue(STORAGE.rateWindowStart, 0);
      let callCount = await GM_getValue(STORAGE.rateCount, 0);
      let lastCallAt = await GM_getValue(STORAGE.lastCallAt, 0);

      if (!windowStart || now - windowStart >= RATE_WINDOW_MS) {
        windowStart = now;
        callCount = 0;
        await GM_setValue(STORAGE.rateWindowStart, windowStart);
        await GM_setValue(STORAGE.rateCount, callCount);
      }

      const waitMs = Math.max(MIN_INTERVAL_MS - (now - lastCallAt), callCount >= RATE_LIMIT ? RATE_WINDOW_MS - (now - windowStart) : 0);

      if (waitMs <= 0) {
        await GM_setValue(STORAGE.lastCallAt, now);
        await GM_setValue(STORAGE.rateCount, callCount + 1);
        return;
      }
      await sleep(waitMs + 25);
    }
  }

  function fetchTorn(url) {
    return new Promise(async (resolve, reject) => {
      await waitForApiSlot();
      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 findInactiveAnchors(apiKey, userLevel, userRank) {
    const lowerLevel = userLevel - 1;
    let currentAnchor = null, lowerAnchor = null;
    const startOffset = Math.max(0, Math.floor((userRank - 1) / PAGE_SIZE) * PAGE_SIZE);

    for (let distance = 0; distance < MAX_SCAN_PAGES; distance++) {
        const offsets = [startOffset - (distance * PAGE_SIZE), startOffset + (distance * PAGE_SIZE)];
        for (let offset of offsets) {
            if (offset < 0) continue;
            const data = await fetchTorn(`https://api.torn.com/v2/torn/hof?limit=${PAGE_SIZE}&offset=${offset}&cat=level&key=${apiKey}`);
            const players = data.hof || [];
            
            for (const p of players) {
                const isInactive = (Math.floor(Date.now() / 1000) - p.last_action) >= INACTIVE_THRESHOLD_SECONDS;
                if (!isInactive) continue;

                if (p.level === userLevel && p.position < userRank) {
                    if (!currentAnchor || p.position > currentAnchor.position) currentAnchor = p;
                }
                if (p.level === lowerLevel && p.position > userRank) {
                    if (!lowerAnchor || p.position < lowerAnchor.position) lowerAnchor = p;
                }
            }
            if (currentAnchor && lowerAnchor) return { currentAnchor, lowerAnchor };
        }
    }
    return { currentAnchor, lowerAnchor };
  }

  async function getAccurateLevel() {
    const key = await getApiKey();
    if (!key) return null;

    const cachedRaw = await GM_getValue(STORAGE.cache, null);
    if (cachedRaw) {
        const cached = JSON.parse(cachedRaw);
        if (Date.now() - cached.updatedAt < CACHE_DURATION_MS) {
            const user = await fetchTorn(`https://api.torn.com/v2/user/hof?key=${key}`);
            if (user.hof.level.value === cached.level && cached.currentAnchor && cached.lowerAnchor) {
                const progress = (cached.lowerAnchor.position - user.hof.level.rank) / (cached.lowerAnchor.position - cached.currentAnchor.position);
                return (user.hof.level.value + Math.max(0, Math.min(0.99, progress))).toFixed(2);
            }
        }
    }

    try {
      const user = await fetchTorn(`https://api.torn.com/v2/user/hof?key=${key}`);
      const lvl = user.hof.level.value;
      const rank = user.hof.level.rank;

      if (lvl >= 100) return "100.00";

      const { currentAnchor, lowerAnchor } = await findInactiveAnchors(key, lvl, rank);
      
      let finalVal = lvl;
      if (currentAnchor && lowerAnchor) {
          const progress = (lowerAnchor.position - rank) / (lowerAnchor.position - currentAnchor.position);
          finalVal = lvl + Math.max(0, Math.min(0.99, progress));
      }

      await GM_setValue(STORAGE.cache, JSON.stringify({
          level: lvl,
          currentAnchor,
          lowerAnchor,
          updatedAt: Date.now()
      }));

      return finalVal.toFixed(2);
    } catch (e) {
      console.error("[Accurate Level]", e);
      return null;
    }
  }

  function injectPill(val) {
    if (document.getElementById('acc-lvl-pill')) {
      document.getElementById('acc-lvl-val').textContent = val;
      return;
    }

    const header = document.querySelector('#header-root') || document.querySelector('.header-wrapper');
    const tray = header?.querySelector('[class*="right_"]') || header?.querySelector('[class*="header-buttons"]');
    if (!tray) return;

    const pill = document.createElement('div');
    pill.id = 'acc-lvl-pill';
    pill.style = "display: inline-flex; align-items: center; background: #222; border: 1px solid #444; border-radius: 10px; padding: 2px 8px; margin: 0 6px; height: 22px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,0.4); flex-shrink: 0; transition: border-color 0.2s;";
    pill.onmouseover = () => pill.style.borderColor = "#85b200";
    pill.onmouseout = () => pill.style.borderColor = "#444";
    
    pill.innerHTML = `
      <span style="color: #85b200; font-size: 10px; font-weight: bold; margin-right: 5px; 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;">${val}</span>
    `;

    pill.onclick = () => window.location.href = "/halloffame.php#/type=level";
    tray.prepend(pill);
  }

  async function update() {
    const val = await getAccurateLevel();
    if (val) injectPill(val);
  }

  if (document.readyState === "complete") update();
  else window.addEventListener("load", update);

  setInterval(update, 1800000);

  setInterval(() => {
      if (!document.getElementById('acc-lvl-pill')) {
          update();
      }
  }, 3000);
})();