GymIQ

Live color-coded ratio panel for Torn battle stats. Supports Baldr's, Hank's, and custom ratios. Includes training recommendations, progress bars, what-if calculator, history tracking, ratio drift alerts, faction war mode, export, and light/dark theme.

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 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.

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

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         GymIQ
// @namespace    torn-ratio-helper
// @version      4.3.0
// @description  Live color-coded ratio panel for Torn battle stats. Supports Baldr's, Hank's, and custom ratios. Includes training recommendations, progress bars, what-if calculator, history tracking, ratio drift alerts, faction war mode, export, and light/dark theme.
// @author       ClasixTV
// @match        https://www.torn.com/gym.php*
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ─── PERSISTENT STATE ─────────────────────────────────────────────────────
  let selectedRatio  = GM_getValue('trh_ratio', 'baldr');
  let highStat       = GM_getValue('trh_high', 'str');
  let customMults    = GM_getValue('trh_custom', { high: 1.00, secondary: 0.80, tert1: 0.60, tert2: 0.60 });
  let history        = GM_getValue('trh_history', []);      // [{ts, str, spd, def, dex, energy}]
  let theme          = GM_getValue('trh_theme', 'dark');    // 'dark' | 'light'
  let warMode        = GM_getValue('trh_war', false);       // faction war mode toggle
  let passiveGains   = GM_getValue('trh_passive', { str: 0, spd: 0, def: 0, dex: 0 }); // daily passive gains
  let goalTbs        = GM_getValue('trh_goal_tbs', 0);      // target TBS for planner
  let activeTab      = 'overview';

  // Session-only (not persisted): energy logged this session
  let sessionEnergyStart = null;
  let sessionStatsStart  = null;

  // ─── RATIO DEFINITIONS ────────────────────────────────────────────────────
  const RATIOS = {
    baldr: {
      label: "Baldr's Ratio",
      short: "Baldr's",
      desc:  "High : 80% : 60% : 60%",
      multipliers: { high: 1.00, secondary: 0.80, tert1: 0.60, tert2: 0.60 },
    },
    hank: {
      label: "Hank's Ratio",
      short: "Hank's",
      desc:  "High : 80% : 80% : ~0%",
      multipliers: { high: 1.00, secondary: 0.80, tert1: 0.80, tert2: 0.00 },
    },
    custom: {
      label: "Custom Ratio",
      short: "Custom",
      desc:  "Your own multipliers",
      get multipliers() { return customMults; },
    },
  };

  const ROLES = {
    // Order: high (1st), secondary (2nd), tert1 (3rd), tert2 (4th/dump)
    // For offensive builds: Str/Spd pair together, Def/Dex pair together
    str: { high: 'str', secondary: 'spd', tert1: 'def', tert2: 'dex' },
    spd: { high: 'spd', secondary: 'str', tert1: 'def', tert2: 'dex' },
    def: { high: 'def', secondary: 'dex', tert1: 'str', tert2: 'spd' },
    dex: { high: 'dex', secondary: 'def', tert1: 'str', tert2: 'spd' },
  };

  const STAT_LABELS  = { str: 'Strength', spd: 'Speed', def: 'Defense', dex: 'Dexterity' };
  const STAT_ICONS   = { str: '💪', spd: '⚡', def: '🛡️', dex: '🎯' };
  const STAT_COLORS  = { str: '#f97316', spd: '#22d3ee', def: '#a78bfa', dex: '#4ade80' };

  // ─── HELPERS ──────────────────────────────────────────────────────────────
  function parseStatValue(text) {
    if (!text) return null;
    const cleaned = text.replace(/,/g, '').match(/[\d.]+/);
    return cleaned ? parseFloat(cleaned[0]) : null;
  }

  function fmtNum(n) {
    if (n === null || n === undefined || isNaN(n)) return '???';
    return Math.round(n).toLocaleString();
  }

  function fmtDate(ts) {
    const d = new Date(ts);
    return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  }

  function readStatsFromPage() {
    const stats = { str: null, spd: null, def: null, dex: null };

    // Torn's gym page structure:
    // <div class="propertyTitle___XXXX">
    //   <h3 class="title___XXXX">Strength</h3>
    //   <span class="propertyValue___XXXX">2,049.27</span>
    // </div>
    // Class suffixes are randomised so we match on partial names

    const statNames = {
      str: 'strength',
      spd: 'speed',
      def: 'defense',
      dex: 'dexterity',
    };

    // Find all property title blocks and match by the h3 text
    const titleBlocks = document.querySelectorAll('[class*="propertyTitle"]');
    for (const block of titleBlocks) {
      const h3 = block.querySelector('h3');
      if (!h3) continue;
      const label = h3.textContent.trim().toLowerCase();
      const valueEl = block.querySelector('[class*="propertyValue"]');
      if (!valueEl) continue;
      const v = parseStatValue(valueEl.textContent);
      if (v === null || v < 100) continue;
      for (const [key, name] of Object.entries(statNames)) {
        if (label === name) { stats[key] = v; break; }
      }
    }

    // Fall back to scanning page text if any stats are still missing
    if (Object.values(stats).some(v => v === null)) {
      const patterns = {
        str: /\bstrength\b[\s\S]{0,40}?\b([\d,]{3,})\b/i,
        spd: /\bspeed\b[\s\S]{0,40}?\b([\d,]{3,})\b/i,
        def: /\bdefense\b[\s\S]{0,40}?\b([\d,]{3,})\b/i,
        dex: /\bdexterity\b[\s\S]{0,40}?\b([\d,]{3,})\b/i,
      };

      // Search gym area only, not the whole page, to avoid grabbing unrelated numbers
      const gymArea = document.querySelector(
        '.gym-content, #gym-root, [class*="gym"], [class*="stats"], main'
      );
      const searchText = gymArea ? gymArea.innerText : document.body.innerText;

      for (const [key, rx] of Object.entries(patterns)) {
        if (stats[key] !== null) continue;
        const matches = [...searchText.matchAll(new RegExp(rx.source, 'gi'))];
        // Take the largest number found near the label — that'll be the real stat
        let best = null;
        for (const m of matches) {
          const v = parseStatValue(m[1]);
          if (v !== null && v >= 100 && (best === null || v > best)) best = v;
        }
        if (best !== null) stats[key] = best;
      }
    }

    // If one stat looks tiny compared to the others it's a bad read — clear it
    const validStats = Object.values(stats).filter(v => v !== null);
    if (validStats.length >= 2) {
      const maxStat = Math.max(...validStats);
      for (const key of Object.keys(stats)) {
        if (stats[key] !== null && maxStat > 10000 && stats[key] < maxStat * 0.001) {
          stats[key] = null;
        }
      }
    }

    // If everything adds up to less than 400 it's almost certainly a bad read
    const allPresent = Object.values(stats).every(v => v !== null);
    if (allPresent) {
      const total = Object.values(stats).reduce((a, v) => a + v, 0);
      if (total < 400) return { str: null, spd: null, def: null, dex: null };
    }

    return stats;
  }

  function computeTargets(stats, ratioKey, highStatKey) {
    const ratio  = RATIOS[ratioKey];
    const roles  = ROLES[highStatKey];
    const mults  = ratio.multipliers;
    const highVal = stats[highStatKey] || 0;
    const targets = {};
    for (const [role, statKey] of Object.entries(roles)) {
      targets[statKey] = highVal * mults[role];
    }
    return targets;
  }

  function getStatus(actual, target, isDump) {
    if (isDump) {
      if (actual < 1000)   return { color: '#4ade80', emoji: '✅', grade: 'A', label: 'Great' };
      if (actual < 50000)  return { color: '#facc15', emoji: '⚠️', grade: 'C', label: 'Okay' };
                           return { color: '#f87171', emoji: '❌', grade: 'F', label: 'Too high' };
    }
    if (!target) return { color: '#64748b', emoji: '➖', grade: '-', label: 'N/A' };
    const pct = actual / target;
    if (pct >= 0.98 && pct <= 1.05) return { color: '#4ade80', emoji: '✅', grade: 'A+', label: 'Perfect' };
    if (pct >= 0.95 && pct < 0.98)  return { color: '#86efac', emoji: '✅', grade: 'A',  label: 'On target' };
    if (pct >= 0.90 && pct < 0.95)  return { color: '#bef264', emoji: '🟡', grade: 'B',  label: 'Close' };
    if (pct >= 0.85 && pct < 0.90)  return { color: '#facc15', emoji: '🟡', grade: 'C',  label: 'Low' };
    if (pct > 1.05 && pct <= 1.15)  return { color: '#fb923c', emoji: '🔶', grade: 'B',  label: 'Slightly over' };
    if (pct > 1.15)                  return { color: '#f87171', emoji: '❌', grade: 'D',  label: 'Way over' };
                                     return { color: '#f87171', emoji: '❌', grade: 'F',  label: 'Far off' };
  }

  function overallGrade(stats, targets, roles, ratioKey) {
    let totalPct = 0; let count = 0;
    for (const [key] of Object.entries(STAT_LABELS)) {
      const isDump = ratioKey === 'hank' && roles.tert2 === key;
      if (isDump || !targets[key]) continue;
      const actual = stats[key] || 0;
      totalPct += Math.min(actual / targets[key], 1.0);
      count++;
    }
    if (!count) return { score: 0, letter: 'F' };
    const score = Math.round((totalPct / count) * 100);
    const letter = score >= 98 ? 'A+' : score >= 93 ? 'A' : score >= 88 ? 'B+' : score >= 83 ? 'B'
                 : score >= 78 ? 'C+' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
    return { score, letter };
  }

  function getTrainRecommendation(stats, targets, roles, ratioKey) {
    // Find the stat furthest below target (by %)
    let worst = null; let worstPct = Infinity;
    for (const [key] of Object.entries(STAT_LABELS)) {
      const isDump = ratioKey === 'hank' && roles.tert2 === key;
      if (isDump || !targets[key]) continue;
      const actual = stats[key] || 0;
      const pct = actual / targets[key];
      if (pct < worstPct) { worstPct = pct; worst = key; }
    }
    return worst;
  }

  function recordHistory(stats) {
    const allFound = Object.values(stats).every(v => v !== null);
    if (!allFound) return;
    const now = Date.now();
    // Don't record more than once per hour
    if (history.length > 0 && now - history[history.length - 1].ts < 3600000) {
      // Update the last entry instead
      history[history.length - 1] = { ts: now, ...stats };
    } else {
      history.push({ ts: now, ...stats });
      if (history.length > 30) history = history.slice(-30); // keep last 30 snapshots
    }
    GM_setValue('trh_history', history);
  }

  // ─── CSS ──────────────────────────────────────────────────────────────────
  const CSS = `
    #trh-root * { box-sizing: border-box; }
    #trh-root {
      font-family: 'Courier New', 'Lucida Console', monospace;
      background: #080c14;
      border: 1px solid #1a2535;
      border-top: 3px solid #00c4ff;
      border-radius: 8px;
      padding: 0;
      margin: 14px 0;
      max-width: 600px;
      box-shadow: 0 0 30px rgba(0,196,255,0.08), 0 4px 20px rgba(0,0,0,0.6);
      color: #c8d8e8;
      overflow: visible;
    }
    #trh-header {
      display: flex; align-items: center; justify-content: space-between;
      padding: 10px 14px;
      background: #0b1220;
      border-bottom: 1px solid #1a2535;
      border-radius: 8px 8px 0 0;
      overflow: hidden;
    }
    #trh-title { font-size: 13px; font-weight: 700; color: #00c4ff; letter-spacing: 2px; text-transform: uppercase; }
    #trh-tabs { display: flex; gap: 1px; background: #0b1220; border-bottom: 1px solid #1a2535; padding-left: 10px; overflow-x: auto; scrollbar-width: none; }
    #trh-tabs::-webkit-scrollbar { display: none; }
    .trh-tab {
      flex: 0 0 auto; padding: 8px 14px; font-size: 11px; font-family: 'Courier New', monospace;
      text-align: center; cursor: pointer; color: #4a6080; background: #080c14;
      border: none; text-transform: uppercase; letter-spacing: 1px;
      transition: all 0.15s; border-bottom: 2px solid transparent; white-space: nowrap;
    }
    .trh-tab:hover { color: #8ab0d0; background: #0d1826; }
    .trh-tab.active { color: #00c4ff; border-bottom: 2px solid #00c4ff; background: #0a1520; }
    #trh-body { padding: 14px; }
    .trh-section { margin-bottom: 14px; }
    .trh-label { font-size: 10px; color: #2a5070; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 6px; }

    /* Stat rows */
    .trh-stat-row { margin-bottom: 10px; }
    .trh-stat-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
    .trh-stat-name { font-size: 12px; font-weight: 700; letter-spacing: 1px; }
    .trh-stat-nums { font-size: 11px; color: #4a6080; }
    .trh-stat-nums span { color: #8ab0d0; }
    .trh-bar-bg { height: 6px; background: #0d1826; border-radius: 3px; overflow: hidden; }
    .trh-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s ease; }
    .trh-stat-status { font-size: 10px; margin-top: 3px; }

    /* Grade badge */
    .trh-grade {
      font-size: 28px; font-weight: 900; line-height: 1;
      font-family: 'Courier New', monospace;
    }
    .trh-score-row { display: flex; align-items: center; gap: 14px; }
    .trh-score-detail { font-size: 11px; color: #4a6080; }
    .trh-score-detail strong { color: #8ab0d0; }

    /* Recommendation box */
    .trh-rec-box {
      background: #0a1828; border: 1px solid #1a3050; border-left: 3px solid #00c4ff;
      border-radius: 4px; padding: 10px 12px; font-size: 12px;
    }
    .trh-rec-box .trh-rec-stat { font-size: 15px; font-weight: 700; margin-bottom: 4px; }
    .trh-rec-box .trh-rec-gym { font-size: 11px; color: #4a7090; margin-top: 6px; }

    /* Controls */
    .trh-ctrl-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
    .trh-btn {
      background: #0d1826; border: 1px solid #1a3050; color: #8ab0d0;
      border-radius: 4px; padding: 5px 10px; font-size: 11px; cursor: pointer;
      font-family: 'Courier New', monospace; letter-spacing: 1px;
      transition: all 0.15s; text-transform: uppercase; white-space: nowrap;
    }
    .trh-btn:hover { background: #1a2e48; border-color: #00c4ff; color: #00c4ff; }
    .trh-btn.active { background: #002a40; border-color: #00c4ff; color: #00c4ff; }
    @media (max-width: 480px) {
      .trh-ctrl-row { flex-direction: column; align-items: stretch; }
      .trh-btn { text-align: center; width: 100%; }
      .trh-whatif-grid, .trh-custom-grid { grid-template-columns: 1fr; }
    }
    .trh-select {
      background: #0d1826; border: 1px solid #1a3050; color: #8ab0d0;
      border-radius: 4px; padding: 5px 8px; font-size: 11px; cursor: pointer;
      font-family: 'Courier New', monospace;
    }

    /* What-if */
    .trh-whatif-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
    .trh-whatif-field { display: flex; flex-direction: column; gap: 3px; }
    .trh-whatif-field label { font-size: 10px; color: #2a5070; letter-spacing: 1px; text-transform: uppercase; }
    .trh-whatif-field input {
      background: #0d1826; border: 1px solid #1a3050; color: #c8d8e8;
      border-radius: 4px; padding: 6px 8px; font-size: 12px; width: 100%;
      font-family: 'Courier New', monospace;
    }
    .trh-whatif-field input:focus { outline: none; border-color: #00c4ff; }

    /* History */
    .trh-history-row { display: flex; gap: 6px; align-items: center; font-size: 11px; padding: 5px 0; border-bottom: 1px solid #0d1826; }
    .trh-history-date { color: #2a5070; width: 60px; flex-shrink: 0; }
    .trh-history-stat { flex: 1; text-align: right; }
    .trh-history-delta { font-size: 10px; }

    /* Custom ratio */
    .trh-custom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
    .trh-custom-field { display: flex; flex-direction: column; gap: 3px; }
    .trh-custom-field label { font-size: 10px; color: #2a5070; letter-spacing: 1px; text-transform: uppercase; }
    .trh-custom-field input {
      background: #0d1826; border: 1px solid #1a3050; color: #c8d8e8;
      border-radius: 4px; padding: 6px 8px; font-size: 12px; width: 100%;
      font-family: 'Courier New', monospace;
    }
    .trh-custom-field input:focus { outline: none; border-color: #00c4ff; }

    .trh-tbs { font-size: 22px; font-weight: 700; color: #00c4ff; letter-spacing: -1px; }
    .trh-tbs-label { font-size: 10px; color: #2a5070; letter-spacing: 2px; text-transform: uppercase; margin-top: 2px; }

    .trh-divider { border: none; border-top: 1px solid #0d1826; margin: 12px 0; }
    .trh-warn { font-size: 11px; color: #facc15; padding: 6px 10px; background: #1a1400; border-radius: 4px; margin-bottom: 10px; }

    .trh-no-history { font-size: 12px; color: #2a5070; text-align: center; padding: 20px; }

    /* War mode badge */
    .trh-war-badge {
      font-size: 10px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase;
      padding: 2px 7px; border-radius: 3px; background: #4a0a0a; color: #f87171;
      border: 1px solid #7a1a1a; animation: trh-pulse 2s infinite;
    }
    @keyframes trh-pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }

    /* Drift alert */
    .trh-drift-alert {
      font-size: 11px; color: #fb923c; padding: 6px 10px;
      background: #1a0e00; border-radius: 4px; margin-bottom: 10px;
      border: 1px solid #4a2800;
    }

    /* Goal planner */
    .trh-planner-stat {
      display: flex; align-items: center; gap: 8px;
      padding: 6px 8px; border-radius: 4px; margin-bottom: 4px;
      background: #0a1828; font-size: 12px;
    }

    /* Energy efficiency */
    .trh-eff-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
    .trh-eff-card {
      background: #0a1828; border: 1px solid #1a3050; border-radius: 4px;
      padding: 8px 10px; text-align: center;
    }
    .trh-eff-val { font-size: 18px; font-weight: 700; color: #00c4ff; }
    .trh-eff-lbl { font-size: 10px; color: #2a5070; letter-spacing: 1px; text-transform: uppercase; margin-top: 2px; }

    /* Export */
    .trh-export-box {
      background: #0a1828; border: 1px solid #1a3050; border-radius: 4px;
      padding: 10px; font-size: 11px; color: #4a6080; font-family: 'Courier New', monospace;
      white-space: pre; overflow-x: auto; margin-top: 8px; line-height: 1.6;
    }

    /* Light theme overrides */
    #trh-root.trh-light {
      background: #f8fafc; border-color: #e2e8f0; border-top-color: #0284c7;
      box-shadow: 0 2px 12px rgba(0,0,0,0.1); color: #1e3a5f;
    }
    #trh-root.trh-light #trh-header { background: #f1f5f9; border-bottom-color: #e2e8f0; }
    #trh-root.trh-light #trh-title { color: #0284c7; }
    #trh-root.trh-light #trh-tabs { background: #f1f5f9; border-bottom-color: #e2e8f0; }
    #trh-root.trh-light .trh-tab { color: #94a3b8; background: #f8fafc; }
    #trh-root.trh-light .trh-tab:hover { color: #0284c7; background: #e0f2fe; }
    #trh-root.trh-light .trh-tab.active { color: #0284c7; border-bottom-color: #0284c7; background: #e0f2fe; }
    #trh-root.trh-light .trh-label { color: #94a3b8; }
    #trh-root.trh-light .trh-divider { border-top-color: #e2e8f0; }
    #trh-root.trh-light .trh-btn { background: #f1f5f9; border-color: #e2e8f0; color: #334155; }
    #trh-root.trh-light .trh-btn:hover { background: #e0f2fe; border-color: #0284c7; color: #0284c7; }
    #trh-root.trh-light .trh-btn.active { background: #bae6fd; border-color: #0284c7; color: #0284c7; }
    #trh-root.trh-light .trh-bar-bg { background: #e2e8f0; }
    #trh-root.trh-light .trh-rec-box { background: #f0f9ff; border-color: #bae6fd; }
    #trh-root.trh-light .trh-score-detail { color: #64748b; }
    #trh-root.trh-light .trh-score-detail strong { color: #1e3a5f; }
    #trh-root.trh-light .trh-stat-nums { color: #64748b; }
    #trh-root.trh-light .trh-planner-stat { background: #f0f9ff; }
    #trh-root.trh-light .trh-eff-card { background: #f0f9ff; border-color: #bae6fd; }
    #trh-root.trh-light .trh-eff-val { color: #0284c7; }
    #trh-root.trh-light .trh-eff-lbl { color: #94a3b8; }
    #trh-root.trh-light .trh-export-box { background: #f1f5f9; border-color: #e2e8f0; color: #64748b; }
    #trh-root.trh-light .trh-tbs { color: #0284c7; }
    #trh-root.trh-light .trh-no-history { color: #94a3b8; }
    #trh-root.trh-light .trh-warn { background: #fefce8; color: #854d0e; }
    #trh-root.trh-light .trh-drift-alert { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
    #trh-root.trh-light .trh-history-date { color: #94a3b8; }
    #trh-root.trh-light .trh-history-row { border-bottom-color: #e2e8f0; }
    #trh-root.trh-light .trh-custom-field input,
    #trh-root.trh-light .trh-whatif-field input {
      background: #f1f5f9; border-color: #e2e8f0; color: #1e3a5f;
    }
    #trh-root.trh-light .trh-custom-field input:focus,
    #trh-root.trh-light .trh-whatif-field input:focus { border-color: #0284c7; }
  `;

  // ─── NEW FEATURE HELPERS ──────────────────────────────────────────────────

  function readEnergyFromPage() {
    const els = document.querySelectorAll('[class*="bar-value"]');
    for (const el of els) {
      const m = el.textContent.replace(/,/g, '').match(/(\d+)\s*\/\s*\d+/);
      if (m) {
        const v = parseInt(m[1]);
        if (!isNaN(v) && v >= 0 && v <= 1000) return v;
      }
    }
    return null;
  }

  // Ratio drift: flag stats that have dropped more than 5% below ratio since last session
  function getDriftWarnings(stats, targets, roles) {
    if (history.length < 2) return [];
    const prev = history[history.length - 2];
    const prevTargets = computeTargets(prev, selectedRatio, highStat);
    const warnings = [];
    for (const [key] of Object.entries(STAT_LABELS)) {
      const isDump = selectedRatio === 'hank' && roles.tert2 === key;
      if (isDump || !targets[key] || !prevTargets[key]) continue;
      const currPct = (stats[key] || 0) / targets[key];
      const prevPct = (prev[key] || 0) / prevTargets[key];
      if (prevPct >= 0.90 && currPct < prevPct - 0.05) {
        warnings.push({ key, currPct, prevPct });
      }
    }
    return warnings;
  }

  // Energy efficiency for this session
  function getEfficiency(currentStats) {
    if (!sessionStatsStart) return null;
    const energyNow = readEnergyFromPage();
    if (energyNow === null || sessionEnergyStart === null) return null;
    const energySpent = sessionEnergyStart - energyNow;
    if (energySpent <= 0) return null;
    let totalGains = 0;
    const gains = {};
    for (const key of Object.keys(STAT_LABELS)) {
      gains[key] = Math.max(0, (currentStats[key] || 0) - (sessionStatsStart[key] || 0));
      totalGains += gains[key];
    }
    const per100 = Math.round((totalGains / energySpent) * 100);
    return { gains, totalGains, energySpent, per100 };
  }

  // Goal planner: given target TBS, compute what each stat should be
  function computeGoalTargets(targetTbs, ratioKey, highStatKey) {
    const roles = ROLES[highStatKey];
    const mults = RATIOS[ratioKey].multipliers;
    const multSum = mults.high + mults.secondary + mults.tert1 + mults.tert2;
    if (multSum === 0) return null;
    const highVal = targetTbs / multSum;
    const result = {};
    for (const [role, statKey] of Object.entries(roles)) {
      result[statKey] = Math.round(highVal * mults[role]);
    }
    return result;
  }

  // Rough train estimate (Torn gains are complex; this is a useful approximation)
  function estimateTrains(currentVal, targetVal, passivePerDay) {
    const deficit = Math.max(0, targetVal - currentVal);
    if (deficit === 0) return 0;
    // George's gym: ~7.3 dots, 10e/train. Rough gain per train scales inversely with stat size.
    const avgStat = Math.max(1000, (currentVal + targetVal) / 2);
    const gainPerTrain = Math.max(1, Math.round(73000000 / avgStat));
    const dailyTrains = 1000 / 10; // 1000 energy cap / 10 energy per train
    const passivePerTrain = passivePerDay > 0 ? passivePerDay / dailyTrains : 0;
    const netPerTrain = Math.max(1, gainPerTrain - passivePerTrain);
    return Math.ceil(deficit / netPerTrain);
  }

  function buildExportText(stats, targets, roles) {
    const tbs = Object.values(stats).reduce((a, v) => a + (v || 0), 0);
    const grade = overallGrade(stats, targets, roles, selectedRatio);
    const pad = (s, n) => String(s).padEnd(n);
    return [
      `=== Torn Ratio Helper Export ===`,
      `Date:   ${new Date().toLocaleString()}`,
      `Ratio:  ${RATIOS[selectedRatio].label} | High: ${STAT_LABELS[highStat]}`,
      `Health: ${grade.letter} (${grade.score}%) | TBS: ${fmtNum(tbs)}`,
      ``,
      `${pad('STAT',12)} ${pad('CURRENT',12)} ${pad('TARGET',12)} STATUS`,
      ...Object.entries(STAT_LABELS).map(([key, label]) => {
        const actual = stats[key] || 0;
        const target = targets[key] || 0;
        const isDump = selectedRatio === 'hank' && roles.tert2 === key;
        const status = getStatus(actual, target, isDump);
        return `${pad(label,12)} ${pad(fmtNum(actual),12)} ${pad(isDump?'minimize':fmtNum(Math.round(target)),12)} ${status.label}`;
      }),
    ].join('\n');
  }

  // ─── RENDER TABS ──────────────────────────────────────────────────────────

  function renderOverview(stats, targets, roles, ratio) {
    // War mode: reweight recommendation — defense and dex become priority
    const warWeights = { str: 0.5, spd: 0.5, def: 1.5, dex: 1.5 };

    const rec = (() => {
      let worst = null; let worstScore = Infinity;
      for (const [key] of Object.entries(STAT_LABELS)) {
        const isDump = selectedRatio === 'hank' && roles.tert2 === key;
        if (isDump || !targets[key]) continue;
        const actual = stats[key] || 0;
        const pct = actual / targets[key];
        const weight = warMode ? (warWeights[key] || 1) : 1;
        const score = pct / weight;
        if (score < worstScore) { worstScore = score; worst = key; }
      }
      return worst;
    })();

    const grade = overallGrade(stats, targets, roles, selectedRatio);
    const tbs = Object.values(stats).reduce((a, v) => a + (v || 0), 0);
    const allFound = Object.values(stats).every(v => v !== null);
    const driftWarnings = getDriftWarnings(stats, targets, roles);
    const gradeColor = grade.score >= 90 ? '#4ade80' : grade.score >= 75 ? '#facc15' : '#f87171';

    const statRows = Object.entries(STAT_LABELS).map(([key, label]) => {
      const actual  = stats[key] || 0;
      const target  = targets[key] || 0;
      const isDump  = selectedRatio === 'hank' && roles.tert2 === key;
      const isHigh  = roles.high === key;
      const is2nd   = roles.secondary === key;
      const is3rd   = roles.tert1 === key;
      const status  = getStatus(actual, target, isDump);
      const pct     = isDump ? 0 : (target > 0 ? Math.min((actual / target) * 100, 115) : 0);
      const diff    = Math.round(target - actual);
      const diffStr = isDump ? '<span style="color:#4a6080">minimize</span>'
                    : diff > 0 ? `<span style="color:#f87171">+${fmtNum(diff)} needed</span>`
                    : diff < 0 ? `<span style="color:#fb923c">${fmtNum(Math.abs(diff))} over</span>`
                    : `<span style="color:#4ade80">perfect</span>`;

      const roleBadge = isHigh ? '<span style="font-size:9px;color:#00c4ff;letter-spacing:1px;margin-left:4px">1ST</span>'
                      : is2nd  ? '<span style="font-size:9px;color:#8ab0d0;letter-spacing:1px;margin-left:4px">2ND</span>'
                      : is3rd  ? '<span style="font-size:9px;color:#4a6080;letter-spacing:1px;margin-left:4px">3RD</span>'
                      :          '<span style="font-size:9px;color:#f87171;letter-spacing:1px;margin-left:4px">DUMP</span>';

      return `
        <div class="trh-stat-row">
          <div class="trh-stat-top">
            <div class="trh-stat-name" style="color:${STAT_COLORS[key]}">
              ${STAT_ICONS[key]} ${label}${roleBadge}
            </div>
            <div class="trh-stat-nums">
              <span>${fmtNum(actual)}</span> / ${fmtNum(Math.round(target))}
              &nbsp;<span style="color:${status.color}">${status.grade}</span>
            </div>
          </div>
          <div class="trh-bar-bg">
            <div class="trh-bar-fill" style="width:${Math.min(pct, 100)}%;background:${status.color};${pct > 105 ? 'box-shadow:0 0 6px '+status.color : ''}"></div>
          </div>
          <div class="trh-stat-status" style="color:${status.color}">${status.emoji} ${status.label} &nbsp;·&nbsp; ${diffStr}</div>
        </div>`;
    }).join('');

    const lastEntry = history.length >= 2 ? history[history.length - 2] : null;
    const statKeys = Object.keys(STAT_LABELS);
    const tbsDelta = lastEntry ? tbs - statKeys.reduce((a, k) => a + (lastEntry[k] || 0), 0) : null;

    return `
      ${!allFound ? '<div class="trh-warn">⚠️ Some stats could not be read from this page. Try the gym or profile page.</div>' : ''}
      ${driftWarnings.map(w => `<div class="trh-drift-alert">📉 <strong>${STAT_LABELS[w.key]}</strong> has drifted — was ${Math.round(w.prevPct*100)}% of target, now ${Math.round(w.currPct*100)}%. Consider rebalancing.</div>`).join('')}

      <div class="trh-section">
        <div style="display:flex;gap:16px;align-items:flex-start;justify-content:space-between">
          <div>
            <div class="trh-label" style="display:flex;align-items:center;gap:8px">
              Ratio Health
              ${warMode ? '<span class="trh-war-badge">⚔ WAR MODE</span>' : ''}
            </div>
            <div class="trh-score-row">
              <div class="trh-grade" style="color:${gradeColor}">${grade.letter}</div>
              <div class="trh-score-detail">
                <div><strong>${grade.score}%</strong> in ratio</div>
                <div style="margin-top:2px">${RATIOS[selectedRatio].label}</div>
              </div>
            </div>
          </div>
          <div style="text-align:right">
            <div class="trh-label">Total Battle Stats</div>
            <div class="trh-tbs">${fmtNum(tbs)}</div>
            ${tbsDelta !== null ? `<div class="trh-tbs-label" style="color:${tbsDelta>=0?'#4ade80':'#f87171'}">${tbsDelta>=0?'+':''}${fmtNum(tbsDelta)} since last visit</div>` : '<div class="trh-tbs-label">TBS</div>'}
          </div>
        </div>
      </div>

      <hr class="trh-divider">

      ${rec ? `
      <div class="trh-section">
        <div class="trh-label">Train Next</div>
        <div class="trh-rec-box">
          <div class="trh-rec-stat" style="color:${STAT_COLORS[rec]}">${STAT_ICONS[rec]} ${STAT_LABELS[rec]}</div>
          <div style="font-size:11px;color:#4a7090">
            Currently at ${fmtNum(stats[rec])} · Target ${fmtNum(Math.round(targets[rec]))} · Need +${fmtNum(Math.round(targets[rec] - (stats[rec]||0)))}
          </div>
          <div class="trh-rec-gym"></div>
        </div>
      </div>
      <hr class="trh-divider">` : ''}

      <div class="trh-section">
        <div class="trh-label">Stats</div>
        ${statRows}
      </div>`;
  }

  function renderWhatIf(stats, roles) {
    const wi = {
      str: parseInt(document.getElementById('trh-wi-str')?.value || stats.str || 0),
      spd: parseInt(document.getElementById('trh-wi-spd')?.value || stats.spd || 0),
      def: parseInt(document.getElementById('trh-wi-def')?.value || stats.def || 0),
      dex: parseInt(document.getElementById('trh-wi-dex')?.value || stats.dex || 0),
    };
    const wiTargets = computeTargets(wi, selectedRatio, highStat);
    const wiGrade = overallGrade(wi, wiTargets, roles, selectedRatio);
    const wiTbs = Object.values(wi).reduce((a,b)=>a+b,0);
    const gradeColor = wiGrade.score >= 90 ? '#4ade80' : wiGrade.score >= 75 ? '#facc15' : '#f87171';

    const wiRows = Object.entries(STAT_LABELS).map(([key, label]) => {
      const actual = wi[key] || 0;
      const target = wiTargets[key] || 0;
      const isDump = selectedRatio === 'hank' && roles.tert2 === key;
      const status = getStatus(actual, target, isDump);
      return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid #0d1826;font-size:12px">
        <span style="color:${STAT_COLORS[key]};width:20px">${STAT_ICONS[key]}</span>
        <span style="flex:1;color:#8ab0d0">${label}</span>
        <span style="color:#4a6080">${fmtNum(Math.round(target))}</span>
        <span style="color:${status.color};width:20px;text-align:center">${status.emoji}</span>
      </div>`;
    }).join('');

    return `
      <div class="trh-section">
        <div class="trh-label">Enter Hypothetical Stats</div>
        <div class="trh-whatif-grid" id="trh-wi-grid">
          ${Object.entries(STAT_LABELS).map(([key, label]) => `
            <div class="trh-whatif-field">
              <label>${STAT_ICONS[key]} ${label}</label>
              <input id="trh-wi-${key}" type="number" value="${stats[key] || ''}" placeholder="e.g. 50000">
            </div>`).join('')}
        </div>
      </div>
      <hr class="trh-divider">
      <div class="trh-section">
        <div class="trh-label">Projected Ratio Health</div>
        <div style="display:flex;gap:16px;align-items:center;margin-bottom:10px">
          <div class="trh-grade" style="color:${gradeColor}">${wiGrade.letter}</div>
          <div class="trh-score-detail">
            <div><strong>${wiGrade.score}%</strong> in ratio</div>
            <div>TBS: <strong style="color:#00c4ff">${fmtNum(wiTbs)}</strong></div>
          </div>
        </div>
        ${wiRows}
      </div>`;
  }

  function renderHistory() {
    if (history.length === 0) {
      return `<div class="trh-no-history">📊 No history yet.<br>Stats are recorded each time you visit this page.<br>Check back after training!</div>`;
    }
    const sorted = [...history].reverse().slice(0, 15);
    const headerRow = `
      <div class="trh-history-row" style="font-size:10px;color:#2a5070;text-transform:uppercase;letter-spacing:1px">
        <div class="trh-history-date">Date</div>
        ${Object.entries(STAT_LABELS).map(([k,l])=>`<div class="trh-history-stat">${STAT_ICONS[k]}</div>`).join('')}
        <div class="trh-history-stat">TBS</div>
      </div>`;
    const rows = sorted.map((entry, i) => {
      const prev = sorted[i + 1];
      const tbs = Object.keys(STAT_LABELS).reduce((a, k) => a + (entry[k] || 0), 0);
      const prevTbs = prev ? Object.keys(STAT_LABELS).reduce((a, k) => a + (prev[k] || 0), 0) : null;
      const tbsDelta = prevTbs !== null ? tbs - prevTbs : null;
      return `
        <div class="trh-history-row">
          <div class="trh-history-date">${fmtDate(entry.ts)}</div>
          ${Object.keys(STAT_LABELS).map(k => {
            const delta = prev ? (entry[k]||0) - (prev[k]||0) : null;
            return `<div class="trh-history-stat" style="color:${STAT_COLORS[k]}">
              ${fmtNum(entry[k])}
              ${delta !== null && delta !== 0 ? `<div class="trh-history-delta" style="color:${delta>0?'#4ade80':'#f87171'}">${delta>0?'+':''}${fmtNum(delta)}</div>` : ''}
            </div>`;
          }).join('')}
          <div class="trh-history-stat" style="color:#00c4ff">
            ${fmtNum(tbs)}
            ${tbsDelta !== null && tbsDelta !== 0 ? `<div class="trh-history-delta" style="color:${tbsDelta>0?'#4ade80':'#f87171'}">${tbsDelta>0?'+':''}${fmtNum(tbsDelta)}</div>` : ''}
          </div>
        </div>`;
    }).join('');

    return `
      <div class="trh-section">
        <div class="trh-label">Stat History (last ${sorted.length} snapshots)</div>
        ${headerRow}${rows}
      </div>
      <div style="text-align:right;margin-top:8px">
        <button class="trh-btn" id="trh-clear-history">Clear History</button>
      </div>`;
  }

  function renderSettings() {
    const mults = RATIOS.custom.multipliers;
    const roles = ROLES[highStat];
    const ratio = RATIOS[selectedRatio];

    // Build a live mapping preview: slot → actual stat name + multiplier
    const slotOrder = [
      { role: 'high',      label: '1st — highest',    mult: 1.00 },
      { role: 'secondary', label: '2nd',               mult: ratio.multipliers.secondary },
      { role: 'tert1',     label: '3rd',               mult: ratio.multipliers.tert1 },
      { role: 'tert2',     label: '4th — dump/lowest', mult: ratio.multipliers.tert2 },
    ];

    const mappingRows = slotOrder.map(({ role, label, mult }) => {
      const statKey = roles[role];
      const isDump  = mult === 0;
      return `
        <div style="display:flex;align-items:center;gap:10px;padding:5px 8px;background:#0a1828;border-radius:4px;margin-bottom:4px;font-size:12px">
          <span style="color:#2a5070;width:110px;flex-shrink:0;font-size:10px;text-transform:uppercase;letter-spacing:1px">${label}</span>
          <span style="color:${STAT_COLORS[statKey]}">${STAT_ICONS[statKey]}</span>
          <span style="flex:1;color:#e2e8f0;font-weight:700">${STAT_LABELS[statKey]}</span>
          <span style="color:${isDump ? '#f87171' : '#4a6080'};font-size:11px">${isDump ? 'dump (×0)' : '×' + mult.toFixed(2)}</span>
        </div>`;
    }).join('');

    return `
      <div class="trh-section">
        <div class="trh-label">Active Ratio</div>
        <div class="trh-ctrl-row">
          ${Object.entries(RATIOS).map(([k, r]) =>
            `<button class="trh-btn ${selectedRatio === k ? 'active' : ''}" data-trh-ratio="${k}">${r.short}</button>`
          ).join('')}
        </div>
      </div>
      <div class="trh-section">
        <div class="trh-label">Your Highest Stat</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          Pick whichever stat you train the most. The 2nd, 3rd and 4th slots are assigned automatically based on sensible defaults for that stat. Use the Custom Ratio section below if you want different multipliers.
        </div>
        <div class="trh-ctrl-row">
          ${Object.entries(STAT_LABELS).map(([k, l]) =>
            `<button class="trh-btn ${highStat === k ? 'active' : ''}" data-trh-high="${k}">${STAT_ICONS[k]} ${l}</button>`
          ).join('')}
        </div>
      </div>

      <div class="trh-section">
        <div class="trh-label">Current Stat Order for ${ratio.label}</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          This is how your stats map to each slot. The multiplier (×) shows what fraction of your 1st stat each one is targeted at.
        </div>
        ${mappingRows}
      </div>

      <hr class="trh-divider">
      <div class="trh-section">
        <div class="trh-label">Display</div>
        <div class="trh-ctrl-row">
          <button class="trh-btn ${theme==='dark'?'active':''}" data-trh-theme="dark">🌙 Dark</button>
          <button class="trh-btn ${theme==='light'?'active':''}" data-trh-theme="light">☀️ Light</button>
        </div>
      </div>
      <div class="trh-section">
        <div class="trh-label">Faction War Mode</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          Reweights the "Train Next" recommendation to prioritise Defense and Dexterity during wars.
        </div>
        <button class="trh-btn ${warMode?'active':''}" id="trh-war-toggle">
          ${warMode ? '⚔ War Mode ON' : '☮ War Mode OFF'}
        </button>
      </div>
      <hr class="trh-divider">
      <div class="trh-section">
        <div class="trh-label">Custom Ratio Multipliers</div>
        <div style="font-size:11px;color:#4a6080;margin-bottom:8px">
          Each number is a target multiplier relative to your 1st stat. For example 0.80 means "train this stat to 80% of your highest stat." Set 4th to 0 to make it a dump stat (Hank's style).
        </div>
        <div class="trh-custom-grid">
          <div class="trh-custom-field">
            <label>1st — ${STAT_ICONS[roles.high]} ${STAT_LABELS[roles.high]} (always 1.0)</label>
            <input type="number" value="1.00" disabled style="opacity:0.4">
          </div>
          <div class="trh-custom-field">
            <label>2nd — ${STAT_ICONS[roles.secondary]} ${STAT_LABELS[roles.secondary]}</label>
            <input id="trh-c-secondary" type="number" step="0.01" min="0" max="1" value="${mults.secondary}">
          </div>
          <div class="trh-custom-field">
            <label>3rd — ${STAT_ICONS[roles.tert1]} ${STAT_LABELS[roles.tert1]}</label>
            <input id="trh-c-tert1" type="number" step="0.01" min="0" max="1" value="${mults.tert1}">
          </div>
          <div class="trh-custom-field">
            <label>4th / Dump — ${STAT_ICONS[roles.tert2]} ${STAT_LABELS[roles.tert2]}</label>
            <input id="trh-c-tert2" type="number" step="0.01" min="0" max="1" value="${mults.tert2}">
          </div>
        </div>
        <div style="margin-top:8px">
          <button class="trh-btn" id="trh-save-custom">Save Custom Ratio</button>
        </div>
      </div>`;
  }

  function renderPlanner(stats, roles) {
    const currentTbs = Object.values(stats).reduce((a, v) => a + (v || 0), 0);
    const inputVal = goalTbs || Math.round(currentTbs * 5);
    const goalTargets = computeGoalTargets(inputVal, selectedRatio, highStat);

    const rows = goalTargets ? Object.entries(STAT_LABELS).map(([key, label]) => {
      const current = stats[key] || 0;
      const goal    = goalTargets[key] || 0;
      const isDump  = selectedRatio === 'hank' && roles.tert2 === key;
      const deficit = isDump ? 0 : Math.max(0, goal - current);
      const passive = passiveGains[key] || 0;
      const trains  = isDump ? 0 : estimateTrains(current, goal, passive);
      const days    = trains > 0 ? Math.ceil(trains / 100) : 0; // ~100 trains/day at 1000e
      return `
        <div class="trh-planner-stat">
          <span style="color:${STAT_COLORS[key]};width:20px">${STAT_ICONS[key]}</span>
          <span style="flex:1;font-size:12px;color:#8ab0d0">${label}</span>
          <span style="font-size:11px;color:#4a6080;width:90px;text-align:right">${fmtNum(current)} → ${isDump ? '~0' : fmtNum(goal)}</span>
          <span style="font-size:11px;width:80px;text-align:right;color:${deficit>0?'#f87171':'#4ade80'}">
            ${isDump ? '<span style="color:#4a6080">minimize</span>' : deficit > 0 ? `+${fmtNum(deficit)}` : '✅ done'}
          </span>
          <span style="font-size:11px;width:70px;text-align:right;color:#4a6080">
            ${isDump || trains === 0 ? '' : `~${trains.toLocaleString()} trains`}
          </span>
        </div>`;
    }).join('') : '';

    return `
      <div class="trh-section">
        <div class="trh-label">Target Total Battle Stats</div>
        <div style="display:flex;gap:8px;align-items:center">
          <input id="trh-goal-input" type="number" value="${inputVal}" placeholder="e.g. 10000000"
            style="background:#0d1826;border:1px solid #1a3050;color:#c8d8e8;border-radius:4px;padding:6px 10px;font-size:13px;font-family:'Courier New',monospace;width:180px">
          <button class="trh-btn" id="trh-goal-calc">Calculate</button>
        </div>
        <div style="font-size:10px;color:#2a5070;margin-top:4px">Current TBS: ${fmtNum(currentTbs)}</div>
      </div>
      <hr class="trh-divider">
      <div class="trh-section">
        <div class="trh-label">Passive Daily Gains (optional — from books, education etc.)</div>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
          ${Object.entries(STAT_LABELS).map(([key, label]) => `
            <div class="trh-whatif-field">
              <label>${STAT_ICONS[key]} ${label}/day</label>
              <input id="trh-passive-${key}" type="number" value="${passiveGains[key] || 0}" min="0"
                placeholder="0" style="background:#0d1826;border:1px solid #1a3050;color:#c8d8e8;border-radius:4px;padding:5px 8px;font-size:12px;font-family:'Courier New',monospace;width:100%">
            </div>`).join('')}
        </div>
        <button class="trh-btn" id="trh-save-passive">Save Passive Gains</button>
      </div>
      <hr class="trh-divider">
      ${goalTargets ? `
      <div class="trh-section">
        <div class="trh-label">Stat Goals at ${fmtNum(inputVal)} TBS</div>
        <div style="font-size:10px;color:#2a5070;margin-bottom:6px">Train estimates use George's Gym (7.3 dots, 10e/train) as baseline.</div>
        <div style="display:flex;gap:8px;font-size:10px;color:#2a5070;text-transform:uppercase;letter-spacing:1px;padding:0 8px;margin-bottom:4px">
          <span style="width:20px"></span><span style="flex:1">Stat</span>
          <span style="width:90px;text-align:right">Now → Goal</span>
          <span style="width:80px;text-align:right">Deficit</span>
          <span style="width:70px;text-align:right">Est. Trains</span>
        </div>
        ${rows}
      </div>` : ''}`;
  }

  function renderEfficiency(stats) {
    const eff = getEfficiency(stats);
    const hasSession = sessionStatsStart !== null;

    return `
      <div class="trh-section">
        <div class="trh-label">Session Efficiency Tracker</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:10px">
          Tracks stat gains vs energy spent since you started this browser session on the gym page.
          ${!hasSession ? '<br><span style="color:#facc15">⚠️ Visit the gym page to start tracking.</span>' : ''}
        </div>
        ${eff ? `
        <div class="trh-eff-grid">
          <div class="trh-eff-card">
            <div class="trh-eff-val">${fmtNum(eff.per100)}</div>
            <div class="trh-eff-lbl">Stats per 100 energy</div>
          </div>
          <div class="trh-eff-card">
            <div class="trh-eff-val">${fmtNum(eff.energySpent)}</div>
            <div class="trh-eff-lbl">Energy spent this session</div>
          </div>
          <div class="trh-eff-card">
            <div class="trh-eff-val">${fmtNum(eff.totalGains)}</div>
            <div class="trh-eff-lbl">Total stats gained</div>
          </div>
          <div class="trh-eff-card">
            <div class="trh-eff-val">${fmtNum(Math.round(eff.totalGains / Math.max(1, eff.energySpent / 10)))}</div>
            <div class="trh-eff-lbl">Stats per train (avg)</div>
          </div>
        </div>
        <hr class="trh-divider">
        <div class="trh-label" style="margin-top:8px">Gains by Stat This Session</div>
        ${Object.entries(STAT_LABELS).map(([key, label]) => `
          <div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid #0d1826">
            <span style="color:${STAT_COLORS[key]}">${STAT_ICONS[key]}</span>
            <span style="flex:1;color:#8ab0d0">${label}</span>
            <span style="color:${(eff.gains[key]||0)>0?'#4ade80':'#4a6080'}">+${fmtNum(eff.gains[key]||0)}</span>
          </div>`).join('')}
        ` : `<div class="trh-no-history">${hasSession ? 'No gains recorded yet this session.<br>Train some stats and come back!' : 'Start your session on the gym page.'}</div>`}`;
  }

  function renderExport(stats, targets, roles) {
    const text = buildExportText(stats, targets, roles);
    return `
      <div class="trh-section">
        <div class="trh-label">Export Snapshot</div>
        <div style="font-size:12px;color:#8ab0d0;margin-bottom:8px">
          Copy and paste into your faction Discord, forums, or notes.
        </div>
        <button class="trh-btn" id="trh-copy-export">📋 Copy to Clipboard</button>
        <div class="trh-export-box" id="trh-export-text">${text}</div>
      </div>`;
  }

  // ─── INFO TAB ─────────────────────────────────────────────────────────────
  function renderInfo() {
    return `
      <div class="trh-section">
        <div class="trh-label">Why not just train all stats equally?</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7">
          Torn has <strong style="color:#e2e8f0">specialist gyms</strong> that give significantly better gains than regular gyms — but they have <strong style="color:#e2e8f0">stat ratio requirements</strong> to unlock and keep access. Baldr's and Hank's ratios are the two most popular ways to structure your stats so you can use those better gyms on as many stats as possible.
        </div>
      </div>

      <hr class="trh-divider">

      <div class="trh-section">
        <div class="trh-label">Gym Dots — Why They Matter</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          Gym dots are a multiplier on your stat gains per energy spent. Higher = more gains per train.
        </div>
        <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;font-size:11px">
          <div style="background:#0a1828;border:1px solid #1a3050;border-radius:4px;padding:8px;text-align:center">
            <div style="color:#4a6080;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:4px">George's Gym</div>
            <div style="color:#facc15;font-size:18px;font-weight:700">7.3</div>
            <div style="color:#4a6080;font-size:10px">dots · 10e/train</div>
            <div style="color:#4a6080;font-size:10px;margin-top:4px">No requirements</div>
          </div>
          <div style="background:#0a1828;border:1px solid #1a3050;border-radius:4px;padding:8px;text-align:center">
            <div style="color:#4a6080;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:4px">Special Gyms</div>
            <div style="color:#fb923c;font-size:18px;font-weight:700">7.5</div>
            <div style="color:#4a6080;font-size:10px">dots · 25e/train</div>
            <div style="color:#4a6080;font-size:10px;margin-top:4px">Ratio required</div>
          </div>
          <div style="background:#0a1828;border:1px solid #1a3050;border-radius:4px;padding:8px;text-align:center">
            <div style="color:#4a6080;font-size:10px;letter-spacing:1px;text-transform:uppercase;margin-bottom:4px">Elite Gyms</div>
            <div style="color:#4ade80;font-size:18px;font-weight:700">8.0</div>
            <div style="color:#4a6080;font-size:10px">dots · 50e/train</div>
            <div style="color:#4a6080;font-size:10px;margin-top:4px">Strict ratio req.</div>
          </div>
        </div>
      </div>

      <hr class="trh-divider">

      <div class="trh-section">
        <div class="trh-label">Baldr's Ratio — 1.25 : 1 : 0.75 : 0.75</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          A <strong style="color:#e2e8f0">balanced specialist build</strong>. Your high stat leads, secondary sits at 80% of it, and your two lower stats sit at 75% (about 60% relative to high). Your lowest stat is still around 22% of your total — a much more even spread.
        </div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          With Baldr's you unlock <strong style="color:#e2e8f0">two specialist gyms</strong>: one elite (8.0 dots) for your high stat, and one special (7.5 dots) for your secondary. Your two lower stats train at George's (7.3 dots).
        </div>
        <div style="background:#0a2010;border:1px solid #1a4020;border-left:3px solid #4ade80;border-radius:4px;padding:8px 10px;font-size:11px;color:#86efac">
          ✅ <strong>Best for:</strong> Str/Spd offensive builds, new-to-mid players, anyone who wants flexibility. Str and Spd complement each other (Str = damage, Spd = hit chance) so keeping both reasonably high is smart. Recommended starting point for most players.
        </div>
      </div>

      <hr class="trh-divider">

      <div class="trh-section">
        <div class="trh-label">Hank's Ratio — 1.25 : 1 : 1 : ~0</div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          An <strong style="color:#e2e8f0">aggressive efficiency build</strong>. Three stats are trained hard while one (the dump stat) is kept as close to zero as possible — around 9% of your total stats at most. This unlocks <strong style="color:#e2e8f0">two specialist gyms for three stats</strong> simultaneously.
        </div>
        <div style="font-size:12px;color:#8ab0d0;line-height:1.7;margin-bottom:8px">
          <strong style="color:#e2e8f0">Important:</strong> For Hank's, your high stat almost has to be Def or Dex — <em style="color:#64748b">not Str or Spd</em>. If you dump Speed, you'll miss most attacks. If you dump Strength, you'll hit often but deal little to no damage. Either way you lose fights. High Def or Dex with dumped opposite is the only practical Hank's setup.
        </div>
        <div style="background:#200a0a;border:1px solid #402020;border-left:3px solid #f87171;border-radius:4px;padding:8px 10px;font-size:11px;color:#fca5a5">
          ⚠️ <strong>Best for:</strong> Def or Dex high-stat builds in mid-to-late game. More total gains over time, but very restrictive — your dump stat must stay extremely low forever. Hard to switch away from later. Players who chose this have called it inflexible.
        </div>
      </div>

      <hr class="trh-divider">

      <div class="trh-section">
        <div class="trh-label">Stats Explained</div>
        <div style="display:flex;flex-direction:column;gap:6px;font-size:12px">
          <div style="display:flex;gap:10px;align-items:flex-start;padding:6px;background:#0a1828;border-radius:4px">
            <span style="color:#f97316;font-size:16px;width:22px;flex-shrink:0">💪</span>
            <div><strong style="color:#e2e8f0">Strength</strong> <span style="color:#4a6080;font-size:10px">OFFENSIVE</span><br><span style="color:#64748b">How hard you hit. More Str = more damage per successful strike. Useless if your Speed is too low to land hits.</span></div>
          </div>
          <div style="display:flex;gap:10px;align-items:flex-start;padding:6px;background:#0a1828;border-radius:4px">
            <span style="color:#22d3ee;font-size:16px;width:22px;flex-shrink:0">⚡</span>
            <div><strong style="color:#e2e8f0">Speed</strong> <span style="color:#4a6080;font-size:10px">OFFENSIVE</span><br><span style="color:#64748b">How often you hit. More Spd = higher hit chance each round. Str and Spd work together — you need both to deal reliable damage.</span></div>
          </div>
          <div style="display:flex;gap:10px;align-items:flex-start;padding:6px;background:#0a1828;border-radius:4px">
            <span style="color:#a78bfa;font-size:16px;width:22px;flex-shrink:0">🛡️</span>
            <div><strong style="color:#e2e8f0">Defense</strong> <span style="color:#4a6080;font-size:10px">DEFENSIVE</span><br><span style="color:#64748b">Reduces damage you take when hit. Works independently from Dexterity. High Def = you survive longer in fights.</span></div>
          </div>
          <div style="display:flex;gap:10px;align-items:flex-start;padding:6px;background:#0a1828;border-radius:4px">
            <span style="color:#4ade80;font-size:16px;width:22px;flex-shrink:0">🎯</span>
            <div><strong style="color:#e2e8f0">Dexterity</strong> <span style="color:#4a6080;font-size:10px">DEFENSIVE</span><br><span style="color:#64748b">How often you dodge enemy attacks. More Dex = enemy hits less often. Also works independently from Defense.</span></div>
          </div>
        </div>
      </div>`;
  }

  // ─── MAIN RENDER ──────────────────────────────────────────────────────────
  function render(stats) {
    const roles   = ROLES[highStat];
    const targets = computeTargets(stats, selectedRatio, highStat);

    let tabContent = '';
    if (activeTab === 'overview')        tabContent = renderOverview(stats, targets, roles, selectedRatio);
    else if (activeTab === 'whatif')     tabContent = renderWhatIf(stats, roles);
    else if (activeTab === 'history')    tabContent = renderHistory();
    else if (activeTab === 'export')     tabContent = renderExport(stats, targets, roles);
    else if (activeTab === 'settings')   tabContent = renderSettings();
    else if (activeTab === 'info')       tabContent = renderInfo();

    const tabs = [
      { id: 'overview',  label: 'Overview' },
      { id: 'whatif',    label: 'What-If' },
      { id: 'history',   label: 'History' },
      { id: 'export',    label: 'Export' },
      { id: 'settings',  label: 'Settings' },
      { id: 'info',      label: '📖' },
    ];

    return `
      <div id="trh-root" class="${theme === 'light' ? 'trh-light' : ''}">
        <div id="trh-header">
          <div id="trh-title">⚔ GymIQ</div>
          <div style="display:flex;align-items:center;gap:8px">
            ${warMode ? '<span class="trh-war-badge">⚔ WAR</span>' : ''}
            <div style="font-size:10px;color:#2a5070;letter-spacing:1px">
              ${RATIOS[selectedRatio].short.toUpperCase()} · ${STAT_LABELS[highStat].toUpperCase()} HIGH
            </div>
          </div>
        </div>
        <div id="trh-tabs">
          ${tabs.map(t => `<button class="trh-tab ${activeTab === t.id ? 'active' : ''}" data-trh-tab="${t.id}">${t.label}</button>`).join('')}
        </div>
        <div id="trh-body">
          ${tabContent}
        </div>
      </div>`;
  }

  // ─── INJECT ───────────────────────────────────────────────────────────────
  function injectPanel() {
    const old = document.getElementById('trh-wrapper');
    if (old) old.remove();

    const stats = readStatsFromPage();
    recordHistory(stats);

    if (!document.getElementById('trh-style')) {
      const style = document.createElement('style');
      style.id = 'trh-style';
      style.textContent = CSS;
      document.head.appendChild(style);
    }

    const wrapper = document.createElement('div');
    wrapper.id = 'trh-wrapper';
    wrapper.innerHTML = render(stats);

    const host = [
      document.querySelector('.gym-content'),
      document.querySelector('.content-title'),
      document.querySelector('#gym-root'),
      document.querySelector('.profile-wrapper'),
      document.querySelector('.stats-info'),
      document.querySelector('#react-root'),
      document.querySelector('main'),
      document.querySelector('#mainContainer'),
      document.body,
    ].find(el => el !== null);

    host.insertBefore(wrapper, host.firstChild);
    attachEvents(wrapper, stats);
  }

  // Use event delegation on the wrapper element so listeners are never
  // lost when the inner HTML is replaced by a re-render. One listener
  // on the wrapper catches all clicks from any child button.
  function attachEvents(wrapper, stats) {
    wrapper.addEventListener('click', (e) => {
      const btn = e.target.closest('button, [data-trh-tab], [data-trh-ratio], [data-trh-high], [data-trh-theme]');
      if (!btn) return;

      // Tab navigation
      if (btn.dataset.trhTab) {
        activeTab = btn.dataset.trhTab;
        injectPanel();
        return;
      }

      // Ratio select
      if (btn.dataset.trhRatio) {
        selectedRatio = btn.dataset.trhRatio;
        GM_setValue('trh_ratio', selectedRatio);
        injectPanel();
        return;
      }

      // High stat select
      if (btn.dataset.trhHigh) {
        highStat = btn.dataset.trhHigh;
        GM_setValue('trh_high', highStat);
        injectPanel();
        return;
      }

      // Theme select
      if (btn.dataset.trhTheme) {
        theme = btn.dataset.trhTheme;
        GM_setValue('trh_theme', theme);
        injectPanel();
        return;
      }

      // Named buttons by id
      const id = btn.id;

      if (id === 'trh-war-toggle') {
        warMode = !warMode;
        GM_setValue('trh_war', warMode);
        injectPanel();
        return;
      }

      if (id === 'trh-save-custom') {
        customMults = {
          high:      1.00,
          secondary: parseFloat(document.getElementById('trh-c-secondary')?.value) || 0.80,
          tert1:     parseFloat(document.getElementById('trh-c-tert1')?.value)     || 0.60,
          tert2:     parseFloat(document.getElementById('trh-c-tert2')?.value)     || 0.60,
        };
        GM_setValue('trh_custom', customMults);
        selectedRatio = 'custom';
        GM_setValue('trh_ratio', 'custom');
        injectPanel();
        return;
      }

      if (id === 'trh-clear-history') {
        if (confirm('Clear all stat history?')) {
          history = [];
          GM_setValue('trh_history', []);
          injectPanel();
        }
        return;
      }

      if (id === 'trh-goal-calc') {
        goalTbs = parseInt(document.getElementById('trh-goal-input')?.value) || 0;
        GM_setValue('trh_goal_tbs', goalTbs);
        injectPanel();
        return;
      }

      if (id === 'trh-save-passive') {
        passiveGains = {};
        for (const key of Object.keys(STAT_LABELS)) {
          passiveGains[key] = parseInt(document.getElementById(`trh-passive-${key}`)?.value) || 0;
        }
        GM_setValue('trh_passive', passiveGains);
        injectPanel();
        return;
      }

      if (id === 'trh-copy-export') {
        const text = document.getElementById('trh-export-text')?.textContent || '';
        navigator.clipboard.writeText(text).then(() => {
          btn.textContent = '✅ Copied!';
          setTimeout(() => { btn.textContent = '📋 Copy to Clipboard'; }, 2000);
        });
        return;
      }
    });

    // Enter key on goal input
    wrapper.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && e.target.id === 'trh-goal-input') {
        goalTbs = parseInt(e.target.value) || 0;
        GM_setValue('trh_goal_tbs', goalTbs);
        injectPanel();
      }
    });

    // What-if live update
    wrapper.addEventListener('input', (e) => {
      if (!e.target.id?.startsWith('trh-wi-')) return;
      const wi = {
        str: parseInt(document.getElementById('trh-wi-str')?.value || 0),
        spd: parseInt(document.getElementById('trh-wi-spd')?.value || 0),
        def: parseInt(document.getElementById('trh-wi-def')?.value || 0),
        dex: parseInt(document.getElementById('trh-wi-dex')?.value || 0),
      };
      const roles = ROLES[highStat];
      const wiTargets = computeTargets(wi, selectedRatio, highStat);
      const wiGrade = overallGrade(wi, wiTargets, roles, selectedRatio);
      const gradeColor = wiGrade.score >= 90 ? '#4ade80' : wiGrade.score >= 75 ? '#facc15' : '#f87171';
      const gradeEl = document.querySelector('.trh-grade');
      if (gradeEl) { gradeEl.textContent = wiGrade.letter; gradeEl.style.color = gradeColor; }
    });
  }

  // ─── INIT ─────────────────────────────────────────────────────────────────

  // Only run on the actual gym page
  function isGymPage() {
    return location.pathname === '/gym.php' || location.href.includes('gym.php');
  }

  function removePanel() {
    const el = document.getElementById('trh-wrapper');
    if (el) el.remove();
  }

  // Wait for the page and any other scripts (e.g. TornTools) to finish loading
  // before reading stats — avoids grabbing wrong values from half-loaded DOM
  function waitAndInject(attempts = 0) {
    if (!isGymPage()) { removePanel(); return; }

    const domReady = document.querySelector('.gym-content, .content-title, #gym-root, .stats-info, main');
    if (!domReady && attempts < 40) {
      return setTimeout(() => waitAndInject(attempts + 1), 300);
    }

    const stats = readStatsFromPage();
    const statsFound = Object.values(stats).some(v => v !== null);

    // Keep retrying until we get real stats — other scripts may still be injecting
    if (!statsFound && attempts < 60) {
      return setTimeout(() => waitAndInject(attempts + 1), 500);
    }

    // Grab session baseline on first good read
    if (statsFound && sessionStatsStart === null) {
      sessionStatsStart = { ...stats };
      sessionEnergyStart = readEnergyFromPage();
    }
    injectPanel();
    setTimeout(backgroundStatWatch, 600);
  }

  // If the panel showed the warning banner, keep retrying quietly until real stats appear
  function backgroundStatWatch() {
    if (!isGymPage() || !document.getElementById('trh-wrapper')) return;
    const warningVisible = document.querySelector('#trh-wrapper .trh-warn');
    if (!warningVisible) return;
    const stats = readStatsFromPage();
    if (Object.values(stats).some(v => v !== null)) {
      injectPanel();
    } else {
      setTimeout(backgroundStatWatch, 500);
    }
  }

  // Watch the energy bar — it drops every time you train, so we use it
  // to detect when a train completes and refresh the panel automatically
  let trainObserver = null;
  let refreshDebounce = null;
  let lastEnergyValue = null;
  let observerPaused = false; // pause while we re-render so we don't double-fire

  function getDisplayedEnergy() {
    // Torn's energy bar class name has a random suffix so we match on the partial
    const selectors = [
      '[class*="bar-value"]',
      '[class*="energy"] [class*="bar-value"]',
      '[class*="energy"] [class*="value"]',
    ];
    for (const sel of selectors) {
      try {
        const els = document.querySelectorAll(sel);
        for (const el of els) {
          const m = el.textContent.replace(/,/g, '').match(/(\d+)\s*\/\s*\d+/);
          if (m) {
            const v = parseInt(m[1]);
            if (!isNaN(v) && v >= 0 && v <= 1000) return v;
          }
        }
      } catch(e) {}
    }
    return null;
  }

  function syncEnergyBaseline() {
    const e = getDisplayedEnergy();
    if (e !== null) lastEnergyValue = e;
  }

  function startTrainObserver() {
    if (trainObserver) return;

    syncEnergyBaseline();

    trainObserver = new MutationObserver(() => {
      if (!isGymPage() || observerPaused) return;

      const currentEnergy = getDisplayedEnergy();
      if (currentEnergy === null) return;

      if (lastEnergyValue !== null && currentEnergy < lastEnergyValue) {
        // Energy dropped — train just fired, refresh the panel
        lastEnergyValue = currentEnergy;
        clearTimeout(refreshDebounce);
        refreshDebounce = setTimeout(() => {
          if (!isGymPage() || !document.getElementById('trh-wrapper')) return;
          // Pause so the re-render doesn't trigger the observer again
          observerPaused = true;
          injectPanel();
          // Re-sync energy and unpause once the render settles
          setTimeout(() => {
            syncEnergyBaseline();
            observerPaused = false;
          }, 500);
        }, 800);
      } else {
        // Energy same or went up (regen) — just update the baseline
        lastEnergyValue = currentEnergy;
      }
    });

    trainObserver.observe(document.body, {
      childList: true,
      subtree: true,
      characterData: true,
    });
  }

  // Watch for page navigation and show/hide the panel accordingly
  let lastUrl = location.href;
  new MutationObserver(() => {
    const currentUrl = location.href;
    if (currentUrl !== lastUrl) {
      lastUrl = currentUrl;
      if (isGymPage()) {
        setTimeout(() => waitAndInject(), 800);
        startTrainObserver();
      } else {
        removePanel();
        if (trainObserver) { trainObserver.disconnect(); trainObserver = null; }
      }
    }
  }).observe(document.body, { childList: true, subtree: true });

  if (isGymPage()) {
    waitAndInject();
    startTrainObserver();
  }

})();