Torn Console Jump Optimizer

Works out exactly how much stacked energy to burn on the Game Console for Happy before you train, using Vladar's Training Gains Explained v2.0. Detects your gym from the API, auto-picks the best gym you can reach, and is built for phones.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn Console Jump Optimizer
// @namespace    https://www.torn.com/profiles.php?XID=4347781
// @version      1.6
// @description  Works out exactly how much stacked energy to burn on the Game Console for Happy before you train, using Vladar's Training Gains Explained v2.0. Detects your gym from the API, auto-picks the best gym you can reach, and is built for phones.
// @author       Microddot [4347781]
// @license      MIT
// @match        https://www.torn.com/gym.php*
// @connect      api.torn.com
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// @supportURL   https://www.torn.com/profiles.php?XID=4347781
// @compatible   chrome
// @compatible   firefox
// @compatible   edge
// @compatible   opera
// @icon         data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2374c365' d='M13 2 4 14h6l-1 8 9-12h-6z'/%3E%3C/svg%3E
// ==/UserScript==
// Enjoying this script? Send a Xanax to Microddot [4347781]
// https://www.torn.com/profiles.php?XID=4347781
(function () {
  'use strict';

  /* ------------------------------------------------------------------ *
   * GM_* compatibility wrappers (Torn PDA / no-GM fallbacks)
   * ------------------------------------------------------------------ */
  const STORE_PREFIX = 'GM_cjopt_';

  function gmGetValue(key, def) {
    try {
      if (typeof GM_getValue === 'function') {
        const v = GM_getValue(STORE_PREFIX + key);
        return v === undefined || v === null ? def : JSON.parse(v);
      }
    } catch (e) { /* fall through */ }
    try {
      const v = localStorage.getItem(STORE_PREFIX + key);
      return v === null ? def : JSON.parse(v);
    } catch (e) { return def; }
  }

  function gmSetValue(key, value) {
    const json = JSON.stringify(value);
    try {
      if (typeof GM_setValue === 'function') { GM_setValue(STORE_PREFIX + key, json); return; }
    } catch (e) { /* fall through */ }
    try { localStorage.setItem(STORE_PREFIX + key, json); }
    catch (e) { /* QuotaExceededError — silently drop */ }
  }

  function gmDeleteValue(key) {
    try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(STORE_PREFIX + key); return; } } catch (e) {}
    try { localStorage.removeItem(STORE_PREFIX + key); } catch (e) {}
  }

  function gmAddStyle(css) {
    try { if (typeof GM_addStyle === 'function') { GM_addStyle(css); return; } } catch (e) {}
    const el = document.createElement('style');
    el.textContent = css;
    document.head.appendChild(el);
  }

  // Torn PDA support: PDA replaces the literal below with the user's key at load time.
  const PDA_API_KEY = '###PDA-APIKEY###';
  const HAS_PDA_KEY = !PDA_API_KEY.startsWith('###' + 'PDA') && /^[A-Za-z0-9]{16}$/.test(PDA_API_KEY);

  function httpGet(url) {
    return new Promise((resolve, reject) => {
      if (typeof window.PDA_httpGet === 'function') {
        window.PDA_httpGet(url)
          .then((r) => resolve({ status: r.status, responseText: r.responseText }))
          .catch(reject);
        return;
      }
      if (typeof GM_xmlhttpRequest === 'function') {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          timeout: 15000,
          onload: (r) => resolve({ status: r.status, responseText: r.responseText }),
          onerror: () => reject(new Error('Network error')),
          ontimeout: () => reject(new Error('Request timed out')),
        });
        return;
      }
      const ctrl = new AbortController();
      const t = setTimeout(() => ctrl.abort(), 15000);
      fetch(url, { signal: ctrl.signal })
        .then((res) => res.text().then((text) => { clearTimeout(t); resolve({ status: res.status, responseText: text }); }))
        .catch((err) => { clearTimeout(t); reject(err); });
    });
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, (c) => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
    }[c]));
  }

  /* ------------------------------------------------------------------ *
   * Stat constants (Vladar v2.0). C is the randomness range in the
   * original formula; this optimizer computes expected gains, so C is
   * intentionally unused.
   * ------------------------------------------------------------------ */
  const STAT_CONSTANTS = {
    str: { A: 1600, B: 1700, C: 700 },
    spd: { A: 1600, B: 2000, C: 1350 },
    dex: { A: 1800, B: 1500, C: 1000 },
    def: { A: 2100, B: -600, C: 1500 },
  };

  const STAT_LABELS = { str: 'Strength', spd: 'Speed', dex: 'Dexterity', def: 'Defense' };
  const STAT_WORDS = { str: 'strength', spd: 'speed', def: 'defense', dex: 'dexterity' };

  /* ------------------------------------------------------------------ *
   * Gym data — dots on the /10 scale expected by the formula.
   * (Torn's API may store gym gains multiplied by 10.)
   * null = gym cannot train that stat.
   * ------------------------------------------------------------------ */
  const GYMS = [
    // Lightweight, 5E
    { name: 'Premier Fitness', energy: 5, str: 2.0, spd: 2.0, def: 2.0, dex: 2.0 },
    { name: 'Average Joes', energy: 5, str: 2.4, spd: 2.4, def: 2.8, dex: 2.4 },
    { name: "Woody's Workout", energy: 5, str: 2.8, spd: 3.2, def: 3.0, dex: 2.8 },
    { name: 'Beach Bods', energy: 5, str: 3.2, spd: 3.2, def: 3.2, dex: null },
    { name: 'Silver Gym', energy: 5, str: 3.4, spd: 3.6, def: 3.4, dex: 3.2 },
    { name: 'Pour Femme', energy: 5, str: 3.4, spd: 3.6, def: 3.6, dex: 3.8 },
    { name: 'Davies Den', energy: 5, str: 3.7, spd: null, def: 3.7, dex: 3.7 },
    { name: 'Global Gym', energy: 5, str: 4.0, spd: 4.0, def: 4.0, dex: 4.0 },
    // Middleweight, 10E
    { name: 'Knuckle Heads', energy: 10, str: 4.8, spd: 4.4, def: 4.0, dex: 4.2 },
    { name: 'Pioneer Fitness', energy: 10, str: 4.4, spd: 4.5, def: 4.8, dex: 4.4 },
    { name: 'Anabolic Anomalies', energy: 10, str: 5.0, spd: 4.5, def: 5.2, dex: 4.5 },
    { name: 'Core', energy: 10, str: 5.0, spd: 5.2, def: 5.0, dex: 5.0 },
    { name: 'Racing Fitness', energy: 10, str: 5.0, spd: 5.4, def: 4.8, dex: 5.2 },
    { name: 'Complete Cardio', energy: 10, str: 5.5, spd: 5.8, def: 5.5, dex: 5.2 },
    { name: 'Legs, Bums and Tums', energy: 10, str: null, spd: 5.6, def: 5.6, dex: 5.8 },
    { name: 'Deep Burn', energy: 10, str: 6.0, spd: 6.0, def: 6.0, dex: 6.0 },
    // Heavyweight, 10E
    { name: 'Apollo Gym', energy: 10, str: 6.0, spd: 6.2, def: 6.4, dex: 6.2 },
    { name: 'Gun Shop', energy: 10, str: 6.6, spd: 6.4, def: 6.2, dex: 6.2 },
    { name: 'Force Training', energy: 10, str: 6.4, spd: 6.6, def: 6.4, dex: 6.8 },
    { name: "Cha Cha's", energy: 10, str: 6.4, spd: 6.4, def: 6.8, dex: 7.0 },
    { name: 'Atlas', energy: 10, str: 7.0, spd: 6.4, def: 6.4, dex: 6.6 },
    { name: 'Last Round', energy: 10, str: 6.8, spd: 6.6, def: 7.0, dex: 6.6 },
    { name: 'The Edge', energy: 10, str: 6.8, spd: 7.0, def: 7.0, dex: 6.8 },
    { name: "George's", energy: 10, str: 7.3, spd: 7.3, def: 7.3, dex: 7.3 },
    // Specialty
    { name: "Jail Gym (Crim's)", energy: 5, str: 2.0, spd: 2.0, def: 4.5, dex: 2.0, specialty: true, note: 'Defense value is community-estimated around 4.5.' },
    { name: 'Sports Science Lab', energy: 25, str: null, spd: 8.0, def: null, dex: 8.0, specialty: true, note: 'Requires Last Round unlocked and no Xanax/Ecstasy taken. Speed and Dexterity values are community-estimated.' },
    { name: 'Balboas Gym', energy: 25, str: null, spd: null, def: 7.5, dex: 7.5, specialty: true, note: "Requires Cha Cha's unlocked." },
    { name: 'Frontline Fitness', energy: 25, str: 7.5, spd: 7.5, def: null, dex: null, specialty: true, note: "Requires Cha Cha's unlocked." },
    { name: 'Gym 3000', energy: 50, str: 8.0, spd: null, def: null, dex: null, specialty: true, note: "Requires George's unlocked and Strength 25% above the second-highest stat." },
    { name: 'Mr. Isoyamas', energy: 50, str: null, spd: null, def: 8.0, dex: null, specialty: true },
    { name: 'Total Rebound', energy: 50, str: null, spd: 8.0, def: null, dex: null, specialty: true },
    { name: 'Elites', energy: 50, str: null, spd: null, def: null, dex: 8.0, specialty: true },
  ];

  /* ------------------------------------------------------------------ *
   * Fixed values — these do not vary for this workflow, so they are
   * constants rather than inputs. Change them here if that ever shifts.
   * ------------------------------------------------------------------ */
  const CANDY_COUNT = 49;             // always 49 candies
  const CONSOLE_ENERGY_PER_USE = 5;   // the Game Console always costs 5E per use
  const SCAN_STEP = 10;               // conversion amounts are scanned every 10E
  const CONSOLE_HAPPY_NO = 100;       // Happy per Console use, no 5-star shop job
  const CONSOLE_HAPPY_YES = 200;      // Happy per Console use, working a 5-star shop

  /* ------------------------------------------------------------------ *
   * Candy — base Happy per item (Torn item list)
   * ------------------------------------------------------------------ */
  const CANDIES = [
    { name: 'Box of Bon Bons', happy: 25 },
    { name: 'Box of Chocolate Bars', happy: 25 },
    { name: 'Bag of Bon Bons', happy: 25 },
    { name: 'Bag of Chocolate Kisses', happy: 25 },
    { name: 'Box of Sweet Hearts', happy: 25 },
    { name: 'Lollipop', happy: 25 },
    { name: 'Box of Extra Strong Mints', happy: 25 },
    { name: 'Big Box of Chocolate Bars', happy: 35 },
    { name: 'Bag of Candy Kisses', happy: 50 },
    { name: 'Chocolate Egg', happy: 50 },
    { name: 'Bag of Bloody Eyeballs', happy: 75 },
    { name: 'Bag of Tootsie Rolls', happy: 75 },
    { name: 'Bag of Reindeer Droppings', happy: 100 },
    { name: 'Bag of Chocolate Truffles', happy: 100 },
    { name: 'Bag of Sherbet', happy: 150 },
    { name: 'Bag of Humbugs', happy: 150 },
    { name: 'Pixie Sticks', happy: 150 },
    { name: 'Jawbreaker', happy: 150 },
    { name: 'Birthday Cupcake', happy: 250 },
  ];

  /* ------------------------------------------------------------------ *
   * EVL Stock Reward — flat Happy added to the base amount, i.e. before
   * the Ecstasy multiplier is applied.
   * ------------------------------------------------------------------ */
  const EVL_TIERS = [
    { label: 'No', happy: 0 },
    { label: 'Tier 1', happy: 1000 },
    { label: 'Tier 2', happy: 2000 },
    { label: 'Tier 3', happy: 3000 },
  ];

  function evlLabel(happy) {
    for (let i = 0; i < EVL_TIERS.length; i++) {
      if (EVL_TIERS[i].happy === happy) return EVL_TIERS[i].label;
    }
    return EVL_TIERS[0].label;
  }

  function findCandy(name) {
    for (let i = 0; i < CANDIES.length; i++) {
      if (CANDIES[i].name === name) return CANDIES[i];
    }
    return CANDIES[0];
  }

  /* ------------------------------------------------------------------ *
   * Math engine — expected-value implementation of Vladar's formula
   * ------------------------------------------------------------------ */

  // Excel-compatible rounding to 4 decimal places.
  function round4(x) {
    return Math.round(x * 10000) / 10000;
  }

  // Expected gain for a single train.
  function trainGain(stat, H, G, E, A, B, mult) {
    const S = Math.min(stat, 50000000);
    const Hc = Math.min(H, 99999);
    const base =
      S * round4(1 + 0.07 * round4(Math.log(1 + Hc / 250))) +
      8 * Math.pow(Hc, 1.05) +
      (1 - Math.pow(Hc / 99999, 2)) * A +
      B;
    return base * (1 / 200000) * G * E * mult;
  }

  // Expected Happy loss per train: average of the rounded 4x/5x/6x outcomes.
  function expectedHappyLoss(E) {
    const values = [
      Math.round(0.1 * E * 4),
      Math.round(0.1 * E * 5),
      Math.round(0.1 * E * 6),
    ];
    return (values[0] + values[1] + values[2]) / 3;
  }

  function simulateBlock(startStat, startHappy, energyForBlock, gym, selectedStat, A, B, mult) {
    const E = gym.energy;
    const G = gym[selectedStat];
    if (G === null || G === undefined) {
      return { gained: 0, trains: 0, trainable: false, endHappy: startHappy };
    }
    let H = startHappy;
    let gained = 0;
    const trains = Math.floor(energyForBlock / E);
    const dH = expectedHappyLoss(E);
    for (let i = 0; i < trains; i++) {
      gained += trainGain(startStat + gained, H, G, E, A, B, mult);
      H = Math.max(0, H - dH);
    }
    return { gained, trains, trainable: true, endHappy: H };
  }

  function optimize(p) {
    const consoleHappyPerE = p.consoleHappyPerUse / p.consoleEnergyPerUse;
    const baseHappy = p.privateIslandHappy + p.candyCount * p.happyPerCandy + (p.evlHappy || 0);
    let best = null;
    let noConversionGain = 0;
    const scan = [];
    for (let c = 0; c <= p.stackedEnergy; c += p.scanStep) {
      const energyToTrain = p.stackedEnergy - c;
      const rawHappy = baseHappy + c * consoleHappyPerE;
      const startHappy = Math.min(rawHappy * p.ecstasyMult, 99999);
      const simulated = simulateBlock(
        p.statTotal, startHappy, energyToTrain, p.gym, p.selectedStat, p.A, p.B, p.mult
      );
      const point = {
        converted: c,
        startHappy,
        energyToTrain,
        trains: simulated.trains,
        gained: simulated.gained,
      };
      scan.push(point);
      if (c === 0) noConversionGain = simulated.gained;
      // Strictly greater: on exact ties the first scan point encountered wins.
      if (best === null || simulated.gained > best.gained) best = point;
    }
    const improvement = best.gained - noConversionGain;
    const improvementPct = noConversionGain > 0 ? (improvement / noConversionGain) * 100 : 0;
    return { best, noConversionGain, improvement, improvementPct, scan, baseHappy, consoleHappyPerE };
  }

  /* ------------------------------------------------------------------ *
   * Settings (defaults deep-merged over stored partials)
   * ------------------------------------------------------------------ */
  const DEFAULTS = {
    selectedStat: 'spd',
    gymName: "George's",
    statTotal: 200000,
    perks: {
      property: 0,
      educationGeneral: 0,
      job: 0,
      book: 0,
      uniform: 0,
      steadfast: 0,
      educationStat: 0,
    },
    privateIslandHappy: 5025,
    candyCount: CANDY_COUNT,
    candyName: 'Box of Bon Bons',
    happyPerCandy: 25,
    ecstasyMult: 2,
    stackedEnergy: 900,
    consoleHappyPerUse: CONSOLE_HAPPY_NO,
    evlHappy: 0,
    consoleEnergyPerUse: CONSOLE_ENERGY_PER_USE,
    scanStep: SCAN_STEP,
    autoPickGym: true,
    includeSpecialty: false,
    activeGymName: null,
    stats: { str: 0, spd: 0, def: 0, dex: 0 },
    // Which panels are expanded. Only the first is open by default, so the
    // sheet opens short and the answer is the first thing on screen.
    openSections: { gym: true, happy: false, energy: false, perks: false, api: false },
    drugs: { xan: null, ext: null },
  };

  function deepMerge(defaults, stored) {
    if (typeof stored !== 'object' || stored === null) return defaults;
    const out = Array.isArray(defaults) ? defaults.slice() : Object.assign({}, defaults);
    for (const k of Object.keys(stored)) {
      if (
        typeof defaults[k] === 'object' && defaults[k] !== null && !Array.isArray(defaults[k]) &&
        typeof stored[k] === 'object' && stored[k] !== null
      ) {
        out[k] = deepMerge(defaults[k], stored[k]);
      } else if (stored[k] !== undefined) {
        out[k] = stored[k];
      }
    }
    return out;
  }

  let settings = deepMerge(DEFAULTS, gmGetValue('settings', {}));

  // Force the fixed values, in case an older saved config carries other ones.
  function applyFixed() {
    settings.candyCount = CANDY_COUNT;
    settings.consoleEnergyPerUse = CONSOLE_ENERGY_PER_USE;
    settings.scanStep = SCAN_STEP;
    if (settings.consoleHappyPerUse !== CONSOLE_HAPPY_NO &&
        settings.consoleHappyPerUse !== CONSOLE_HAPPY_YES) {
      settings.consoleHappyPerUse = CONSOLE_HAPPY_NO;
    }
    if (!EVL_TIERS.some((t) => t.happy === settings.evlHappy)) settings.evlHappy = 0;
    settings.happyPerCandy = findCandy(settings.candyName).happy;
  }
  applyFixed();

  function saveSettings() {
    gmSetValue('settings', settings);
  }

  function perkMult(perks) {
    return (
      (1 + perks.property / 100) *
      (1 + perks.educationGeneral / 100) *
      (1 + perks.job / 100) *
      (1 + perks.book / 100) *
      (1 + perks.uniform / 100) *
      (1 + perks.steadfast / 100) *
      (1 + perks.educationStat / 100)
    );
  }


  /* ------------------------------------------------------------------ *
   * Gym access resolution
   *
   * Torn's API exposes `active_gym` (the gym you currently have selected)
   * but there is NO endpoint that lists every gym you have unlocked. The
   * 24 standard gyms unlock strictly in order and `active_gym` is a
   * 1-based index over that same order, so every standard gym at or below
   * your active gym is provably unlocked. Specialty gyms sit outside that
   * progression, so their eligibility can only be *estimated* from battle
   * stats — that stays behind an off-by-default toggle.
   * ------------------------------------------------------------------ */
  const STANDARD_GYMS = GYMS.filter((g) => !g.specialty);

  function normGym(s) {
    return String(s).toLowerCase().replace(/^the\s+/, '').replace(/[^a-z0-9]/g, '');
  }

  const GYM_BY_NORM = {};
  GYMS.forEach((g) => { GYM_BY_NORM[normGym(g.name)] = g; });

  // Torn spells a few of these differently from the table above.
  const GYM_ALIASES = {
    jailgym: "Jail Gym (Crim's)",
    jail: "Jail Gym (Crim's)",
    crims: "Jail Gym (Crim's)",
    balboas: 'Balboas Gym',
    mrisoyamas: 'Mr. Isoyamas',
    isoyamas: 'Mr. Isoyamas',
    legsbumsandtums: 'Legs, Bums and Tums',
    legsbumstums: 'Legs, Bums and Tums',
  };

  function findGymByName(name) {
    if (!name) return null;
    const n = normGym(name);
    if (GYM_BY_NORM[n]) return GYM_BY_NORM[n];
    if (GYM_ALIASES[n]) return GYM_BY_NORM[normGym(GYM_ALIASES[n])] || null;
    for (const key of Object.keys(GYM_BY_NORM)) {
      if (key.indexOf(n) === 0 || n.indexOf(key) === 0) return GYM_BY_NORM[key];
    }
    return null;
  }

  const GYM_LIST_TTL = 24 * 60 * 60 * 1000;

  // torn/?selections=gyms is Public access, so any valid key works.
  function fetchGymIdMap(apiKey) {
    const cached = gmGetValue('gymlist', null);
    if (cached && cached.at && (Date.now() - cached.at) < GYM_LIST_TTL && cached.map) {
      return Promise.resolve(cached.map);
    }
    const url = 'https://api.torn.com/torn/?selections=gyms&key=' +
      encodeURIComponent(apiKey) + '&comment=200merits';
    return httpGet(url).then((res) => {
      let data;
      try { data = JSON.parse(res.responseText); } catch (e) { return null; }
      if (!data || data.error || !data.gyms) return null;
      const map = {};
      Object.keys(data.gyms).forEach((id) => {
        if (data.gyms[id] && data.gyms[id].name) map[id] = data.gyms[id].name;
      });
      gmSetValue('gymlist', { at: Date.now(), map: map });
      return map;
    }).catch(() => null);
  }

  function resolveActiveGym(activeGymId, idMap) {
    if (idMap && idMap[activeGymId]) {
      const byName = findGymByName(idMap[activeGymId]);
      if (byName) return byName;
    }
    // Fallback: IDs 1-24 are the standard progression, 1-based.
    const idx = Number(activeGymId) - 1;
    if (idx >= 0 && idx < STANDARD_GYMS.length) return STANDARD_GYMS[idx];
    return null;
  }

  // Community-documented specialty requirements. Not exposed by the API —
  // treated as a hint, never as fact.
  function specialtyEligible(gym, stats, unlockedIndex, ctx) {
    const s = stats || {};
    const c = ctx || {};
    const has = (name) => {
      const i = STANDARD_GYMS.findIndex((g) => g.name === name);
      return i >= 0 && unlockedIndex >= i;
    };
    const dominates = (key) => {
      const v = Number(s[key]) || 0;
      const rest = ['str', 'spd', 'def', 'dex']
        .filter((k) => k !== key)
        .map((k) => Number(s[k]) || 0);
      const second = Math.max.apply(null, rest);
      return v > 0 && second > 0 && v >= second * 1.25;
    };
    const pair = (a, b, c, d) => {
      const x = (Number(s[a]) || 0) + (Number(s[b]) || 0);
      const y = (Number(s[c]) || 0) + (Number(s[d]) || 0);
      return x > 0 && y > 0 && x >= y * 1.25;
    };
    switch (gym.name) {
      case 'Gym 3000': return has("George's") && dominates('str');
      case 'Mr. Isoyamas': return has("George's") && dominates('def');
      case 'Total Rebound': return has("George's") && dominates('spd');
      case 'Elites': return has("George's") && dominates('dex');
      case 'Balboas Gym': return has("Cha Cha's") && pair('def', 'dex', 'str', 'spd');
      case 'Frontline Fitness': return has("Cha Cha's") && pair('str', 'spd', 'def', 'dex');
      case 'Sports Science Lab':
        // Last Round unlocked AND never having taken Xanax or Ecstasy. The
        // drug counters come from personalstats; if we don't have them, stay
        // conservative and leave it out. Planning a run *with* Ecstasy also
        // rules it out.
        return has('Last Round') &&
          c.ecstasyMult <= 1 &&
          c.xanTaken === 0 &&
          c.extTaken === 0;
      default: return false; // Jail Gym: only while actually jailed.
    }
  }

  function accessibleGyms(activeGymName, stats, includeSpecialty, ctx) {
    if (!activeGymName) return GYMS.slice(); // nothing detected yet
    const active = findGymByName(activeGymName);
    const activeIdx = active ? STANDARD_GYMS.findIndex((g) => g.name === active.name) : -1;
    // Standing in a specialty gym implies the standard list is exhausted.
    const unlockedIndex = activeIdx >= 0 ? activeIdx : STANDARD_GYMS.length - 1;
    const out = STANDARD_GYMS.slice(0, unlockedIndex + 1);
    if (active && out.indexOf(active) === -1) out.push(active);
    if (includeSpecialty) {
      GYMS.filter((g) => g.specialty).forEach((g) => {
        if (out.indexOf(g) === -1 && specialtyEligible(g, stats, unlockedIndex, ctx)) out.push(g);
      });
    }
    return out;
  }

  // "Best gym" is decided by running the optimizer on each candidate, not
  // by comparing dots. A higher dot can still lose: a 50E gym wastes the
  // remainder of a stack that a 10E gym spends completely.
  function rankGyms(candidates, stat, cfg) {
    const consts = STAT_CONSTANTS[stat];
    const rows = [];
    candidates.forEach((gym) => {
      if (gym[stat] === null || gym[stat] === undefined) return;
      const r = optimize({
        statTotal: cfg.statTotal,
        selectedStat: stat,
        gym: gym,
        A: consts.A,
        B: consts.B,
        mult: cfg.mult,
        privateIslandHappy: cfg.privateIslandHappy,
        candyCount: cfg.candyCount,
        happyPerCandy: cfg.happyPerCandy,
        evlHappy: cfg.evlHappy,
        ecstasyMult: cfg.ecstasyMult,
        stackedEnergy: cfg.stackedEnergy,
        consoleHappyPerUse: cfg.consoleHappyPerUse,
        consoleEnergyPerUse: cfg.consoleEnergyPerUse,
        scanStep: cfg.scanStep,
      });
      rows.push({ gym: gym, gained: r.best.gained, converted: r.best.converted });
    });
    rows.sort((a, b) => b.gained - a.gained);
    return rows;
  }

  /* ------------------------------------------------------------------ *
   * Torn API autofill
   * ------------------------------------------------------------------ */
  const PCT_RE = /(\d+(?:\.\d+)?)\s*%/;

  // Does this perk string describe a gym-gains bonus?
  //
  // Torn writes these as "gym gains" or "gains in the Gym", e.g.
  //   "Increases speed gym gains by 20%"        (faction Steadfast)
  //   "1% bonus to speed gains in the gym"      (Sports Science education)
  //   "Increases speed gains in the gym by 5%"  (Sports Sneakers enhancer)
  //   "Increases all gym gains by 20% for 31 days" (books)
  // "training gains" is also accepted for anything worded that way.
  // Gym *experience* perks are excluded — they don't affect stat gains.
  function isGymGainPerk(s) {
    if (s.indexOf('gain') === -1) return false;
    if (s.indexOf('experience') !== -1) return false;
    return s.indexOf('gym') !== -1 || s.indexOf('training') !== -1;
  }

  // Sum gym-gain perk percentages in a list.
  // Returns { general, statSpecific } for the selected stat; perks that
  // mention a different stat are excluded.
  function parsePerkList(list, selectedStat) {
    let general = 0;
    let statSpecific = 0;
    const selWord = STAT_WORDS[selectedStat];
    const otherWords = Object.values(STAT_WORDS).filter((w) => w !== selWord);
    for (const raw of Array.isArray(list) ? list : []) {
      const s = String(raw).toLowerCase();
      if (!isGymGainPerk(s)) continue;
      const m = s.match(PCT_RE);
      if (!m) continue;
      const pct = parseFloat(m[1]);
      const mentionsSel = s.includes(selWord);
      const mentionsOther = otherWords.some((w) => s.includes(w));
      if (mentionsOther && !mentionsSel) continue; // different stat → exclude
      if (mentionsSel) statSpecific += pct;
      else general += pct;
    }
    return { general, statSpecific };
  }

  function autofillFromApi(apiKey) {
    const url =
      'https://api.torn.com/user/?selections=battlestats,bars,perks,gym,personalstats' +
      '&key=' + encodeURIComponent(apiKey) +
      '&comment=200merits';
    return httpGet(url).then((res) => {
      let data;
      try { data = JSON.parse(res.responseText); }
      catch (e) { throw new Error('Torn API returned an unreadable response.'); }
      if (data.error) {
        const code = data.error.code;
        if ([1, 2, 10, 13, 18].includes(code)) gmDeleteValue('apikey');
        throw new Error('Torn API error ' + code + ': ' + data.error.error);
      }

      const stat = settings.selectedStat;
      const statField = STAT_WORDS[stat]; // strength / speed / defense / dexterity
      if (typeof data[statField] === 'number') settings.statTotal = data[statField];
      // Keep all four stats: the specialty-gym ratio gates need them.
      ['str', 'spd', 'def', 'dex'].forEach((k) => {
        const v = data[STAT_WORDS[k]];
        if (typeof v === 'number') settings.stats[k] = v;
      });
      if (data.energy && typeof data.energy.current === 'number') settings.stackedEnergy = data.energy.current;
      if (data.happy && typeof data.happy.maximum === 'number') settings.privateIslandHappy = data.happy.maximum;
      // Sports Science Lab is gated on never having used Xanax or Ecstasy.
      if (data.personalstats) {
        const ps = data.personalstats;
        if (typeof ps.xantaken === 'number') settings.drugs.xan = ps.xantaken;
        if (typeof ps.exttaken === 'number') settings.drugs.ext = ps.exttaken;
      }

      const property = parsePerkList(data.property_perks, stat);
      const job = parsePerkList(data.job_perks, stat);
      const book = parsePerkList(data.book_perks, stat);
      const enhancer = parsePerkList(data.enhancer_perks, stat);
      const education = parsePerkList(data.education_perks, stat);
      const faction = parsePerkList(data.faction_perks, stat);

      settings.perks.property = property.general + property.statSpecific;
      settings.perks.job = job.general + job.statSpecific;
      settings.perks.book = book.general + book.statSpecific;
      settings.perks.uniform = enhancer.general + enhancer.statSpecific;
      settings.perks.educationGeneral = education.general;
      settings.perks.educationStat = education.statSpecific;
      settings.perks.steadfast = faction.statSpecific;

      saveSettings();

      // Resolve the active gym. `active_gym` is an ID, so map it to a name
      // via torn/?selections=gyms (Public access, cached for 24h).
      if (typeof data.active_gym === 'undefined' || data.active_gym === null) {
        return { activeGym: null };
      }
      return fetchGymIdMap(apiKey).then((map) => {
        const g = resolveActiveGym(data.active_gym, map);
        if (g) {
          settings.activeGymName = g.name;
          saveSettings();
        }
        return { activeGym: g };
      });
    });
  }

  /* ------------------------------------------------------------------ *
   * Styles — mobile first. Dark-mode aware via custom properties.
   * ------------------------------------------------------------------ */
  gmAddStyle(`
:root {
  --cj-bg: #ffffff; --cj-bg2: #f4f4f4; --cj-text: #222222; --cj-muted: #6a6a6a;
  --cj-border: #d4d4d4; --cj-accent: #1a6b34; --cj-accent-text: #ffffff;
  --cj-card: #f7f7f7; --cj-good: #1a6b34; --cj-err: #c0392b;
}
body.dark-mode {
  --cj-bg: #2e2e2e; --cj-bg2: #262626; --cj-text: #d5d5d5; --cj-muted: #9a9a9a;
  --cj-border: #454545; --cj-accent: #74c365; --cj-accent-text: #191919;
  --cj-card: #383838; --cj-good: #74c365; --cj-err: #e57373;
}
#cj-launcher {
  display: block; width: 100%; margin: 8px 0; padding: 12px;
  border: none; border-radius: 6px; background: var(--cj-accent);
  color: var(--cj-accent-text); font-size: 15px; font-weight: bold; cursor: pointer;
}
#cj-overlay {
  position: fixed; inset: 0; z-index: 999999;
  background: rgba(0,0,0,0.65); overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  display: flex; justify-content: center; align-items: flex-start;
}
#cj-modal {
  background: var(--cj-bg); color: var(--cj-text);
  width: 100%; max-width: 480px; min-height: 100%;
  font-size: 14px; line-height: 1.35;
}
/* --- sticky top: title + the answer, always on screen --- */
#cj-top { position: sticky; top: 0; z-index: 2; background: var(--cj-bg);
  border-bottom: 1px solid var(--cj-border); }
#cj-head { display: flex; align-items: center; justify-content: space-between;
  padding: 10px 14px; background: var(--cj-bg2); }
#cj-title { font-size: 15px; font-weight: bold; }
#cj-head-actions { display: flex; align-items: center; gap: 6px; }
#cj-top-autofill {
  height: 38px; padding: 0 16px; border: none; border-radius: 6px;
  background: var(--cj-accent); color: var(--cj-accent-text);
  font-size: 14px; font-weight: bold; cursor: pointer; white-space: nowrap;
}
#cj-top-autofill:disabled { opacity: 0.6; }
#cj-close { background: none; border: none; color: var(--cj-text);
  font-size: 22px; line-height: 1; padding: 6px 10px; margin: -6px -10px -6px 0;
  cursor: pointer; }
#cj-answer { padding: 12px 14px 10px; }
.cj-a-main { font-size: 22px; font-weight: bold; }
.cj-a-main b { color: var(--cj-good); }
.cj-a-sub { color: var(--cj-muted); margin-top: 2px; }
.cj-a-tiles { display: flex; gap: 8px; margin-top: 10px; }
.cj-a-tile { flex: 1 1 0; background: var(--cj-card); border-radius: 6px; padding: 7px 9px; }
.cj-a-tile span { display: block; font-size: 11px; color: var(--cj-muted); }
.cj-a-tile b { font-size: 16px; }
.cj-a-fine { font-size: 11px; color: var(--cj-muted); margin-top: 8px; }
.cj-a-err { color: var(--cj-err); font-weight: bold; }
/* --- collapsible sections --- */
.cj-body { padding: 0 0 4px; }
.cj-sec { border-bottom: 1px solid var(--cj-border); }
.cj-sec > summary {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 13px 14px; cursor: pointer; list-style: none; min-height: 22px;
}
.cj-sec > summary::-webkit-details-marker { display: none; }
.cj-sec-n { font-weight: bold; }
.cj-sec-n::before { content: '+'; display: inline-block; width: 14px;
  color: var(--cj-muted); font-weight: normal; }
.cj-sec[open] .cj-sec-n::before { content: '\\2212'; }
.cj-sec-v { color: var(--cj-muted); font-size: 12px; text-align: right; }
.cj-sec-body { padding: 0 14px 14px; }
/* --- fields: one column on phones, two when there is room --- */
.cj-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 420px) { .cj-grid { grid-template-columns: 1fr 1fr; } }
.cj-field { display: flex; flex-direction: column; min-width: 0; }
.cj-field.cj-wide { grid-column: 1 / -1; }
.cj-field label { font-size: 11px; color: var(--cj-muted); margin-bottom: 3px; }
.cj-field input, .cj-field select {
  background: var(--cj-bg2); color: var(--cj-text);
  border: 1px solid var(--cj-border); border-radius: 6px;
  padding: 0 10px; height: 44px; width: 100%; box-sizing: border-box;
  font-size: 16px; /* 16px stops iOS zooming in on focus */
}
.cj-note { font-size: 11px; color: var(--cj-muted); margin-top: 8px; }
.cj-access { font-size: 11px; color: var(--cj-good); margin-top: 8px; }
.cj-checks { display: flex; flex-direction: column; gap: 2px; margin-top: 10px; }
.cj-checks label { display: flex; align-items: center; gap: 8px;
  font-size: 12px; color: var(--cj-text); padding: 7px 0; cursor: pointer; }
.cj-checks input { width: 20px; height: 20px; flex: none; }
#cj-apikey { -webkit-text-security: disc; }
#cj-save-api { width: 100%; height: 44px; margin-top: 10px; border: none;
  border-radius: 6px; background: var(--cj-accent); color: var(--cj-accent-text);
  font-size: 15px; font-weight: bold; cursor: pointer; }
.cj-status { font-size: 12px; color: var(--cj-muted); margin-top: 8px; }
.cj-error { color: var(--cj-err); }
.cj-footer { padding: 12px 14px 20px; text-align: center; font-size: 12px; }
.cj-footer a { color: var(--cj-accent); text-decoration: none; }
@media (min-width: 560px) {
  #cj-overlay { padding: 24px 12px; align-items: flex-start; }
  #cj-modal { min-height: 0; border-radius: 8px; overflow: hidden;
    border: 1px solid var(--cj-border); }
}
`);

  /* ------------------------------------------------------------------ *
   * UI
   * ------------------------------------------------------------------ */
  const fmtInt = (n) => Math.round(n).toLocaleString();
  const fmtNum = (n) => n.toLocaleString(undefined, { maximumFractionDigits: 2 });
  const fmtGain = (n) => (n >= 1000 ? fmtInt(n) : fmtNum(n));

  function numField(id, label, value, attrs, wide) {
    return '<div class="cj-field' + (wide ? ' cj-wide' : '') + '">' +
      '<label for="' + id + '">' + escapeHtml(label) + '</label>' +
      '<input id="' + id + '" type="number" inputmode="decimal" ' + (attrs || '') +
      ' value="' + escapeHtml(String(value)) + '"></div>';
  }

  function selField(id, label, optionsHtml, wide) {
    return '<div class="cj-field' + (wide ? ' cj-wide' : '') + '">' +
      '<label for="' + id + '">' + escapeHtml(label) + '</label>' +
      '<select id="' + id + '">' + optionsHtml + '</select></div>';
  }

  function opts(list, selected) {
    return list.map((o) =>
      '<option value="' + escapeHtml(String(o.v)) + '"' +
      (String(o.v) === String(selected) ? ' selected' : '') + '>' +
      escapeHtml(o.t) + '</option>'
    ).join('');
  }

  function section(key, name, bodyHtml) {
    return '<details class="cj-sec" id="cj-sec-' + key + '"' +
      (settings.openSections[key] ? ' open' : '') + '>' +
      '<summary><span class="cj-sec-n">' + escapeHtml(name) + '</span>' +
      '<span class="cj-sec-v" id="cj-sum-' + key + '"></span></summary>' +
      '<div class="cj-sec-body">' + bodyHtml + '</div></details>';
  }

  function buildModal() {
    const overlay = document.createElement('div');
    overlay.id = 'cj-overlay';

    const gymBody =
      '<div class="cj-grid">' +
      selField('cj-stat', 'Stat', opts(
        Object.keys(STAT_LABELS).map((k) => ({ v: k, t: STAT_LABELS[k] })), settings.selectedStat)) +
      numField('cj-statTotal', 'Current stat total', settings.statTotal, 'min="0" step="1"') +
      selField('cj-gym', 'Gym', GYMS.map((g) =>
        '<option value="' + escapeHtml(g.name) + '"' + (g.name === settings.gymName ? ' selected' : '') +
        '>' + escapeHtml(g.name + (g.specialty ? ' *' : '') + ' (' + g.energy + 'E)') + '</option>'
      ).join(''), true) +
      '</div>' +
      '<div class="cj-checks">' +
      '<label><input type="checkbox" id="cj-autogym"' + (settings.autoPickGym ? ' checked' : '') +
      '> Auto-pick the best gym I can reach</label>' +
      '<label><input type="checkbox" id="cj-specialty"' + (settings.includeSpecialty ? ' checked' : '') +
      '> Include specialty gyms I may qualify for</label>' +
      '</div>' +
      '<div class="cj-access" id="cj-gym-access"></div>' +
      '<div class="cj-note" id="cj-gym-note"></div>';

    const happyBody =
      '<div class="cj-grid">' +
      numField('cj-piHappy', 'Private Island Happy', settings.privateIslandHappy, 'min="0" step="1"') +
      selField('cj-ecstasy', 'Ecstasy', opts(
        [{ v: 1, t: 'No' }, { v: 2, t: 'Yes (×2)' }], settings.ecstasyMult)) +
      selField('cj-candyType', 'Candy (' + CANDY_COUNT + ' eaten)', opts(
        CANDIES.map((c) => ({ v: c.name, t: c.name + ' — ' + c.happy })), settings.candyName), true) +
      selField('cj-evl', 'Using EVL Stock Reward?', opts(
        EVL_TIERS.map((t) => ({ v: t.happy, t: t.label + ' — ' + t.happy + ' Happy' })), settings.evlHappy), true) +
      '</div>' +
      '<div class="cj-note" id="cj-happy-note"></div>';

    const energyBody =
      '<div class="cj-grid">' +
      numField('cj-energy', 'Stacked energy', settings.stackedEnergy, 'min="0" step="1"', true) +
      selField('cj-shopJob', 'Work at a 5 Star Game / Toy Shop?', opts([
        { v: CONSOLE_HAPPY_NO, t: 'No — ' + CONSOLE_HAPPY_NO + ' Happy per use' },
        { v: CONSOLE_HAPPY_YES, t: 'Yes — ' + CONSOLE_HAPPY_YES + ' Happy per use' },
      ], settings.consoleHappyPerUse), true) +
      '</div>' +
      '<div class="cj-note" id="cj-console-note"></div>';

    const perksBody =
      '<div class="cj-grid">' +
      numField('cj-p-property', 'Property %', settings.perks.property, 'min="0" step="0.1"') +
      numField('cj-p-job', 'Job %', settings.perks.job, 'min="0" step="0.1"') +
      numField('cj-p-book', 'Book %', settings.perks.book, 'min="0" step="0.1"') +
      numField('cj-p-uniform', 'Uniform / Sports Sneakers %', settings.perks.uniform, 'min="0" step="0.1"') +
      numField('cj-p-steadfast', 'Faction Steadfast %', settings.perks.steadfast, 'min="0" step="0.1"') +
      numField('cj-p-eduGen', 'Education, general %', settings.perks.educationGeneral, 'min="0" step="0.1"') +
      numField('cj-p-eduStat', 'Education, stat-specific %', settings.perks.educationStat, 'min="0" step="0.1"') +
      '</div>' +
      '<div class="cj-note">Perks multiply, they do not add. Multiplier: <b id="cj-mult"></b></div>';

    const apiBody =
      '<div class="cj-grid">' +
      '<div class="cj-field cj-wide"><label for="cj-apikey">API key — stored on this device, sent only to api.torn.com</label>' +
      '<input id="cj-apikey" type="text" inputmode="latin" autocomplete="off" autocapitalize="off" ' +
      'autocorrect="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" maxlength="16"></div>' +
      '</div>' +
      '<button id="cj-save-api">Save API</button>' +
      '<div class="cj-status" id="cj-api-status"></div>';

    overlay.innerHTML =
      '<div id="cj-modal">' +
      '<div id="cj-top">' +
      '<div id="cj-head"><span id="cj-title">Console Jump</span>' +
      '<span id="cj-head-actions">' +
      '<button id="cj-top-autofill">Autofill</button>' +
      '<button id="cj-close" aria-label="Close">✕</button>' +
      '</span></div>' +
      '<div id="cj-answer"></div>' +
      '</div>' +
      '<div class="cj-body">' +
      section('gym', 'Stat & Gym', gymBody) +
      section('happy', 'Happy', happyBody) +
      section('energy', 'Energy & Console', energyBody) +
      section('perks', 'Perks', perksBody) +
      section('api', 'Torn API', apiBody) +
      '</div>' +
      '<div class="cj-footer">' +
      '<div class="cj-note">Expected averages — Torn rolls randomness into every train.</div>' +
      '<a href="https://www.torn.com/profiles.php?XID=4347781" target="_blank" rel="noopener">' +
      '💊 Send a Xanax to Microddot [4347781] if you enjoy this script</a></div>' +
      '</div>';

    document.body.appendChild(overlay);
    const $ = (id) => overlay.querySelector('#' + id);

    const storedKey = gmGetValue('apikey', '');
    if (HAS_PDA_KEY) {
      $('cj-apikey').value = PDA_API_KEY;
      $('cj-api-status').textContent = 'Torn PDA key detected.';
    } else if (storedKey) {
      $('cj-apikey').value = storedKey;
    }

    function readInputs() {
      const num = (id, def) => {
        const v = parseFloat($(id).value);
        return Number.isFinite(v) ? v : def;
      };
      settings.selectedStat = $('cj-stat').value;
      settings.gymName = $('cj-gym').value;
      settings.statTotal = num('cj-statTotal', DEFAULTS.statTotal);
      settings.perks.property = num('cj-p-property', 0);
      settings.perks.educationGeneral = num('cj-p-eduGen', 0);
      settings.perks.job = num('cj-p-job', 0);
      settings.perks.book = num('cj-p-book', 0);
      settings.perks.uniform = num('cj-p-uniform', 0);
      settings.perks.steadfast = num('cj-p-steadfast', 0);
      settings.perks.educationStat = num('cj-p-eduStat', 0);
      settings.privateIslandHappy = num('cj-piHappy', DEFAULTS.privateIslandHappy);
      settings.candyName = $('cj-candyType').value;
      settings.happyPerCandy = findCandy(settings.candyName).happy;
      settings.evlHappy = parseInt($('cj-evl').value, 10) || 0;
      settings.ecstasyMult = parseInt($('cj-ecstasy').value, 10) || 1;
      settings.stackedEnergy = num('cj-energy', DEFAULTS.stackedEnergy);
      settings.consoleHappyPerUse = parseInt($('cj-shopJob').value, 10) === CONSOLE_HAPPY_NO
        ? CONSOLE_HAPPY_NO : CONSOLE_HAPPY_YES;
      settings.autoPickGym = $('cj-autogym').checked;
      settings.includeSpecialty = $('cj-specialty').checked;
      ['gym', 'happy', 'energy', 'perks', 'api'].forEach((k) => {
        settings.openSections[k] = $('cj-sec-' + k).open;
      });
      applyFixed();
      saveSettings();
    }

    function writeInputs() {
      $('cj-stat').value = settings.selectedStat;
      $('cj-gym').value = settings.gymName;
      $('cj-statTotal').value = settings.statTotal;
      $('cj-p-property').value = settings.perks.property;
      $('cj-p-eduGen').value = settings.perks.educationGeneral;
      $('cj-p-job').value = settings.perks.job;
      $('cj-p-book').value = settings.perks.book;
      $('cj-p-uniform').value = settings.perks.uniform;
      $('cj-p-steadfast').value = settings.perks.steadfast;
      $('cj-p-eduStat').value = settings.perks.educationStat;
      $('cj-piHappy').value = settings.privateIslandHappy;
      $('cj-candyType').value = settings.candyName;
      $('cj-evl').value = String(settings.evlHappy);
      $('cj-ecstasy').value = String(settings.ecstasyMult);
      $('cj-energy').value = settings.stackedEnergy;
      $('cj-shopJob').value = String(settings.consoleHappyPerUse);
      $('cj-autogym').checked = !!settings.autoPickGym;
      $('cj-specialty').checked = !!settings.includeSpecialty;
    }

    // The key the next request should use: whatever is typed if it looks
    // valid, otherwise whatever was saved earlier (or injected by Torn PDA).
    function currentKey() {
      const typed = $('cj-apikey').value.trim();
      if (/^[A-Za-z0-9]{16}$/.test(typed)) return typed;
      const stored = String(gmGetValue('apikey', '') || '').trim();
      return /^[A-Za-z0-9]{16}$/.test(stored) ? stored : '';
    }

    function setApiStatus(msg, isError) {
      const el = $('cj-api-status');
      el.textContent = msg;
      if (isError) el.classList.add('cj-error');
      else el.classList.remove('cj-error');
    }

    // One tap: pull stats, energy, happy, perks and the active gym, then
    // re-run the optimizer. Errors pop the Torn API panel open so the
    // message is actually visible.
    function doAutofill() {
      const key = currentKey();
      const btn = $('cj-top-autofill');
      if (!key) {
        $('cj-sec-api').open = true;
        setApiStatus('Add your 16-character Torn API key below, then tap Save API.', true);
        $('cj-apikey').focus();
        render();
        return;
      }
      btn.disabled = true;
      btn.textContent = '…';
      setApiStatus('Fetching from api.torn.com…', false);
      readInputs(); // capture the selected stat before perks are parsed
      const done = () => { btn.disabled = false; };
      autofillFromApi(key)
        .then((info) => {
          writeInputs();
          setApiStatus('Autofilled.' +
            (info && info.activeGym ? ' Active gym: ' + info.activeGym.name + '.' : ' Gym not identified.'), false);
          btn.textContent = 'Updated';
          setTimeout(() => { btn.textContent = 'Autofill'; }, 1500);
          done();
          render();
        })
        .catch((err) => {
          $('cj-sec-api').open = true;
          setApiStatus(String(err.message || err), true);
          btn.textContent = 'Autofill';
          done();
          render();
        });
    }

    function render() {
      readInputs();
      const stat = settings.selectedStat;
      const consts = STAT_CONSTANTS[stat];
      const mult = perkMult(settings.perks);

      const cfg = {
        statTotal: settings.statTotal,
        mult: mult,
        privateIslandHappy: settings.privateIslandHappy,
        candyCount: settings.candyCount,
        happyPerCandy: settings.happyPerCandy,
        evlHappy: settings.evlHappy,
        ecstasyMult: settings.ecstasyMult,
        stackedEnergy: settings.stackedEnergy,
        consoleHappyPerUse: settings.consoleHappyPerUse,
        consoleEnergyPerUse: settings.consoleEnergyPerUse,
        scanStep: settings.scanStep,
      };

      // --- gym auto-pick ---
      const access = $('cj-gym-access');
      if (!settings.activeGymName) {
        access.textContent = 'Autofill to detect your gym and auto-pick the best one you can reach.';
      } else if (!settings.autoPickGym) {
        access.textContent = 'Your active gym: ' + settings.activeGymName + '.';
      } else {
        const ranked = rankGyms(
          accessibleGyms(settings.activeGymName, settings.stats, settings.includeSpecialty, {
            xanTaken: settings.drugs.xan, extTaken: settings.drugs.ext, ecstasyMult: settings.ecstasyMult,
          }), stat, cfg);
        if (!ranked.length) {
          access.textContent = 'None of the gyms you can reach train ' + STAT_LABELS[stat] + '.';
        } else {
          settings.gymName = ranked[0].gym.name;
          $('cj-gym').value = settings.gymName;
          saveSettings();
          // Short, and only mentions the active gym when you'd have to move.
          access.textContent = 'Best of ' + ranked.length + ' gyms you can reach: ' +
            ranked[0].gym.name + ' (' + ranked[0].gym[stat] + ' dots).' +
            (ranked[0].gym.name !== settings.activeGymName
              ? " You're in " + settings.activeGymName + '.' : '');
        }
      }

      const gym = GYMS.find((g) => g.name === settings.gymName) || GYMS[GYMS.length - 9];
      $('cj-gym-note').textContent = [gym.specialty ? 'Specialty gym.' : '', gym.note || ''].join(' ').trim();

      // --- live notes ---
      $('cj-mult').textContent = '×' + mult.toFixed(4);
      const candyHappy = CANDY_COUNT * settings.happyPerCandy;
      const happyParts = [
        CANDY_COUNT + ' × ' + settings.candyName + ' (' + settings.happyPerCandy + ') = ' + fmtInt(candyHappy),
        fmtInt(settings.privateIslandHappy) + ' Private Island',
      ];
      if (settings.evlHappy > 0) {
        happyParts.push(fmtInt(settings.evlHappy) + ' EVL ' + evlLabel(settings.evlHappy));
      }
      const baseHappy = settings.privateIslandHappy + candyHappy + settings.evlHappy;
      $('cj-happy-note').textContent = happyParts.join(' + ') + ' = ' + fmtInt(baseHappy) +
        ' Happy before the Console' + (settings.ecstasyMult > 1 ? ', doubled by Ecstasy.' : '.');
      $('cj-console-note').textContent = settings.consoleHappyPerUse + ' Happy per ' +
        CONSOLE_ENERGY_PER_USE + 'E use = ' + fmtNum(settings.consoleHappyPerUse / CONSOLE_ENERGY_PER_USE) +
        ' Happy per energy. Scanned every ' + SCAN_STEP + 'E.';

      // --- section summaries, so you can read them without opening --- 
      $('cj-sum-gym').textContent = STAT_LABELS[stat] + ' · ' + gym.name;
      $('cj-sum-happy').textContent = fmtInt(baseHappy * settings.ecstasyMult) + ' Happy' +
        (settings.ecstasyMult > 1 ? ' (E)' : '');
      $('cj-sum-energy').textContent = fmtInt(settings.stackedEnergy) + 'E · ' +
        fmtNum(settings.consoleHappyPerUse / CONSOLE_ENERGY_PER_USE) + ' Happy/E';
      $('cj-sum-perks').textContent = '×' + mult.toFixed(4);
      $('cj-sum-api').textContent = settings.activeGymName
        ? 'Connected' : (currentKey() ? 'Key saved' : 'No key');

      // --- the answer ---
      const answer = $('cj-answer');
      if (gym[stat] === null || gym[stat] === undefined) {
        answer.innerHTML = '<div class="cj-a-main cj-a-err">Can\'t train here</div>' +
          '<div class="cj-a-sub">' + escapeHtml(gym.name) + ' has no ' + STAT_LABELS[stat] +
          ' training. Pick another gym or stat.</div>';
        return;
      }

      const r = optimize(Object.assign({}, cfg, {
        selectedStat: stat, gym: gym, A: consts.A, B: consts.B,
      }));

      let main;
      let sub;
      if (r.best.converted === 0) {
        main = 'Convert nothing';
        sub = 'Train all ' + fmtInt(settings.stackedEnergy) + 'E — jumping costs more energy than it returns.';
      } else {
        main = 'Convert <b>' + fmtInt(r.best.converted) + 'E</b>';
        sub = 'then train ' + fmtInt(r.best.energyToTrain) + 'E · start Happy ' + fmtInt(r.best.startHappy) +
          (r.improvementPct < 1 ? ' · barely worth it' : '');
      }

      answer.innerHTML =
        '<div class="cj-a-main">' + main + '</div>' +
        '<div class="cj-a-sub">' + escapeHtml(sub) + '</div>' +
        '<div class="cj-a-tiles">' +
        '<div class="cj-a-tile"><span>Expected gain</span><b>' + escapeHtml(fmtGain(r.best.gained)) + '</b></div>' +
        '<div class="cj-a-tile"><span>vs no convert</span><b>+' + r.improvementPct.toFixed(1) + '%</b></div>' +
        '</div>' +
        '<div class="cj-a-fine">' + fmtInt(r.best.trains) + ' trains at ' + escapeHtml(gym.name) +
        ' · baseline ' + fmtInt(r.noConversionGain) + ' (+' + fmtInt(r.improvement) + ')</div>';
    }

    // Debounced re-render on any input change
    let renderTimer = null;
    const queue = (ms) => { clearTimeout(renderTimer); renderTimer = setTimeout(render, ms); };
    overlay.addEventListener('input', (e) => { if (e.target.id !== 'cj-apikey') queue(150); });
    overlay.addEventListener('change', (e) => { if (e.target.id !== 'cj-apikey') queue(50); });
    overlay.addEventListener('toggle', () => queue(0), true);

    // Choosing a gym by hand means you want that gym, not the auto-pick.
    $('cj-gym').addEventListener('change', () => {
      if ($('cj-autogym').checked) {
        $('cj-autogym').checked = false;
        settings.autoPickGym = false;
        saveSettings();
      }
    });

    // Save API just stores the key; the Autofill button up top does the work.
    $('cj-save-api').addEventListener('click', () => {
      const key = $('cj-apikey').value.trim();
      if (!/^[A-Za-z0-9]{16}$/.test(key)) {
        setApiStatus('Enter a valid 16-character Torn API key.', true);
        return;
      }
      gmSetValue('apikey', key);
      setApiStatus('Key saved. Tap Autofill at the top to load everything.', false);
      render();
    });

    $('cj-top-autofill').addEventListener('click', doAutofill);

    $('cj-close').addEventListener('click', () => overlay.remove());
    overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });

    render();
    return overlay;
  }

  /* ------------------------------------------------------------------ *
   * Launcher injection on the gym page
   * ------------------------------------------------------------------ */
  function injectLauncher() {
    if (document.getElementById('cj-launcher')) return;
    const btn = document.createElement('button');
    btn.id = 'cj-launcher';
    btn.type = 'button';
    btn.textContent = '⚡ Console Jump Optimizer';
    btn.addEventListener('click', () => {
      if (!document.getElementById('cj-overlay')) buildModal();
    });
    const anchor =
      document.querySelector('.content-title') ||
      document.querySelector('#mainContainer .content-wrapper') ||
      document.body;
    if (anchor === document.body) {
      btn.style.position = 'fixed';
      btn.style.right = '10px';
      btn.style.bottom = '60px';
      btn.style.zIndex = '999998';
      document.body.appendChild(btn);
    } else {
      anchor.appendChild(btn);
    }
  }

  function waitForElement(selector, timeout) {
    return new Promise((resolve) => {
      const found = document.querySelector(selector);
      if (found) return resolve(found);
      const obs = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el) { obs.disconnect(); resolve(el); }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(() => { obs.disconnect(); resolve(null); }, timeout || 10000);
    });
  }

  waitForElement('.content-title, #mainContainer', 10000).then(injectLauncher);
  window.addEventListener('beforeunload', () => {
    const o = document.getElementById('cj-overlay');
    if (o) o.remove();
  });
})();