Accurate level progress via HoF rank interpolation + XP/hour tracking
// ==UserScript==
// @name Level Progress Estimation
// @namespace http://tampermonkey.net/
// @version 15.00
// @description Accurate level progress via HoF rank interpolation + XP/hour tracking
// @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";
const TIME_KEY = "pda_last_lvl_time";
const OLD_VAL_KEY = "pda_prev_lvl_int";
const HISTORY_KEY = "pda_lvl_history";
let isVisible = localStorage.getItem(STORAGE_KEY)!== 'false';
let lastKnownValue = localStorage.getItem(VAL_KEY) || "--";
let lastUpdate = parseInt(localStorage.getItem(TIME_KEY) || "0");
let history = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
let showRate = false;
let currentData = null;
let pillInjected = false;
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; }
#acc-lvl-pill {
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;
transition: background 0.2s;
position: relative;
-webkit-tap-highlight-color: transparent;
}
#acc-lvl-pill:active { background: #2a2a2a; }
.lvl-up-flash {
background: #2d4a1d!important;
box-shadow: 0 0 8px #85b200!important;
}
.rank-up { color: #85b200!important; }
.rank-down { color: #ff4444!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 with 'hof' access:");
if (key) {
key = key.trim();
localStorage.setItem("APIKey", key);
await GM_setValue("torn_api_key", key);
}
}
return key? key.trim() : null;
}
async function fetchTorn(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: 10000,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data.error) reject(data.error.error);
else resolve(data);
} catch (e) { reject("JSON Parse Error"); }
},
onerror: () => reject("Network Error"),
ontimeout: () => reject("Timeout")
});
});
}
function updateHistory(level, rank, fraction) {
const now = Date.now();
history.push({ level, rank, fraction, time: now });
history = history.filter(h => now - h.time < 86400000);
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
function calculateXPRate(currentLevel, currentFraction) {
if (history.length < 2) return null;
const sameLevel = history.filter(h => h.level === currentLevel);
if (sameLevel.length < 2) return null;
const oldest = sameLevel[0];
const newest = sameLevel[sameLevel.length - 1];
const timeHours = (newest.time - oldest.time) / 3600000;
if (timeHours < 0.5) return null;
const fractionGain = newest.fraction - oldest.fraction;
const ratePerHour = fractionGain / timeHours;
if (ratePerHour <= 0) return { rate: 0, eta: null };
const remainingFraction = 1 - currentFraction;
const hoursToLevel = remainingFraction / ratePerHour;
return {
rate: ratePerHour,
eta: hoursToLevel
};
}
function formatETA(hours) {
if (!hours || hours <= 0) return "∞";
if (hours < 1) return `${Math.round(hours * 60)}m`;
if (hours < 24) return `${hours.toFixed(1)}h`;
const days = Math.floor(hours / 24);
const hrs = Math.round(hours % 24);
return `${days}d ${hrs}h`;
}
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 { value: "100.00", confident: true, level, rank: myRank, fraction: 1 };
const offset = Math.max(1, myRank - 500);
const hofData = await fetchTorn(`https://api.torn.com/v2/torn/hof?limit=1000&offset=${offset}&cat=level&key=${key}`);
const players = hofData.hof || [];
const currentLevelPlayers = players.filter(p => p.level === level);
if (currentLevelPlayers.length === 0) return { value: level.toFixed(2), confident: false, level, rank: myRank, fraction: 0 };
const positions = currentLevelPlayers.map(p => p.position);
const topOfLevel = Math.min(...positions);
let bottomOfLevel = Math.max(...positions);
const nextLevelPlayers = players.filter(p => p.level === level + 1);
if (nextLevelPlayers.length > 0) {
bottomOfLevel = Math.min(...nextLevelPlayers.map(p => p.position)) - 1;
}
const range = bottomOfLevel - topOfLevel + 1;
if (range <= 1) return { value: level.toFixed(2), confident: false, level, rank: myRank, fraction: 0 };
const distFromTop = myRank - topOfLevel;
let fraction = distFromTop / range;
fraction = Math.pow(fraction, 1.25);
if (fraction <= 0) fraction = 0.01;
if (fraction >= 1) fraction = 0.99;
updateHistory(level, myRank, fraction);
const confident = range >= 50;
const value = (level + fraction).toFixed(2);
return { value, confident, level, rank: myRank, fraction, range };
} catch (e) {
console.error("[PDA Level] Calc error:", 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 flashLevelUp() {
const pill = document.getElementById('acc-lvl-pill');
if (!pill) return;
pill.classList.add('lvl-up-flash');
setTimeout(() => pill.classList.remove('lvl-up-flash'), 3000);
}
function updatePillDisplay() {
const valSpan = document.getElementById('acc-lvl-val');
if (!valSpan ||!currentData) return;
if (showRate) {
const xpRate = calculateXPRate(currentData.level, currentData.fraction);
if (xpRate && xpRate.rate > 0) {
valSpan.textContent = `+${(xpRate.rate * 100).toFixed(2)}%/h`;
} else {
valSpan.textContent = "No rate yet";
}
} else {
valSpan.textContent = (currentData.confident? "" : "~") + currentData.value;
}
}
function updateUI(data) {
if (data) currentData = data;
const oldLevelInt = parseInt(localStorage.getItem(OLD_VAL_KEY) || "0");
let tooltip = "Tap to toggle level/rate";
let deltaIndicator = "";
if (data && data.value!== "--") {
const newLevelInt = Math.floor(parseFloat(data.value));
if (oldLevelInt && newLevelInt > oldLevelInt) flashLevelUp();
if (history.length >= 2) {
const prev = history[history.length - 2];
if (prev.level === data.level) {
const rankDelta = prev.rank - data.rank;
if (rankDelta > 0) deltaIndicator = ` <span class="rank-up">↑${rankDelta}</span>`;
else if (rankDelta < 0) deltaIndicator = ` <span class="rank-down">↓${Math.abs(rankDelta)}</span>`;
}
}
const xpRate = calculateXPRate(data.level, data.fraction);
if (xpRate && xpRate.rate > 0) {
tooltip = `Level ${data.value} | +${(xpRate.rate * 100).toFixed(2)}%/hr | ETA ${formatETA(xpRate.eta)}\nTap to toggle`;
} else {
tooltip = `Level ${data.value} | Range: ${data.range} players | Need 2+ hours for rate\nTap to toggle`;
}
lastKnownValue = (data.confident? "" : "~") + data.value;
localStorage.setItem(VAL_KEY, lastKnownValue);
localStorage.setItem(OLD_VAL_KEY, newLevelInt.toString());
localStorage.setItem(TIME_KEY, Date.now().toString());
}
const deltaSpan = document.getElementById('acc-lvl-delta');
const pill = document.getElementById('acc-lvl-pill');
if (deltaSpan) deltaSpan.innerHTML = deltaIndicator;
if (pill) pill.title = tooltip;
updatePillDisplay();
syncUI();
}
function injectPill() {
// Nuke all existing pills first
document.querySelectorAll('#acc-lvl-pill').forEach(el => el.remove());
const header = document.querySelector('#header-root') || document.querySelector('.header-wrapper');
const tray = header?.querySelector('[class*="right_"]') || header?.querySelector('[class*="header-buttons"]');
if (!tray) return false;
const newPill = document.createElement('div');
newPill.id = 'acc-lvl-pill';
newPill.innerHTML = `<span style="color: #85b200; font-size: 10px; font-weight: bold; margin-right: 4px; font-family: sans-serif;">LV</span>
<span id="acc-lvl-val" style="color: #fff; font-size: 11px; font-family: 'Courier New', monospace; font-weight: bold;">${lastKnownValue}</span>
<span id="acc-lvl-delta" style="font-size: 9px; margin-left: 3px;"></span>`;
tray.prepend(newPill);
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.title = 'Toggle Level Progress';
a.innerHTML = '📈';
li.appendChild(a);
statusUl.appendChild(li);
}
return true;
}
async function refresh() {
const data = await getAccurateLevel();
if (data) updateUI(data);
}
function scheduleNextRefresh() {
const now = new Date();
const nextHour = new Date(now);
nextHour.setUTCHours(now.getUTCHours() + 1, 0, 10, 0);
const msUntil = nextHour - now;
setTimeout(async () => {
await refresh();
scheduleNextRefresh();
}, msUntil);
}
// Event delegation - survives DOM rebuilds
document.addEventListener('click', function(e) {
const pill = e.target.closest('#acc-lvl-pill');
if (pill) {
e.preventDefault();
e.stopImmediatePropagation();
showRate =!showRate;
updatePillDisplay();
return false;
}
const icon = e.target.closest('#pda-lvl-status-btn');
if (icon) {
e.preventDefault();
e.stopImmediatePropagation();
isVisible =!isVisible;
localStorage.setItem(STORAGE_KEY, isVisible);
syncUI();
return false;
}
}, true);
function init() {
injectPill();
refresh();
scheduleNextRefresh();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Only watch for header being rebuilt, not every change
const observer = new MutationObserver(() => {
if (!document.getElementById('acc-lvl-pill')) {
injectPill();
updateUI(currentData);
}
});
const target = document.querySelector('#header-root') || document.body;
observer.observe(target, { childList: true, subtree: false });
})();