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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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