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.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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