Torn Gym Optimizer

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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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
})();