A clean HUD for Torn — live countdowns for drug, booster and medical cooldowns plus energy, nerve, happy and life bars. Works on desktop and TornPDA.
// ==UserScript==
// @name Torn Cooldown Tracker
// @namespace https://greasyfork.org/
// @version 1.2.3
// @description A clean HUD for Torn — live countdowns for drug, booster and medical cooldowns plus energy, nerve, happy and life bars. Works on desktop and TornPDA.
// @author Imtazking [2189762]
// @license MIT
// @match https://www.torn.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect api.torn.com
// ==/UserScript==
(function () {
'use strict';
// ── Platform ───────────────────────────────────────────────────────────────
const IS_PDA = typeof PDA_httpGet !== 'undefined';
const STORAGE_KEY = 'tct_api_key';
const WIDGET_ID = 'tct-widget';
const REFRESH_SEC = 30;
const POS_KEY_X = 'tct_pos_x';
const POS_KEY_Y = 'tct_pos_y';
let apiKey = IS_PDA ? '###PDA-APIKEY###' : (GM_getValue ? GM_getValue(STORAGE_KEY, '') : '');
let userData = null;
let tickTimer = null;
let refreshTimer = null;
let collapsed = GM_getValue ? (GM_getValue('tct_collapsed', false)) : false;
// ── Utilities ──────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
function saveKey(k) { if (!IS_PDA && GM_setValue) GM_setValue(STORAGE_KEY, k); }
function saveCollapsed(v) { if (GM_setValue) GM_setValue('tct_collapsed', v); }
function savePos(x, y) {
if (GM_setValue) { GM_setValue(POS_KEY_X, x); GM_setValue(POS_KEY_Y, y); }
}
function loadPos() {
const x = GM_getValue ? GM_getValue(POS_KEY_X, null) : null;
const y = GM_getValue ? GM_getValue(POS_KEY_Y, null) : null;
return (x !== null && y !== null) ? { x, y } : null;
}
function fmtCountdown(secs) {
if (!secs || secs <= 0) return null;
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (h > 0) return `${h}h ${m.toString().padStart(2,'0')}m`;
if (m > 0) return `${m}m ${s.toString().padStart(2,'0')}s`;
return `${s}s`;
}
function readyAt(secs) {
if (!secs || secs <= 0) return null;
const d = new Date(Date.now() + secs * 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function ticksToFull(current, max, tickAmt, tickSecs) {
if (!tickAmt || !tickSecs || current >= max) return 0;
const needed = max - current;
const ticks = Math.ceil(needed / tickAmt);
return ticks * tickSecs;
}
function fmtToFull(secs) {
if (secs <= 0) return 'Full';
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
if (h > 0) return `${h}h ${m}m to full`;
if (m > 0) return `${m}m to full`;
return `<1m to full`;
}
// ── CSS ────────────────────────────────────────────────────────────────────
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
#${WIDGET_ID} {
position: fixed;
bottom: 80px;
right: 24px;
z-index: 99996;
width: 220px;
background: #0d0d0d;
border: 1px solid #1e1e1e;
border-radius: 10px;
font-family: 'Inter', system-ui, sans-serif;
font-size: 12px;
color: #c0c0c0;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
#tct-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
background: #0a0a0a;
border-bottom: 1px solid #1a1a1a;
cursor: grab;
user-select: none;
}
#tct-header:active { cursor: grabbing; }
#tct-widget { box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
#tct-widget.dragging { box-shadow: none; }
#tct-header.clicking { cursor: pointer; }
.tct-grip {
display: flex; flex-direction: column; gap: 2px;
flex-shrink: 0; margin-right: 7px; opacity: 0.25;
transition: opacity 0.15s;
}
#tct-header:hover .tct-grip { opacity: 0.5; }
.tct-grip span {
display: block; width: 14px; height: 1.5px;
background: #c8c8c8; border-radius: 1px;
}
#tct-header-left {
display: flex;
align-items: center;
gap: 7px;
}
.tct-wordmark {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #c8963e;
}
.tct-player-name {
font-size: 10px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 90px;
}
#tct-chevron {
font-size: 9px;
color: #333;
transition: transform 0.2s;
flex-shrink: 0;
}
#tct-chevron.up { transform: rotate(180deg); }
#tct-body { padding: 10px 12px; }
#tct-body.hidden { display: none; }
/* ── Section labels ── */
.tct-section-label {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #2a2a2a;
margin: 10px 0 6px;
}
.tct-section-label:first-child { margin-top: 0; }
/* ── Cooldown rows ── */
.tct-cd-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid #111;
}
.tct-cd-row:last-child { border-bottom: none; }
.tct-cd-left {
display: flex;
align-items: center;
gap: 7px;
}
.tct-cd-icon {
width: 22px;
height: 22px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.tct-cd-icon.drug { background: #1a1230; }
.tct-cd-icon.booster { background: #1a1508; }
.tct-cd-icon.medical { background: #0c1e18; }
.tct-cd-name {
font-size: 11px;
color: #888;
font-weight: 500;
}
.tct-cd-right { text-align: right; }
.tct-cd-timer {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
}
.tct-cd-timer.active { color: #c0622a; }
.tct-cd-timer.ready { color: #4d9e6e; animation: tct-pulse 2s ease-in-out infinite; }
.tct-cd-timer.partial { color: #c8963e; }
@keyframes tct-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.tct-cd-sub {
font-size: 9px;
color: #2e2e2e;
margin-top: 1px;
font-family: 'JetBrains Mono', monospace;
}
/* ── Bar rows ── */
.tct-bar-row {
padding: 5px 0;
border-bottom: 1px solid #111;
}
.tct-bar-row:last-child { border-bottom: none; }
.tct-bar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 5px;
}
.tct-bar-left {
display: flex;
align-items: center;
gap: 5px;
}
.tct-bar-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.tct-bar-name {
font-size: 11px;
color: #666;
font-weight: 500;
}
.tct-bar-val {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: #999;
font-weight: 500;
}
.tct-bar-val .tct-max { color: #333; font-size: 10px; }
.tct-bar-track {
height: 3px;
background: #1a1a1a;
border-radius: 2px;
overflow: hidden;
}
.tct-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s ease;
}
.tct-bar-sub {
font-size: 9px;
color: #666;
margin-top: 3px;
font-family: 'JetBrains Mono', monospace;
}
/* ── Setup ── */
#tct-setup {
padding: 14px 12px;
text-align: center;
}
#tct-setup p {
font-size: 11px;
color: #333;
line-height: 1.5;
margin: 0 0 10px;
}
#tct-key-input {
width: 100%;
background: #141414;
border: 1px solid #222;
border-radius: 6px;
color: #c0c0c0;
padding: 7px 10px;
font-size: 11px;
font-family: 'Inter', sans-serif;
text-align: center;
letter-spacing: 0.06em;
box-sizing: border-box;
margin-bottom: 8px;
}
#tct-key-input:focus { outline: none; border-color: #c8963e; }
#tct-connect-btn {
width: 100%;
padding: 7px;
border-radius: 6px;
font-size: 11px;
font-family: 'Inter', sans-serif;
font-weight: 600;
cursor: pointer;
background: #c8963e;
color: #0a0a0a;
border: none;
letter-spacing: 0.04em;
}
#tct-connect-btn:hover { opacity: 0.88; }
/* ── Footer ── */
#tct-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-top: 1px solid #141414;
background: #0a0a0a;
}
.tct-reset-btn {
font-size: 9px;
color: #222;
cursor: pointer;
background: none;
border: none;
font-family: 'Inter', sans-serif;
padding: 0;
letter-spacing: 0.03em;
}
.tct-reset-btn:hover { color: #c8963e; }
/* ── Loading / error ── */
.tct-status-msg {
padding: 14px 12px;
font-size: 11px;
color: #333;
text-align: center;
line-height: 1.5;
}
.tct-status-msg.error { color: #804040; }
.tct-retry {
display: inline-block;
margin-top: 8px;
font-size: 10px;
color: #c8963e;
cursor: pointer;
background: none;
border: none;
font-family: 'Inter', sans-serif;
padding: 0;
}
`;
// ── API ────────────────────────────────────────────────────────────────────
function apiCall(cb) {
const url = `https://api.torn.com/user/?selections=cooldowns,bars&key=${apiKey}&comment=TCT`;
if (IS_PDA) {
try {
PDA_httpGet(url, r => {
try {
const d = JSON.parse(r);
if (d.error) return cb(null, d.error.error || 'API error');
cb(d, null);
} catch(e) { cb(null, 'Parse error'); }
});
} catch(e) { cb(null, 'PDA_httpGet failed'); }
} else {
GM_xmlhttpRequest({
method: 'GET', url,
onload(r) {
try {
const d = JSON.parse(r.responseText);
if (d.error) return cb(null, d.error.error || 'API error');
cb(d, null);
} catch(e) { cb(null, 'Parse error'); }
},
onerror() { cb(null, 'Network error'); }
});
}
}
// ── Tick: count down live without API calls ────────────────────────────────
function startTick() {
stopTick();
let lastRender = Date.now();
tickTimer = setInterval(() => {
if (!userData) return;
const elapsed = Math.round((Date.now() - lastRender) / 1000);
lastRender = Date.now();
// Decrement cooldowns
const cd = userData.cooldowns;
if (cd.drug > 0) cd.drug = Math.max(0, cd.drug - elapsed);
if (cd.booster > 0) cd.booster = Math.max(0, cd.booster - elapsed);
if (cd.medical > 0) cd.medical = Math.max(0, cd.medical - elapsed);
// Increment bars — bars are top-level on userData (not nested under .bars)
['energy','nerve','happy','life'].forEach(k => {
const b = userData[k];
if (b && b.current < b.maximum && b.increment && b.interval) {
b.current = Math.min(b.maximum, b.current + (b.increment * elapsed / b.interval));
}
});
renderBody();
}, 1000);
}
function stopTick() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } }
// ── Silent background refresh ──────────────────────────────────────────────
function startRefreshCycle() {
stopRefreshCycle();
refreshTimer = setInterval(() => {
// Silently fetch new data and update state without touching the DOM
const url = `https://api.torn.com/user/?selections=cooldowns,bars&key=${apiKey}&comment=TCT`;
const doFetch = IS_PDA
? cb => { try { PDA_httpGet(url, r => { try { cb(JSON.parse(r)); } catch(e){} }); } catch(e){} }
: cb => { GM_xmlhttpRequest({ method:'GET', url, onload(r){ try { cb(JSON.parse(r.responseText)); } catch(e){} } }); };
doFetch(d => {
if (!d || d.error) return;
// Update cooldowns
if (d.cooldowns) userData.cooldowns = d.cooldowns;
// Update bars preserving the live-ticked current values only if API value differs significantly
['energy','nerve','happy','life'].forEach(k => {
if (d[k] && userData[k]) {
userData[k].maximum = d[k].maximum;
userData[k].increment = d[k].increment;
userData[k].interval = d[k].interval;
userData[k].fulltime = d[k].fulltime;
// Only snap current if API differs by more than 1 tick (avoids jump from network delay)
const diff = Math.abs(d[k].current - Math.floor(userData[k].current));
if (diff > d[k].increment * 2) userData[k].current = d[k].current;
} else if (d[k]) {
userData[k] = d[k];
}
});
});
}, REFRESH_SEC * 1000);
}
function stopRefreshCycle() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
// ── Fetch ──────────────────────────────────────────────────────────────────
function fetchData() {
setBody(`<div class="tct-status-msg">Updating…</div>`);
apiCall((d, err) => {
if (err) {
setBody(`<div class="tct-status-msg error">${err}<br><button class="tct-retry" id="tct-retry-btn">Retry</button></div>`);
const rb = $('tct-retry-btn');
if (rb) rb.onclick = fetchData;
return;
}
userData = d;
renderBody();
startTick();
startRefreshCycle();
});
}
// ── Render ─────────────────────────────────────────────────────────────────
function setBody(html) {
const body = $('tct-body');
if (body) body.innerHTML = html;
}
function cdRow(type, label, emoji, secs, maxSecs) {
const active = secs > 0;
const pct = maxSecs ? Math.min(100, Math.round((secs / maxSecs) * 100)) : 0;
let timerText, timerClass, sub;
if (!active) {
timerText = 'Ready';
timerClass = 'ready';
sub = '';
} else if (maxSecs && secs > maxSecs) {
// Over cap — locked out
timerText = fmtCountdown(secs - maxSecs) + ' locked';
timerClass = 'active';
sub = `cap in ${fmtCountdown(secs - maxSecs)}`;
} else {
timerText = fmtCountdown(secs);
timerClass = secs > maxSecs * 0.5 ? 'active' : 'partial';
sub = readyAt(secs) ? `ready at ${readyAt(secs)}` : '';
}
return `
<div class="tct-cd-row">
<div class="tct-cd-left">
<div class="tct-cd-icon ${type}">${emoji}</div>
<span class="tct-cd-name">${label}</span>
</div>
<div class="tct-cd-right">
<div class="tct-cd-timer ${timerClass}">${timerText}</div>
${sub ? `<div class="tct-cd-sub">${sub}</div>` : ''}
</div>
</div>`;
}
function barRow(key, label, color, bar) {
if (!bar) return '';
const cur = Math.floor(bar.current);
const max = bar.maximum;
const pct = max > 0 ? Math.min(100, Math.round((cur / max) * 100)) : 0;
// API fields: increment = amount per tick, interval = seconds per tick, fulltime = secs to full
const secsToFull = cur < max && bar.fulltime ? bar.fulltime : 0;
const sub = cur >= max ? 'Full' : fmtToFull(secsToFull);
return `
<div class="tct-bar-row">
<div class="tct-bar-header">
<div class="tct-bar-left">
<div class="tct-bar-dot" style="background:${color}"></div>
<span class="tct-bar-name">${label}</span>
</div>
<span class="tct-bar-val">${cur}<span class="tct-max">/${max}</span></span>
</div>
<div class="tct-bar-track">
<div class="tct-bar-fill" style="width:${pct}%;background:${color}"></div>
</div>
<div class="tct-bar-sub">${sub}</div>
</div>`;
}
function renderBody() {
if (!userData) return;
const cd = userData.cooldowns || {};
// bars are top-level keys on userData
// Drug cap is 0 (must hit 0 to use again), booster cap 24h, medical cap 6h
const BOOSTER_CAP = 24 * 3600;
const MEDICAL_CAP = 6 * 3600;
const html = `
<div class="tct-section-label">Cooldowns</div>
${cdRow('drug', 'Drug', '💊', cd.drug || 0, 0)}
${cdRow('booster', 'Booster', '⚡', cd.booster || 0, BOOSTER_CAP)}
${cdRow('medical', 'Medical', '🩹', cd.medical || 0, MEDICAL_CAP)}
<div class="tct-section-label">Bars</div>
${barRow('energy', 'Energy', '#4d9e6e', userData.energy)}
${barRow('nerve', 'Nerve', '#c0622a', userData.nerve)}
${barRow('happy', 'Happy', '#c8963e', userData.happy)}
${barRow('life', 'Life', '#7855be', userData.life)}
`;
setBody(html);
}
// ── Widget shell ───────────────────────────────────────────────────────────
function buildWidget() {
const w = document.createElement('div');
w.id = WIDGET_ID;
w.innerHTML = `
<div id="tct-header">
<div id="tct-header-left">
<div class="tct-grip" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<span class="tct-wordmark">Cooldowns</span>
<span class="tct-player-name" id="tct-player-name"></span>
</div>
<span id="tct-chevron">${collapsed ? '▲' : '▼'}</span>
</div>
<div id="tct-body" class="${collapsed ? 'hidden' : ''}">
${!apiKey || apiKey === '###PDA-APIKEY###' && !IS_PDA
? setupHTML()
: '<div class="tct-status-msg">Loading…</div>'}
</div>
<div id="tct-footer">
${!IS_PDA ? `<button class="tct-reset-btn" id="tct-reset">Change key</button>` : ''}
</div>
`;
document.body.appendChild(w);
// Apply saved position
const savedPos = loadPos();
if (savedPos) {
w.style.right = 'auto';
w.style.bottom = 'auto';
w.style.left = savedPos.x + 'px';
w.style.top = savedPos.y + 'px';
}
// Drag logic
let isDragging = false;
let dragStartX, dragStartY;
let dragMoved = false;
const header = $('tct-header');
header.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
isDragging = true;
dragMoved = false;
const rect = w.getBoundingClientRect();
dragStartX = e.clientX - rect.left;
dragStartY = e.clientY - rect.top;
w.style.right = 'auto';
w.style.bottom = 'auto';
w.style.left = rect.left + 'px';
w.style.top = rect.top + 'px';
header.setPointerCapture(e.pointerId);
w.classList.add('dragging');
header.style.cursor = 'grabbing';
e.preventDefault();
});
header.addEventListener('pointermove', e => {
if (!isDragging) return;
dragMoved = true;
const newX = Math.max(0, Math.min(window.innerWidth - w.offsetWidth, e.clientX - dragStartX));
const newY = Math.max(0, Math.min(window.innerHeight - w.offsetHeight, e.clientY - dragStartY));
w.style.left = newX + 'px';
w.style.top = newY + 'px';
});
header.addEventListener('pointerup', e => {
if (!isDragging) return;
isDragging = false;
header.style.cursor = '';
header.releasePointerCapture(e.pointerId);
w.classList.remove('dragging');
if (dragMoved) {
const rect = w.getBoundingClientRect();
savePos(Math.round(rect.left), Math.round(rect.top));
}
});
// Header toggle — only fires if we didn't drag
$('tct-header').onclick = () => {
if (dragMoved) return;
collapsed = !collapsed;
saveCollapsed(collapsed);
$('tct-body').classList.toggle('hidden', collapsed);
$('tct-chevron').classList.toggle('up', !collapsed);
};
$('tct-chevron').classList.toggle('up', !collapsed);
if (!IS_PDA) {
const rb = $('tct-reset');
if (rb) rb.onclick = e => {
e.stopPropagation();
apiKey = ''; saveKey('');
userData = null; stopTick(); stopRefreshCycle();
setBody(setupHTML());
bindSetup();
};
}
if (apiKey && !(apiKey === '###PDA-APIKEY###' && !IS_PDA)) {
fetchData();
} else if (!IS_PDA) {
bindSetup();
} else {
// PDA with injected key
fetchData();
}
}
function setupHTML() {
return `
<div id="tct-setup">
<p>Enter your Torn API key.<br>Requires Minimal Access.</p>
<input id="tct-key-input" type="text" placeholder="API key" maxlength="16" autocomplete="off" spellcheck="false"/>
<button id="tct-connect-btn">Connect</button>
</div>`;
}
function bindSetup() {
const input = $('tct-key-input');
const btn = $('tct-connect-btn');
if (!input || !btn) return;
btn.onclick = () => {
const v = input.value.trim();
if (v.length !== 16) { input.style.borderColor = '#c0622a'; return; }
apiKey = v; saveKey(v);
setBody('<div class="tct-status-msg">Connecting…</div>');
fetchData();
};
input.onkeydown = e => { if (e.key === 'Enter') btn.click(); };
}
function updatePlayerName() {
if (!userData) return;
const el = $('tct-player-name');
if (el && userData.name) el.textContent = userData.name;
}
// Patch fetchData to update player name after load
const _fetchData = fetchData;
// ── Init ───────────────────────────────────────────────────────────────────
function injectStyles() {
if ($('tct-styles')) return;
const s = document.createElement('style');
s.id = 'tct-styles';
s.textContent = CSS;
document.head.appendChild(s);
}
function init() {
if ($(WIDGET_ID)) return;
injectStyles();
buildWidget();
}
// Patch apiCall success to update name
const origApiCall = apiCall;
function apiCallWithName(cb) {
origApiCall((d, err) => {
if (d && d.name) {
const el = $('tct-player-name');
if (el) el.textContent = d.name;
}
cb(d, err);
});
}
// Override fetchData to use named version
function fetchData() {
setBody(`<div class="tct-status-msg">Updating…</div>`);
apiCallWithName((d, err) => {
if (err) {
setBody(`<div class="tct-status-msg error">${err}<br><button class="tct-retry" id="tct-retry-btn">Retry</button></div>`);
const rb = $('tct-retry-btn');
if (rb) rb.onclick = fetchData;
return;
}
userData = d;
renderBody();
startTick();
startRefreshCycle();
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();