Shows real gym gain numbers instead of dot-bars, and flags whether you're training in the best gym you've unlocked for each stat.
// ==UserScript==
// @name Torn Gym Optimizer
// @namespace StrLi.torn.gymoptimizer
// @version 1.0.0
// @description Shows real gym gain numbers instead of dot-bars, and flags whether you're training in the best gym you've unlocked for each stat.
// @author StrLi
// @license MIT
// @match https://www.torn.com/gym.php*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @run-at document-idle
// ==/UserScript==
(function () {
console.log('%c[TGO] script loaded and executing', 'color:#8fd3ff;font-weight:bold;');
'use strict';
// ---------------------------------------------------------------------
// CONFIG
// ---------------------------------------------------------------------
const CACHE_HOURS = 24; // torn gym data is static, no need to refetch every load
const STATS = ['str', 'spe', 'def', 'dex'];
const STAT_LABEL = { str: 'Strength', spe: 'Speed', def: 'Defense', dex: 'Dexterity' };
const STAT_API_KEY = { str: 'strength', spe: 'speed', def: 'defense', dex: 'dexterity' };
// ---------------------------------------------------------------------
// API KEY (asks once, stores locally via GM storage)
// ---------------------------------------------------------------------
function getApiKey() {
let key = GM_getValue('tgo_api_key', '');
if (!key) {
key = prompt('Torn Gym Optimizer: paste a Torn API key (Public/Minimal is enough):');
if (key) GM_setValue('tgo_api_key', key.trim());
}
return key;
}
function apiGet(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data.error) reject(data.error);
else resolve(data);
} catch (e) {
reject(e);
}
},
onerror: reject,
});
});
}
// ---------------------------------------------------------------------
// DATA FETCH (cached)
// ---------------------------------------------------------------------
async function getGymData(key) {
const cached = GM_getValue('tgo_gym_cache', null);
const now = Date.now();
if (cached && now - cached.ts < CACHE_HOURS * 3600 * 1000) {
return cached.gyms;
}
const data = await apiGet(`https://api.torn.com/torn/?selections=gyms&key=${key}`);
const gyms = data.gyms; // { "1": { name, stage, cost, energy, strength, speed, defense, dexterity, note }, ... }
GM_setValue('tgo_gym_cache', { ts: now, gyms });
return gyms;
}
// Single call per page load — no caching layer needed here since this
// is only ever called once, right when the page opens/reloads.
async function getUserGym(key) {
const data = await apiGet(`https://api.torn.com/user/?selections=gym&key=${key}`);
return data.active_gym; // numeric gym id
}
function gymDots(gym, stat) {
const raw = gym[STAT_API_KEY[stat]];
if (raw === undefined || raw === null || raw === '-' || raw === 0) return 0;
return Number(raw) / 10; // API stores dots x10 (Premier Fitness Str = 20 -> 2.0)
}
function normalizeName(s) {
return s.toLowerCase().replace(/[^a-z0-9]/g, '');
}
const ROW_SELECTOR = 'button[class*="gymButton___"]';
function isRowUnlocked(rowEl) {
const cls = rowEl.className || '';
if (/(^|\s)locked___/.test(cls)) return false;
if (/(^|\s)inProgress___/.test(cls)) return false; // still building toward unlock
return true; // "active___" or plain gymButton class = unlocked
}
function getRowName(rowEl) {
const label = rowEl.getAttribute('aria-label') || '';
const m = label.match(/^(.+?)\.\s*Membership cost/);
return m ? m[1].trim() : '';
}
function findRowForGym(gymName) {
const rows = document.querySelectorAll(ROW_SELECTOR);
const target = normalizeName(gymName);
for (const row of rows) {
if (normalizeName(getRowName(row)) === target) return row;
}
return null;
}
window.tgoDebugRows = function () {
document.querySelectorAll(ROW_SELECTOR).forEach((r, i) => {
console.log(i, isRowUnlocked(r) ? 'UNLOCKED' : 'locked', getRowName(r), r.className);
});
};
// ---------------------------------------------------------------------
// wait for the React-rendered gym button list to actually exist
// before scanning it.
// ---------------------------------------------------------------------
function waitForGymButtons(timeoutMs = 8000) {
return new Promise((resolve, reject) => {
const existing = document.querySelectorAll(ROW_SELECTOR);
if (existing.length > 0) {
resolve(existing);
return;
}
const observer = new MutationObserver(() => {
const rows = document.querySelectorAll(ROW_SELECTOR);
if (rows.length > 0) {
observer.disconnect();
clearTimeout(timer);
resolve(rows);
}
});
observer.observe(document.body, { childList: true, subtree: true });
const timer = setTimeout(() => {
observer.disconnect();
const rows = document.querySelectorAll(ROW_SELECTOR);
if (rows.length > 0) resolve(rows);
else reject(new Error('Torn Gym Optimizer: timed out waiting for gym buttons to render'));
}, timeoutMs);
});
}
// ---------------------------------------------------------------------
// RENDER: numeric overlay on the gym-detail flyout's stat bars
// ---------------------------------------------------------------------
const PANEL_SELECTOR = '[class*="gymDetails___"]';
function enhanceStatBars(root) {
root.querySelectorAll(`${PANEL_SELECTOR} [class*="progressBar___"][aria-label]`).forEach((bar) => {
const label = bar.getAttribute('aria-label') || '';
const m = label.match(/([\d.]+)\s*out of\s*([\d.]+)/i);
if (!m) return;
const nameEl = bar.querySelector('[class*="statName___"]');
if (!nameEl || nameEl.querySelector('.tgo-num')) return; // already enhanced
const numEl = document.createElement('span');
numEl.className = 'tgo-num';
numEl.style.cssText = 'margin-left:8px;color:#8fd3ff;font-weight:bold;';
numEl.textContent = `(${m[1]}/${m[2]})`;
nameEl.appendChild(numEl);
const exerciseEl = bar.querySelector('[class*="exerciseName___"]');
if (exerciseEl && !exerciseEl.querySelector('.tgo-ex')) {
const tag = document.createElement('span');
tag.className = 'tgo-ex';
tag.style.cssText = 'margin-left:6px;color:#888;font-style:italic;';
tag.textContent = `· ${exerciseEl.textContent.trim()}`;
if (!exerciseEl.nextSibling || !exerciseEl.nextSibling.classList || !exerciseEl.nextSibling.classList.contains('tgo-ex')) {
exerciseEl.after(tag);
}
}
});
}
function watchStatPanels() {
enhanceStatBars(document); // in case one is already open
const observer = new MutationObserver((mutations) => {
for (const mut of mutations) {
if (mut.addedNodes.length) {
enhanceStatBars(document);
break;
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// ---------------------------------------------------------------------
// Banner color scales with how many of the 4 stats are already
// optimal — not just all-or-nothing.
// ---------------------------------------------------------------------
const BANNER_COLORS = [
{ bg: '#3a1717', fg: '#f0a0a0' }, // 0 optimized — red
{ bg: '#3a3417', fg: '#e0d08a' }, // 1 optimized — yellow
{ bg: '#2a3a1f', fg: '#c3e0a0' }, // 2 optimized — light green
{ bg: '#20351f', fg: '#a8d69a' }, // 3 optimized — lighter green
{ bg: '#173a1e', fg: '#8ee0a0' }, // 4 optimized — full green
];
function buildBanner(text, optimizedCount, totalCount) {
let el = document.getElementById('tgo-banner');
if (!el) {
el = document.createElement('div');
el.id = 'tgo-banner';
el.style.cssText =
'padding:10px 14px;margin:8px 0;border-radius:4px;font-size:13px;line-height:1.5;';
const anchor = document.querySelector(ROW_SELECTOR) || document.body;
anchor.parentElement.insertBefore(el, anchor);
}
const idx = Math.max(0, Math.min(BANNER_COLORS.length - 1, optimizedCount));
const { bg, fg } = BANNER_COLORS[idx];
el.style.background = bg;
el.style.color = fg;
el.innerHTML = text;
}
// ---------------------------------------------------------------------
// RENDER RECOMMENDATION
// ---------------------------------------------------------------------
async function renderRecommendation(gyms, activeGymId) {
try {
await waitForGymButtons();
} catch (e) {
console.error(e.message);
return;
}
const unlocked = []; // [{id, gym}]
Object.entries(gyms).forEach(([id, gym]) => {
const row = findRowForGym(gym.name);
if (!row) return; // gym not shown on page / not this weight tier
if (isRowUnlocked(row)) unlocked.push({ id: Number(id), gym });
});
if (unlocked.length === 0) {
console.warn('Torn Gym Optimizer: no unlocked gyms detected — run tgoDebugRows() and check ROW_SELECTOR / isRowUnlocked().');
return;
}
const activeGym = gyms[activeGymId];
const lines = [];
let optimizedCount = 0;
STATS.forEach((stat) => {
let best = unlocked[0];
unlocked.forEach((u) => {
if (gymDots(u.gym, stat) > gymDots(best.gym, stat)) best = u;
});
const bestVal = gymDots(best.gym, stat);
const curVal = activeGym ? gymDots(activeGym, stat) : 0;
if (bestVal <= 0) {
optimizedCount++;
return;
}
if (best.id !== Number(activeGymId) && bestVal > curVal) {
lines.push(
`${STAT_LABEL[stat]}: best unlocked gym is <b>${best.gym.name}</b> ` +
`(${bestVal.toFixed(1)} dots, ${best.gym.energy}e/train) vs current ` +
`<b>${activeGym ? activeGym.name : '?'}</b> (${curVal.toFixed(1)} dots, ${activeGym ? activeGym.energy : '?'}e/train)`
);
} else {
optimizedCount++;
lines.push(`${STAT_LABEL[stat]}: ✅ already optimal (${activeGym ? activeGym.name : '?'})`);
}
});
buildBanner(`<b>Gym Optimizer</b><br>${lines.join('<br>')}`, optimizedCount, STATS.length);
}
// ---------------------------------------------------------------------
// MAIN — runs once per page load/reload. One gym-data fetch (cached
// 24h) + one user/gym fetch, then render. No background polling: if
// you switch gyms, just reload the page (or open it again) and it'll
// pick up the new active gym on that single fresh call.
// ---------------------------------------------------------------------
async function main() {
const key = getApiKey();
if (!key) return;
let gyms, activeGymId;
try {
[gyms, activeGymId] = await Promise.all([getGymData(key), getUserGym(key)]);
} catch (e) {
console.error('Torn Gym Optimizer: API error', e);
return;
}
try {
await waitForGymButtons();
} catch (e) {
console.error(e.message);
return;
}
await renderRecommendation(gyms, activeGymId);
}
watchStatPanels(); // numeric overlay — independent of API, runs immediately
main(); // recommendation banner — one API call per page load, no polling
})();