Estimate progress to your next Torn level.
// ==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();
})();