Torn Gym Planner

Gym gains calculator, happy jump planner, and logger for Torn City

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Torn Gym Planner
// @namespace    iSatomi
// @version      3.34
// @description  Gym gains calculator, happy jump planner, and logger for Torn City
// @author       iSatomi [3580191]
// @license      MIT
// @match        https://www.torn.com/gym.php*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // =========================================================================
    // SHARED CORE — API key, gmFetch, cross-page stat/perk cache
    // =========================================================================


    // ── Shared persistence (key = 'apiKey' for all modules) ──────────────────
    const _load = (k, d) => { const v = GM_getValue(k, null); return v !== null ? v : d; };
    const _save = (k, v) => GM_setValue(k, v);

    // Cross-page cache helpers — data written on one page, read on another
    const cache = {
        get: (k) => { try { const v = GM_getValue('aio_'+k, null); return v ? JSON.parse(v) : null; } catch(_){ return null; }},
        set: (k, v) => GM_setValue('aio_'+k, JSON.stringify(v)),
    };
    // Keys: apiKey, battlestats {str,spd,def,dex}, workstats {man,int,end},
    //       perks_raw (flat array of all perk strings), edu_reduction (number)

    // ── Shared gmFetch ────────────────────────────────────────────────────────
    function gmFetch(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method:"GET", url, timeout:10000,
                onload:    r => { try { resolve(JSON.parse(r.responseText)); } catch(e) { reject(e); }},
                onerror:   () => reject(new Error("Network error")),
                ontimeout: () => reject(new Error("Timeout")),
            });
        });
    }

    // ── Shared CSS base — all three modules use .t-wrap, .t-sec, .t-row etc ──
    // Module-specific styles are injected separately per module.
    document.head.insertAdjacentHTML('beforeend', `<style>
.t-wrap{margin:8px 0 12px;background:#181818;border:1px solid #333;border-radius:6px;font-family:Arial,sans-serif;font-size:14px;color:#ccc;overflow:hidden}
.t-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:linear-gradient(135deg,#222,#1a1a1a);border-bottom:1px solid #2a2a2a;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}
.t-hdr:active{background:#2a2a2a}
.t-title{font-size:15px;font-weight:bold;color:#e0e0e0}
.t-tog{font-size:16px;color:#555;transition:transform .2s}
.t-wrap.open .t-tog{transform:rotate(180deg)}
.t-body{display:none;padding:12px}
.t-wrap.open .t-body{display:block}
.t-sec{font-size:10px;font-weight:bold;color:#555;letter-spacing:.08em;text-transform:uppercase;margin:14px 0 6px;padding-bottom:4px;border-bottom:1px solid #252525}
.t-sec:first-child{margin-top:0}
.t-row{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 10px;margin-top:3px;border-radius:4px;background:#1e1e1e}
.t-rl{color:#778;font-size:12px;flex-shrink:0}
.t-rv{color:#dde;font-size:12px;text-align:right}
.t-row.g .t-rv{color:#7abf7a}.t-row.b .t-rv{color:#7a9acc}.t-row.r .t-rv{color:#bf7a7a}.t-row.a .t-rv{color:#bf9f5a}
.t-field{margin-bottom:8px}
.t-field label{display:block;font-size:12px;color:#888;margin-bottom:3px}
.t-field select,.t-field input[type=number],.t-field input[type=password],.t-field input[type=text]{width:100%;padding:8px 10px;background:#222;border:1px solid #383838;border-radius:4px;color:#e0e0e0;font-size:14px;box-sizing:border-box;-webkit-appearance:none;appearance:none}
.t-field select:focus,.t-field input:focus{outline:none;border-color:#555;background:#282828}
.t-sg{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.t-sg .t-field{margin-bottom:0}
.t-sg .t-field label{font-size:11px}
.t-btns{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.t-btn{flex:1;padding:10px 8px;border-radius:4px;border:1px solid #383838;background:#222;color:#ddd;font-size:13px;font-weight:bold;cursor:pointer;text-align:center;-webkit-tap-highlight-color:transparent;transition:background .15s;min-width:60px}
.t-btn:active{background:#2a2a3a}
.t-btn-g{background:#1a2518;border-color:#3a5030;color:#7abf7a}
.t-btn-b{background:#18182a;border-color:#3a3a60;color:#8a8aee}
.t-btn-c{background:#182028;border-color:#304060;color:#6aaade}
.t-status{display:none;margin-top:8px;padding:8px 10px;border-radius:4px;font-size:12px;line-height:1.5;word-break:break-word}
.t-status.ok{display:block;background:#182018;border:1px solid #2a4a2a;color:#7abf7a}
.t-status.err{display:block;background:#201818;border:1px solid #4a2828;color:#bf7a7a}
.t-status.warn{display:block;background:#1e1a10;border:1px solid #4a3a18;color:#bf9f5a}
.t-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;cursor:pointer;-webkit-tap-highlight-color:transparent;font-size:11px;font-weight:bold;color:#555;letter-spacing:.06em;text-transform:uppercase}
.t-coll-tog{font-size:12px;color:#444;transition:transform .2s}
.t-coll.open .t-coll-tog{transform:rotate(180deg)}
.t-coll-body{display:none;padding:8px 10px 10px}
.t-coll.open .t-coll-body{display:block}
.t-bar{height:6px;background:#1e1e2a;border-radius:3px;margin-top:8px;overflow:hidden}
.t-bar-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,#3a5aa0,#6a8acc)}
</style>`);




    // ─────────────────────────────────────────────────────────────────────────
    // GYM DATA — hardcoded fallback only. No separate API call needed.
    // Gym IDs match the Torn API user?selections=gym response (plain integer).
    // Dot values are the actual display values (API stores ×10, we store /10 already).
    // ─────────────────────────────────────────────────────────────────────────
    // Fallback gym data (API: gain/10, energy from energy_cost)
    // Format: { id, name, tier, energy, str, spd, def, dex }
    const GYMS_FALLBACK = [
        { id:1,  name:"Premier Fitness",    tier:"L", energy:5,  str:2.0, spd:2.0, def:2.0, dex:2.0 },
        { id:2,  name:"Average Joes",       tier:"L", energy:5,  str:2.4, spd:2.4, def:2.8, dex:2.4 },
        { id:3,  name:"Woody's Workout",    tier:"L", energy:5,  str:2.8, spd:3.2, def:3.0, dex:2.8 },
        { id:4,  name:"Beach Bods",         tier:"L", energy:5,  str:3.2, spd:3.2, def:3.2, dex:0   },
        { id:5,  name:"Silver Gym",         tier:"L", energy:5,  str:3.4, spd:3.6, def:3.4, dex:3.2 },
        { id:6,  name:"Pour Femme",         tier:"L", energy:5,  str:3.4, spd:3.6, def:3.6, dex:3.8 },
        { id:7,  name:"Davies Den",         tier:"L", energy:5,  str:3.7, spd:0,   def:3.7, dex:3.7 },
        { id:8,  name:"Global Gym",         tier:"L", energy:5,  str:4.0, spd:4.0, def:4.0, dex:4.0 },
        { id:9,  name:"Knuckle Heads",      tier:"M", energy:10, str:4.8, spd:4.4, def:4.0, dex:4.2 },
        { id:10, name:"Pioneer Fitness",    tier:"M", energy:10, str:4.4, spd:4.6, def:4.8, dex:4.4 },
        { id:11, name:"Anabolic Anomalies", tier:"M", energy:10, str:5.0, spd:4.6, def:5.2, dex:4.6 },
        { id:12, name:"Core",               tier:"M", energy:10, str:5.0, spd:5.2, def:5.0, dex:5.0 },
        { id:13, name:"Racing Fitness",     tier:"M", energy:10, str:5.0, spd:5.4, def:4.8, dex:5.2 },
        { id:14, name:"Complete Cardio",    tier:"M", energy:10, str:5.5, spd:5.8, def:5.5, dex:5.2 },
        { id:15, name:"Legs, Bums and Tums",tier:"M", energy:10, str:0,   spd:5.6, def:5.6, dex:5.8 },
        { id:16, name:"Deep Burn",          tier:"M", energy:10, str:6.0, spd:6.0, def:6.0, dex:6.0 },
        { id:17, name:"Apollo Gym",         tier:"H", energy:10, str:6.0, spd:6.2, def:6.4, dex:6.2 },
        { id:18, name:"Gun Shop",           tier:"H", energy:10, str:6.6, spd:6.4, def:6.2, dex:6.2 },
        { id:19, name:"Force Training",     tier:"H", energy:10, str:6.4, spd:6.6, def:6.4, dex:6.8 },
        { id:20, name:"Cha Cha's",          tier:"H", energy:10, str:6.4, spd:6.4, def:6.8, dex:7.0 },
        { id:21, name:"Atlas",              tier:"H", energy:10, str:7.0, spd:6.4, def:6.4, dex:6.6 },
        { id:22, name:"Last Round",         tier:"H", energy:10, str:6.8, spd:6.6, def:7.0, dex:6.6 },
        { id:23, name:"The Edge",           tier:"H", energy:10, str:6.8, spd:7.0, def:7.0, dex:6.8 },
        { id:24, name:"George's",           tier:"H", energy:10, str:7.3, spd:7.3, def:7.3, dex:7.3 },
        { id:25, name:"Balboas Gym",        tier:"S", energy:25, str:0,   spd:0,   def:7.5, dex:7.5 },
        { id:26, name:"Frontline Fitness",  tier:"S", energy:25, str:7.5, spd:7.5, def:0,   dex:0   },
        { id:27, name:"Gym 3000",           tier:"S", energy:50, str:8.0, spd:0,   def:0,   dex:0   },
        { id:28, name:"Mr. Isoyamas",       tier:"S", energy:50, str:0,   spd:0,   def:8.0, dex:0   },
        { id:29, name:"Total Rebound",      tier:"S", energy:50, str:0,   spd:8.0, def:0,   dex:0   },
        { id:30, name:"Elites",             tier:"S", energy:50, str:0,   spd:0,   def:0,   dex:8.0 },
        { id:31, name:"Sports Science Lab", tier:"S", energy:25, str:9.0, spd:9.0, def:9.0, dex:9.0 },
        { id:32, name:"The Jail Gym",       tier:"J", energy:5,  str:3.4, spd:3.4, def:4.6, dex:0   },
    ];

    // Runtime gym list — starts as fallback, gets replaced by API data
    // One-time cache migration: clear old key if present
    if (GM_getValue("gymDataCache", null)) { GM_deleteValue("gymDataCache"); GM_deleteValue("gymDataCacheTs"); }

    let GYMS = GYMS_FALLBACK.slice();
    // Maps: id → gym, name.lower → gym
    let GYM_BY_ID   = {};
    let GYM_BY_NAME = {};

    function buildGymMaps() {
        GYM_BY_ID   = {};
        GYM_BY_NAME = {};
        for (const g of GYMS) {
            GYM_BY_ID[g.id] = g;
            GYM_BY_NAME[g.name.toLowerCase()] = g;
        }
        // Debug: log any gym with very high dots (> Sports Science Lab 9.0)
        const suspicious = GYMS.filter(g => Math.max(g.str||0,g.spd||0,g.def||0,g.dex||0) > 9.5);
        if (suspicious.length) {
            console.warn('[AIO Debug] Gyms with >9.5 dots:', JSON.stringify(suspicious));
        }
    }
    buildGymMaps();

    // ─────────────────────────────────────────────────────────────────────────
    // ALL-STATS STORE — populated by fetchFromAPI, used for specialist eligibility
    // ─────────────────────────────────────────────────────────────────────────
    let allStats = { strength:0, speed:0, defense:0, dexterity:0 };
    // Restore battle stats from cross-page cache if available
    (function() {
        const bs = cache.get('battlestats');
        if (bs) { allStats = bs; }
    })();


    // Set of gym IDs the user has actually unlocked (purchased membership).
    // Populated by scraping the gym page DOM — zero API calls needed.
    // Falls back to null (unknown) if DOM scrape fails.
    // Standard gyms unlock sequentially — if user is at gym N, they've unlocked all standard
    // gyms up to and including N. Specialist gyms require separate purchase.
    // STANDARD_UNLOCK_ORDER maps gym id → sequential position (1=first gym, 24=George's)
    const STANDARD_UNLOCK_ORDER = {
        1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8,      // L tier
        9:9, 10:10, 11:11, 12:12, 13:13, 14:14, 15:15, 16:16,  // M tier
        17:17, 18:18, 19:19, 20:20, 21:21, 22:22, 23:23, 24:24, // H tier
        32:0, // Jail gym — always accessible in jail
    };
    // Specialist gym IDs (purchased separately, not in the sequential chain)
    const SPECIALIST_IDS = new Set([25, 26, 27, 28, 29, 30, 31]);

    // Specialist unlock requirements — stat ratio conditions
    const SPECIALIST_REQS = {
        25: { // Balboas Gym — DEF+DEX ≥25% > STR+SPD, needs Cha Cha's (id 20) unlocked
            prereqId: 20,
            check: s => (s.defense + s.dexterity) >= (s.strength + s.speed) * 1.25,
            desc: "(DEF+DEX) must be ≥25% more than (STR+SPD) · Needs Cha Cha's",
        },
        26: { // Frontline Fitness — STR+SPD ≥25% > DEF+DEX, needs Cha Cha's
            prereqId: 20,
            check: s => (s.strength + s.speed) >= (s.defense + s.dexterity) * 1.25,
            desc: "(STR+SPD) must be ≥25% more than (DEF+DEX) · Needs Cha Cha's",
        },
        27: { // Gym 3000 — STR ≥25% > 2nd highest, needs George's (id 24)
            prereqId: 24,
            check: s => { const rest = [s.speed,s.defense,s.dexterity].sort((a,b)=>b-a); return s.strength >= rest[0]*1.25; },
            desc: "STR must be ≥25% above 2nd highest stat · Needs George's",
        },
        28: { // Mr. Isoyamas — DEF ≥25% > 2nd highest, needs George's
            prereqId: 24,
            check: s => { const rest = [s.strength,s.speed,s.dexterity].sort((a,b)=>b-a); return s.defense >= rest[0]*1.25; },
            desc: "DEF must be ≥25% above 2nd highest stat · Needs George's",
        },
        29: { // Total Rebound — SPD ≥25% > 2nd highest, needs George's
            prereqId: 24,
            check: s => { const rest = [s.strength,s.defense,s.dexterity].sort((a,b)=>b-a); return s.speed >= rest[0]*1.25; },
            desc: "SPD must be ≥25% above 2nd highest stat · Needs George's",
        },
        30: { // Elites — DEX ≥25% > 2nd highest, needs George's
            prereqId: 24,
            check: s => { const rest = [s.strength,s.speed,s.defense].sort((a,b)=>b-a); return s.dexterity >= rest[0]*1.25; },
            desc: "DEX must be ≥25% above 2nd highest stat · Needs George's",
        },
        31: { // Sports Science Lab — needs Last Round (id 22), drug-limited
            prereqId: 22,
            check: () => true,
            desc: "Needs Last Round unlocked · ≤50 Xanax and ≤50 Ecstasy lifetime",
            drugLimited: true,
        },
    };

    // Check if a gym is accessible given what we know about unlocks + stats
    // Returns { state: 'unlocked'|'ratio'|'prereq', reason, purchased }

    // Rank all gyms for a given stat — split into unlocked, purchasable (ratio met), and locked

    // ─────────────────────────────────────────────────────────────────────────
    // GYM DATA — fetched from Torn API (torn/?selections=gyms) on autofill,
    // cached 24h. Fallback table used only if API hasn't been called yet.
    // The fallback DEX column is approximate — API values are authoritative.
    // ─────────────────────────────────────────────────────────────────────────
    async function loadGymsFromAPI(key) {
        if (!key || key.length !== 16) return false;

        // Use cache if fresh (24h)
        const cacheTs   = _load("gymDataCacheTs_v2", 0);
        const cacheData = _load("gymDataCache_v2", null);
        if (cacheData && (Date.now() - cacheTs) < 86400000) {
            try {
                const cached = JSON.parse(cacheData);
                if (cached?.length > 20) {
                    GYMS = cached;
                    buildGymMaps();
                    rebuildGymDropdown();
                    return true;
                }
            } catch(_) { /* stale/corrupt cache */ }
        }

        try {
            const data = await gmFetch(`https://api.torn.com/torn/?selections=gyms&key=${key}&comment=GymGains`);
            if (data?.error) return false;
            if (!data?.gyms) return false;

            // API returns: { "13": { id:13, name:"Racing Fitness", energy_cost:10,
            //   strength:50, speed:54, defense:48, dexterity:52 }, ... }
            // Gains stored as *10 in API, divide by 10 to get actual dots
            // Build from API data directly — don't filter to fallback only,
            // so new gyms added to Torn are included automatically
            const fallbackById = {};
            GYMS_FALLBACK.forEach(g => { fallbackById[g.id] = g; });

            const updated = Object.entries(data.gyms).map(([idStr, apiGym]) => {
                const id       = parseInt(idStr);
                const fallback = fallbackById[id];
                // Determine tier from fallback if known, else guess from energy cost
                const energy   = apiGym.energy_cost ?? fallback?.energy ?? 10;
                const tier     = fallback?.tier ?? (energy >= 50 ? 'S' : energy >= 25 ? 'S' : energy >= 10 ? 'H' : 'L');
                return {
                    id,
                    name:   apiGym.name   || fallback?.name   || `Gym ${id}`,
                    tier,
                    energy,
                    str: apiGym.strength  != null ? apiGym.strength  / 10 : (fallback?.str ?? 0),
                    spd: apiGym.speed     != null ? apiGym.speed     / 10 : (fallback?.spd ?? 0),
                    def: apiGym.defense   != null ? apiGym.defense   / 10 : (fallback?.def ?? 0),
                    dex: apiGym.dexterity != null ? apiGym.dexterity / 10 : (fallback?.dex ?? 0),
                };
            }).filter(g => {
                if (!(g.str || g.spd || g.def || g.dex)) return false; // trains nothing
                // Skip gyms with suspiciously high dots that we can't identify
                // (likely API data artifacts or test gyms) — cap at 10 dots
                const maxDots = Math.max(g.str||0, g.spd||0, g.def||0, g.dex||0);
                if (maxDots > 10) { console.log('[AIO] Skipping implausible gym:', g); return false; }
                return true;
            });

            // Jail gym (id:32) reports inflated stats (10.0 dots) from API — always use fallback
            const jailFallback = GYMS_FALLBACK.find(g => g.id === 32);
            const jailIdx = updated.findIndex(g => g.id === 32);
            if (jailIdx >= 0) updated[jailIdx] = jailFallback;  // replace API version with fallback
            else if (jailFallback) updated.push(jailFallback);   // add if missing
            updated.sort((a, b) => a.id - b.id);

            GYMS = updated;
            buildGymMaps();
            rebuildGymDropdown();
            _save("gymDataCache_v2", JSON.stringify(GYMS));
            _save("gymDataCacheTs_v2", Date.now());
            return true;
        } catch(_) {
            return false;
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // CONSTANTS
    // ─────────────────────────────────────────────────────────────────────────
    const STAT_KEYS   = { strength:"str", speed:"spd", defense:"def", dexterity:"dex" };
    const STAT_LABELS = { strength:"Strength", speed:"Speed", defense:"Defense", dexterity:"Dexterity" };
    const STAT_ABBREV = { strength:"STR", speed:"SPD", defense:"DEF", dexterity:"DEX" };

    // Vladar's formula constants per stat (post stat-cap-removal, still uses S in formula uncapped)
    // dS = (S*ROUND(1+0.07*ROUND(LN(1+H/250),4),4) + 8*H^1.05 + (1-(H/99999)^2)*A + B)
    //      * (1/200000) * G * E * perks
    // Note: The 50M cap in the formula term was removed Aug 2022 — use actual stat value
    const STAT_CONSTS = {
        strength:  { A:1600, B:1700,  C:700  },
        speed:     { A:1600, B:2000,  C:1350 },
        dexterity: { A:1800, B:1500,  C:1000 },
        defense:   { A:2100, B:-600,  C:1500 },
    };

    const NATURAL_E  = { no:20,  yes:30  };
    const E_CAP      = { no:100, yes:150 };
    const MAX_ITER   = 10000;
    const XAN_CD_AVG = 7, XAN_CD_MIN = 6, XAN_CD_MAX = 8;
    const EDVD_CD = 6, EDVD_HAPPY = 2500;
    const FHC_HAPPY = 500;   // Feathery Hotel Coupon: refills energy + +500 happy
    const FHC_CD    = 6;     // hours of booster CD per FHC
    const FHC_MAX   = 5;     // max FHCs in 30h CD window
    const ITEM_IDS  = { xanax:206, edvd:540, ecstasy:203, fhc:367, lsd:415 };

    // ── Tuning constants ─────────────────────────────────────────────────────
    const FORMULA_CORRECTION = 1.027;  // empirical correction from 34 logged sessions
    const LOG_CLICK_GATE_MS  = 8000;   // window after TRAIN click during which we accept a log
    const LOG_DEDUPE_MS      = 30000;  // suppress duplicate logs within this window
    const OUTLIER_THRESHOLD  = 0.50;   // entries with >50% prediction error excluded from calibration
    const JUMP_HAPPY_RATIO   = 1.5;    // happy / propHappy ratio that classifies a session as a jump
    // Energy can types — IDs resolved at runtime via Torn API items lookup
    // Name must exactly match the Torn item name (after "Can of " prefix)
    const CAN_TYPES = [
        { label:"Goose Juice",     name:"Can of Goose Juice",     e:5,  id:1,    cd:2 },
        { label:"Damp Valley",     name:"Can of Damp Valley",     e:10, id:68,   cd:2 },
        { label:"Crocozade",       name:"Can of Crocozade",       e:15, id:69,   cd:2 },
        { label:"Munster",         name:"Can of Munster",         e:20, id:372,  cd:2 },
        { label:"Santa Shooters",  name:"Can of Santa Shooters",  e:20, id:1020, cd:2 },
        { label:"Red Cow",         name:"Can of Red Cow",         e:25, id:607,  cd:2 },
        { label:"Rockstar Rudolph",name:"Can of Rockstar Rudolph",e:25, id:1021, cd:2 },
        { label:"Taurine Elite",   name:"Can of Taurine Elite",   e:30, id:967,  cd:2 },
    ];
    // Candy types — all give 30min booster CD each, max 48/jump
    // Grouped: 25-happy (cheapest/shoplift), 35-happy (big choc), 50-happy (Crimes 2.0)
    const CANDY_TYPES = [
        { label:"Bag of Candy Kisses",      happy:50, name:"Bag of Candy Kisses",      id:616 },
        { label:"Jawbreaker",               happy:50, name:"Jawbreaker",               id:617 },
        { label:"Pixie Sticks",             happy:50, name:"Pixie Sticks",             id:618 },
        { label:"Big Box of Chocolates",    happy:35, name:"Big Box of Chocolate Bars",id:207 },
        { label:"Box of Chocolates",        happy:25, name:"Box of Chocolate Bars",    id:208 },
        { label:"Lollipop",                 happy:25, name:"Lollipop",                 id:621 },
        { label:"Bag of Bon Bons",          happy:25, name:"Bag of Bon Bons",          id:619 },
        { label:"Box of Mints",             happy:25, name:"Box of Extra Strong Mints",id:620 },
    ];
    const CANDY_CD_HRS = 0.5; // 30min = 0.5h per candy

    // Live prices fetched for all cans, stored here
    let canPrices   = {};  // { canLabel: price }
    let candyPrices = {};  // { candyLabel: price }
    let allItemPrices = {}; // { xanax, edvd, ecstasy, fhc, lsd, + canLabel, candyLabel: price }
    const PRICE_CACHE_KEY = 'aio_priceCache';
    const PRICE_CACHE_TTL = 60 * 60 * 1000; // 1 hour

    // Resolve can item IDs from Torn API (cached in GM storage for 7 days)
    async function resolveCanIDs(apiKey) {
        const CACHE_KEY = "canItemIds", CACHE_TS = "canItemIdsTs";
        const cached = _load(CACHE_KEY, null), ts = _load(CACHE_TS, 0);
        if (cached && (Date.now()-ts) < 7*86400000) {
            try {
                const ids = JSON.parse(cached);
                CAN_TYPES.forEach(c   => { if (ids[c.name]) c.id = ids[c.name]; });
                CANDY_TYPES.forEach(c => { if (ids[c.name]) c.id = ids[c.name]; });
                return;
            } catch(_) {}
        }
        const key = (_load("apiKey","") || "").trim();
        if (!key || key.length !== 16) return;
        try {
            const data = await gmFetch("https://api.torn.com/torn/?selections=items&key="+key+"&comment=AIOIDs");
            if (data?.error || !data?.items) return;
            const idMap = {};
            for (const [id, item] of Object.entries(data.items)) {
                // Match by name only — Torn API types vary (cans="Energy Drink", candy="Drug")
                if (CAN_TYPES.some(c=>c.name===item.name) || CANDY_TYPES.some(c=>c.name===item.name))
                    idMap[item.name] = parseInt(id);
            }
            CAN_TYPES.forEach(c   => { if (idMap[c.name]) c.id = idMap[c.name]; });
            CANDY_TYPES.forEach(c => { if (idMap[c.name]) c.id = idMap[c.name]; });
            _save(CACHE_KEY, JSON.stringify(idMap)); _save(CACHE_TS, Date.now());
        } catch(_) {}
    }

    const GG_DEFAULTS = {
        gymId:"18", stat:"strength", subscriber:"no",
        happy:"4525", statTotal:"0", statGoal:"300000", calcMode:"daily",
        factionPerk:"0", propertyPerk:"0", eduStatPerk:"0", eduGenPerk:"0",
        jobPerk:"0", bookPerk:"0", sportsSneakers:"0", steroids:"0",
        energy:"10", dailyRefill:"no", dailyRefillCost:"1725000",
        hjXanaxCount:"4", hjEDVDs:"5", hjANJob:"no", hjEcstasy:"yes", hjRefill:"yes",
        hjBaseHappy:"0", hjVoracity:"0",
        hjXanaxCost:"880000", hjEDVDCost:"2500000", hjEcstasyCost:"100000",
        hjXanaxOD:"3", hjEcstasyOD:"5", hjToleration:"0", hjNightclub:"no",
        hjFHC:"0", hjFHCCost:"12000000", hjFHCHappy:"500",
        hjLSD:"0", hjLSDCost:"500000",
        hjCans:"0", hjCanType:"0", hjCanCost:"1650000", hjCanFactionPerk:"0",
        hjCandies:"0", hjCandyType:"0", hjCandyCost:"500000",
        hjCandyVoracity:"0", hjCandyAbsorption:"no",
        xgXanaxCount:"1", xgXanaxCost:"880000",
        xgRefill:"no", xgRefillCost:"1725000",
    };
    const gg_load = k => _load(k, GG_DEFAULTS[k]);
    const gg_save = (k, v) => _save(k, v);

    const STYLES = `
.gg-wrap{margin:8px 0 12px;background:#181818;border:1px solid #333;border-radius:6px;font-family:Arial,sans-serif;font-size:14px;color:#ccc;overflow:hidden}
.gg-header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:linear-gradient(135deg,#242424,#1c1c1c);border-bottom:1px solid #2a2a2a;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}
.gg-header:hover{background:linear-gradient(135deg,#2c2c2c,#222)}
.gg-title{font-size:15px;font-weight:bold;color:#e0e0e0}
.gg-toggle{font-size:16px;color:#555;transition:transform .2s}
.gg-wrap.open .gg-toggle{transform:rotate(180deg)}
.gg-body{display:none;padding:12px}
.gg-wrap.open .gg-body{display:block}
.gg-sec{font-size:10px;font-weight:bold;color:#556;letter-spacing:.08em;text-transform:uppercase;margin:14px 0 6px;padding-bottom:4px;border-bottom:1px solid #252525}
.gg-sec:first-child{margin-top:0}
.gg-field{margin-bottom:8px}
.gg-field label{display:block;font-size:12px;color:#888;margin-bottom:3px}
.gg-field select,.gg-field input[type=number],.gg-field input[type=password]{width:100%;padding:8px 10px;background:#222;border:1px solid #383838;border-radius:4px;color:#e0e0e0;font-size:14px;box-sizing:border-box;-webkit-appearance:none;appearance:none}
.gg-field select:focus,.gg-field input:focus{outline:none;border-color:#555;background:#282828}
.gg-sg{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.gg-sg .gg-field{margin-bottom:0}
.gg-sg .gg-field label{font-size:11px}
.gg-api-row{display:flex;gap:6px;align-items:stretch}
.gg-api-row input{flex:1}
.gg-api-btn{flex-shrink:0;padding:8px 12px;border-radius:4px;border:1px solid #3a5030;background:#1a2518;color:#7abf7a;font-size:12px;font-weight:bold;cursor:pointer;-webkit-tap-highlight-color:transparent}
.gg-api-btn:hover{background:#20301e}
.gg-tab-row{display:flex;gap:4px}
.gg-tab{flex:1;padding:9px 6px;border-radius:4px;border:1px solid #383838;background:#222;color:#888;font-size:13px;font-weight:bold;cursor:pointer;text-align:center;-webkit-tap-highlight-color:transparent;transition:all .15s}
.gg-tab.active{background:#18182a;border-color:#303058;color:#7a7acc}
.gg-tab:hover{background:#2a2a2a}
.gg-cfg-btn{padding:9px 11px;border-radius:4px;border:1px solid #383838;background:#222;color:#556;font-size:13px;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:all .15s;flex-shrink:0}
.gg-cfg-btn.active{border-color:#303058;color:#7a7acc;background:#181828}
.gg-cfg-btn:hover{background:#2a2a2a}
.gg-section{display:none}
.gg-section.active{display:block}
.gg-btn-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.gg-btn{flex:1;padding:10px 8px;border-radius:4px;border:1px solid #383838;background:#222;color:#ddd;font-size:13px;font-weight:bold;cursor:pointer;text-align:center;-webkit-tap-highlight-color:transparent;transition:background .15s;min-width:60px}
.gg-btn:hover,.gg-btn:active{background:#2a2a2a}
.gg-btn-fill{background:#1a2518;border-color:#3a5030;color:#7abf7a}
.gg-btn-fill:hover,.gg-btn-fill:active{background:#20301e}
.gg-btn-calc{background:#18182a;border-color:#303058;color:#7a7acc}
.gg-btn-calc:hover,.gg-btn-calc:active{background:#20203a}
.gg-btn-compare{background:#1a1a18;border-color:#3a3818;color:#aaaa5a;flex:0 0 auto;padding:10px 12px;font-size:12px}
.gg-btn-compare:hover,.gg-btn-compare:active{background:#26261a}
.gg-btn-copy{background:#1a1a2a;border-color:#2a2a48;color:#5a5a88;font-size:12px;flex:0 0 auto;padding:10px 12px}
.gg-status{display:none;margin-top:8px;padding:8px 10px;border-radius:4px;font-size:12px;line-height:1.5;word-break:break-word}
.gg-status.ok{display:block;background:#182018;border:1px solid #2a4a2a;color:#7abf7a}
.gg-status.warn{display:block;background:#201e10;border:1px solid #4a3a18;color:#bf9f5a}
.gg-row{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 10px;margin-top:3px;border-radius:4px;background:#1e1e1e}
.gg-row .gg-rl{color:#888;font-size:12px;flex-shrink:0}
.gg-row .gg-rv{color:#ddd;font-size:13px;text-align:right;word-break:break-word}
.gg-row.hi .gg-rv{color:#7abf7a}
.gg-row.g .gg-rv{color:#7abf7a}
.gg-row.a .gg-rv{color:#bf9f5a}
.gg-row.b .gg-rv{color:#7a7acc}
.gg-collapsible{margin-top:8px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:4px}
.gg-collapsible-header{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;cursor:pointer;-webkit-tap-highlight-color:transparent;font-size:11px;font-weight:bold;color:#556;letter-spacing:.06em;text-transform:uppercase}
.gg-collapsible-header:hover{color:#888}
.gg-collapsible-toggle{font-size:12px;color:#444;transition:transform .2s}
.gg-collapsible.open .gg-collapsible-toggle{transform:rotate(180deg)}
.gg-collapsible-body{display:none;padding:8px 10px 10px}
.gg-collapsible.open .gg-collapsible-body{display:block}
.gg-results{margin-top:10px}
.gg-tldr{background:#0e1a1e;border:1px solid #1a3a4a;border-radius:5px;padding:12px 14px;margin-bottom:10px}
.gg-tldr-title{font-size:11px;font-weight:bold;color:#4a8aaa;letter-spacing:.06em;text-transform:uppercase;margin-bottom:10px}
.gg-tldr-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.gg-tldr-cell{background:#141e24;border:1px solid #1e3040;border-radius:3px;padding:8px 6px;text-align:center}
.gg-tldr-val{font-size:16px;font-weight:bold;color:#7abf7a;line-height:1.2}
.gg-tldr-lbl{font-size:10px;color:#446;margin-top:3px;text-transform:uppercase;letter-spacing:.04em}
.gg-rsec{font-size:10px;font-weight:bold;color:#445;letter-spacing:.08em;text-transform:uppercase;margin:12px 0 4px;padding-bottom:3px;border-bottom:1px solid #222}
.gg-rsec:first-child{margin-top:4px}
.gg-price-badge{font-size:10px;font-weight:normal;color:#4a7a4a;margin-left:4px}
.gg-price-badge.loading{color:#555}
.gg-price-badge.err{color:#7a4a4a}
.gg-cmp{margin-top:10px}
.gg-cmp-title{font-size:12px;font-weight:bold;color:#aaaa5a;letter-spacing:.05em;text-transform:uppercase;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #2a2a1a}
.gg-cmp-grid{display:grid;grid-template-columns:minmax(0,1.1fr) repeat(3,minmax(0,1fr));gap:0;width:100%;table-layout:fixed}
.gg-cmp-hdr{font-size:10px;font-weight:bold;color:#555;padding:5px 4px;background:#1a1a1a;border-bottom:1px solid #252525;text-align:center;word-break:break-word;line-height:1.3}
.gg-cmp-hdr:first-child{text-align:left;color:#444}
.gg-cmp-hdr.daily{color:#7a7acc}
.gg-cmp-hdr.jump{color:#aaaa5a}
.gg-cmp-label{font-size:11px;color:#666;padding:5px 4px;background:#1c1c1c;border-bottom:1px solid #222;word-break:break-word;line-height:1.3}
.gg-cmp-val{font-size:11px;color:#bbb;padding:5px 4px;background:#1e1e1e;border-bottom:1px solid #222;text-align:right;word-break:break-all;line-height:1.3}
.gg-cmp-val.win{color:#7abf7a;font-weight:bold}
.gg-cmp-val.lose{color:#555}
.gg-cmp-verdict{margin-top:12px;padding:10px 12px;border-radius:4px;font-size:12px;line-height:1.7;background:#141a10;border:1px solid #2a3a20;color:#8abf7a}
.gg-bestgym{margin-top:8px;background:#141414;border:1px solid #252525;border-radius:5px;overflow:hidden}
.gg-bestgym-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#1a1a1a;border-bottom:1px solid #252525;cursor:pointer;-webkit-tap-highlight-color:transparent;user-select:none}
.gg-bestgym-title{font-size:11px;font-weight:bold;color:#aaaa5a;letter-spacing:.06em;text-transform:uppercase}
.gg-bestgym-toggle{font-size:12px;color:#555;transition:transform .2s}
.gg-bestgym.open .gg-bestgym-toggle{transform:rotate(180deg)}
.gg-bestgym-body{display:none;padding:8px 10px 10px}
.gg-bestgym.open .gg-bestgym-body{display:block}
.gg-gym-row{display:flex;align-items:center;gap:8px;padding:5px 8px;margin-top:3px;border-radius:3px;background:#1c1c1c;cursor:pointer;-webkit-tap-highlight-color:transparent}
.gg-gym-row:hover{background:#222}
.gg-gym-row.current{background:#18222a;border:1px solid #2a3a4a}
.gg-gym-row.best{background:#181e14;border:1px solid #2a3a20}
.gg-gym-rank{font-size:11px;color:#555;width:16px;flex-shrink:0;text-align:center}
.gg-gym-name{font-size:12px;color:#ccc;flex:1}
.gg-gym-name.current{color:#7aafcc}
.gg-gym-name.best{color:#7abf7a}
.gg-gym-dots-val{font-size:12px;font-weight:bold;color:#7abf7a;flex-shrink:0}
.gg-gym-dots-val.dim{color:#666}
.gg-gym-e{font-size:10px;color:#555;flex-shrink:0}
.gg-gym-use-btn{font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid #2a4a2a;background:#182018;color:#6a9f6a;cursor:pointer;flex-shrink:0;-webkit-tap-highlight-color:transparent}
.gg-gym-use-btn:hover{background:#1e2a1e}
.gg-bestgym-sec{font-size:10px;color:#446;margin:8px 0 3px;letter-spacing:.05em;text-transform:uppercase}
.gg-dots{display:flex;gap:3px;align-items:center;margin-top:4px;font-size:11px;color:#556}
.gg-dot{width:10px;height:10px;border-radius:50%;background:#2a2a2a;border:1px solid #333;display:inline-block}
.gg-dot.on{background:#7abf7a;border-color:#5a9f5a}
.gg-dot.half{background:#bf9f5a;border-color:#9f7f3a}
.gg-dot.off{background:#1e1e1e;border-color:#2a2a2a}
.gg-dot-val{margin-left:4px;color:#7abf7a;font-weight:bold}
.gg-dot-na{color:#555;font-style:italic;margin-left:4px}
.gg-field input.invalid,.gg-field select.invalid{border-color:#7a3030!important;background:#201818!important}
.gg-field input.warn-input,.gg-field select.warn-input{border-color:#6a5020!important}
.gg-val-msg{font-size:10px;color:#bf5a5a;margin-top:2px;display:none}
.gg-val-msg.show{display:block}
.gg-infobar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;padding:6px 8px;background:#141414;border:1px solid #252525;border-radius:4px;font-size:11px;color:#556}
.gg-infobar-item{display:flex;align-items:center;gap:4px}
.gg-infobar-val{color:#888;font-weight:bold}
.gg-infobar-val.hi{color:#7abf7a}
.gg-last-updated{font-size:10px;color:#444;margin-left:auto;white-space:nowrap}
.gg-cfg-panel{margin-top:8px;padding:10px 12px;background:#141414;border:1px solid #252525;border-radius:5px}
`;

    function buildGymOptions() {
        const placeholder = `<option value="" disabled>— select gym —</option>`;
        return placeholder + GYMS.map(g => {
            const label = g.tier === "J" ? g.name : `[${g.tier}] ${g.name}`;
            return `<option value="${g.id}">${label}</option>`;
        }).join('');
    }

    function buildHTML() {
        const statOpts = Object.keys(STAT_KEYS).map(s =>
            `<option value="${s}">${STAT_LABELS[s]}</option>`).join('');
        const perkSel = id =>
            `<select id="gg-${id}">${Array.from({length:101},(_,i)=>
            `<option value="${i}">${i}%</option>`).join('')}</select>`;
        return `
<div class="gg-header">
  <span class="gg-title">🏋️ Gym Gains Calculator</span>
  <span class="gg-toggle">▼</span>
</div>
<div class="gg-body">

<!-- info bar shown after autofill -->
<div id="gg-infobar" class="gg-infobar" style="display:none">
  <div class="gg-infobar-item">⚡ <span class="gg-infobar-val" id="gg-info-energy">—</span></div>
  <div class="gg-infobar-item">😊 <span class="gg-infobar-val" id="gg-info-happy">—</span></div>
  <div class="gg-infobar-item" id="gg-info-sub-wrap" style="display:none">
    🎫 <span class="gg-infobar-val hi" id="gg-info-sub">Subscriber</span>
  </div>
  <div class="gg-last-updated" id="gg-last-updated"></div>
</div>

<!-- ── ONBOARDING — visible until first successful autofill ────────── -->
<div id="gg-onboard" style="margin:8px 0 12px;padding:10px 12px;background:#16201f;border:1px solid #2a4040;border-radius:5px;display:none">
  <div style="font-size:13px;color:#9aaaaa;font-weight:bold;margin-bottom:4px">👋 Welcome — quick setup</div>
  <div style="font-size:11px;color:#778;line-height:1.6">
    1. Add your Torn API key in <strong>⚙ Settings</strong> below<br>
    2. Tap <strong>⟳ Auto-fill</strong> to pull your stats, perks, and prices<br>
    3. Tell the script your current energy → tap <strong>Calculate</strong>
  </div>
</div>

<!-- ── STEP 1: WHAT TO TRAIN ─────────────────────────── -->
<div class="gg-sec">1. What to train</div>
<div class="gg-sg">
  <div class="gg-field">
    <label>Gym <span id="gg-gym-badge" style="font-size:10px;color:#4a7a4a"></span></label>
    <select id="gg-gym">${buildGymOptions()}</select>
    <div class="gg-dots" id="gg-dots-display"></div>
  </div>
  <div class="gg-field">
    <label style="font-weight:bold;color:#9a9acc">Stat</label>
    <select id="gg-stat" style="font-size:15px;font-weight:bold">${statOpts}</select>
  </div>
</div>
<div class="gg-bestgym" id="gg-bestgym-panel">
  <div class="gg-bestgym-header">
    <span class="gg-bestgym-title">⭐ Best Gym for Stat</span>
    <span class="gg-bestgym-toggle">▼</span>
  </div>
  <div class="gg-bestgym-body" id="gg-bestgym-body">
    <div style="font-size:11px;color:#446;padding:4px 0 2px">Auto-fill to check specialist gym eligibility.</div>
  </div>
</div>

<!-- ── STEP 2: YOUR PROFILE (one-time config) ─────────── -->
<div class="gg-collapsible" id="gg-profile-panel" style="margin-top:12px">
  <div class="gg-collapsible-header">
    <span style="color:#9a9aaa">2. Your profile</span>
    <span style="font-size:10px;color:#556;margin-left:6px" id="gg-profile-summary">— set once, applies to all sessions</span>
    <span class="gg-collapsible-toggle">▼</span>
  </div>
  <div class="gg-collapsible-body">
    <div style="font-size:11px;color:#556;margin-bottom:8px;line-height:1.5">
      Auto-fill pulls these from the API. Only edit if needed.
    </div>
    <div class="gg-sg">
      <div class="gg-field">
        <label>Current stat <span id="gg-autofill-badge" style="font-size:10px;color:#4a7a4a;display:none">✓ auto</span></label>
        <input type="number" id="gg-statTotal" min="0" placeholder="e.g. 120,000">
        <div class="gg-val-msg" id="gg-val-stat">Enter your current stat</div>
      </div>
      <div class="gg-field">
        <label>Your goal <span id="gg-goal-progress" style="font-size:10px;color:#556"></span></label>
        <input type="number" id="gg-statGoal" min="0" placeholder="e.g. 600,000">
        <div class="gg-val-msg" id="gg-val-goal">Goal must be higher than current</div>
      </div>
    </div>
    <div class="gg-sg">
      <div class="gg-field">
        <label>Your max happy</label>
        <input type="number" id="gg-happy" min="0" placeholder="e.g. 5025">
        <div class="gg-val-msg" id="gg-val-happy">From your property — auto-filled</div>
      </div>
      <div class="gg-field">
        <label>Subscriber?</label>
        <select id="gg-subscriber">
          <option value="no">No (100E cap)</option>
          <option value="yes">Yes (150E cap)</option>
        </select>
      </div>
    </div>
  </div>
</div>

<!-- ── STEP 3: THIS SESSION ─────────────────────────── -->
<div class="gg-sec" style="margin-top:14px">3. What you have right now</div>
<div style="font-size:11px;color:#556;margin-bottom:8px;line-height:1.5">
  Tell the script what energy and items are available, then tap Calculate.
</div>
<div class="gg-sg">
  <div class="gg-field">
    <label>Current energy ⚡ <span id="gg-energy-badge" style="font-size:10px;color:#4a7a4a"></span></label>
    <input type="number" id="gg-energy" min="0" placeholder="e.g. 150">
  </div>
  <div class="gg-field">
    <label>Drug ready?</label>
    <select id="gg-session-drug">
      <option value="none">None — no drug CD</option>
      <option value="xanax">Xanax (+250E)</option>
      <option value="lsd">LSD (+50E)</option>
    </select>
  </div>
</div>
<div class="gg-sg">
  <div class="gg-field">
    <label>Use Points Refill?</label>
    <select id="gg-session-refill">
      <option value="no">No</option>
      <option value="yes">Yes (+150E, ~$1.7m)</option>
    </select>
  </div>
  <div class="gg-field">
    <label>Happy right now 😊</label>
    <input type="number" id="gg-session-happy" min="0" placeholder="blank = your max">
  </div>
</div>

<!-- ── PRIMARY ACTION — big, obvious, hard to miss ─── -->
<button class="gg-btn gg-btn-calc" id="gg-calc" style="width:100%;font-size:15px;padding:13px;margin-top:10px;font-weight:bold">
  Calculate this session →
</button>

<!-- ── SECONDARY ACTIONS — smaller, less prominent ─── -->
<div class="gg-btn-row" style="margin-top:8px;gap:6px">
  <button class="gg-btn gg-btn-fill" id="gg-autofill" style="flex:1;font-size:12px">⟳ Auto-fill</button>
  <button class="gg-btn gg-btn-compare" id="gg-compare" style="flex:1;font-size:12px">⚖ Compare</button>
</div>
<div class="gg-btn-row" style="margin-top:6px;gap:6px">
  <button class="gg-btn" id="gg-tab-plan" style="flex:1;font-size:11px;color:#bf9f5a;border-color:#3a3018;background:#1a1808">📋 Long-term plan</button>
  <button class="gg-btn" id="gg-tab-bonuses-btn" style="flex:1;font-size:11px;color:#7a6a9a;border-color:#2a2038;background:#181620">🎖 Perks &amp; bonuses</button>
</div>
<button class="gg-btn gg-btn-copy" id="gg-copy" style="width:100%;margin-top:6px;font-size:11px;display:none">📋 Copy result</button>

<!-- ── BONUSES PANEL ── -->
<div class="gg-section" id="gg-bonuses-inputs">
  <div class="gg-cfg-panel">
    <div class="gg-sec" style="margin-top:0">🎖 Perks &amp; Training Bonuses</div>
    <div style="font-size:11px;color:#556;margin-bottom:10px;line-height:1.5">
      These boost gym gains. Auto-filled from your perks. <span id="gg-perks-badge" style="color:#4a7a4a"></span>
    </div>
    <div class="gg-sg">
      <div class="gg-field"><label>Faction Gym</label>${perkSel('factionPerk')}</div>
      <div class="gg-field"><label>Property Gym</label>${perkSel('propertyPerk')}</div>
      <div class="gg-field"><label>Education (this stat)</label>${perkSel('eduStatPerk')}</div>
      <div class="gg-field"><label>Education (all stats)</label>${perkSel('eduGenPerk')}</div>
      <div class="gg-field"><label>Job Bonus</label>${perkSel('jobPerk')}</div>
      <div class="gg-field"><label>Book Bonus</label>${perkSel('bookPerk')}</div>
      <div class="gg-field">
        <label>Steroids</label>
        <select id="gg-steroids"><option value="0">None</option><option value="20">+20%</option></select>
      </div>
      <div class="gg-field">
        <label>Sports Sneakers (SPD)</label>
        <select id="gg-sportsSneakers"><option value="0">None</option><option value="5">+5%</option></select>
      </div>
    </div>
    <div id="gg-bonus-summary" style="margin-top:10px;padding:8px 10px;background:#141e14;border:1px solid #253025;border-radius:4px;font-size:11px;color:#6a9a6a;display:none"></div>
  </div>
</div>

<!-- ── PLAN AHEAD PANEL ── -->
<div class="gg-section" id="gg-plan-inputs">
  <div class="gg-cfg-panel">
    <div class="gg-sec" style="margin-top:0">📋 Long-term Plan — How long to reach your goal?</div>
    <div style="font-size:11px;color:#556;margin-bottom:10px;line-height:1.5">
      Pick how you'll be training day-to-day. The script will project days until you hit your goal.
    </div>
    <div class="gg-field">
      <label>I'll be training with…</label>
      <select id="gg-plan-method">
        <option value="daily">📅 Just natural energy regen</option>
        <option value="xanax">💊 Regen + Xanax every CD</option>
        <option value="jump">⚡ Happy Jumps</option>
      </select>
    </div>
    <div id="gg-plan-xanax-opts" style="display:none;margin-top:8px">
      <div class="gg-sg">
        <div class="gg-field">
          <label>Xanax per day</label>
          <select id="gg-xgXanaxCount">
            <option value="1">1/day (~24h CD)</option>
            <option value="2">2/day (~12h CD)</option>
            <option value="3" selected>3/day (~8h CD) ← typical</option>
            <option value="4">4/day (6h CD)</option>
          </select>
        </div>
        <div class="gg-field">
          <label>Xanax cost <span id="gg-xg-xanax-price" class="gg-price-badge"></span></label>
          <input type="number" id="gg-xgXanaxCost" min="0" placeholder="e.g. 860000">
        </div>
      </div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>Points Refill/day</label>
          <select id="gg-xgRefill"><option value="no">No</option><option value="yes">Yes</option></select>
        </div>
        <div class="gg-field">
          <label>Refill cost ($)</label>
          <input type="number" id="gg-xgRefillCost" min="0" placeholder="1725000">
        </div>
      </div>
    </div>
    <div id="gg-plan-daily-opts" style="margin-top:8px">
      <div class="gg-sg">
        <div class="gg-field">
          <label>Points Refill/day</label>
          <select id="gg-dailyRefill"><option value="no">No</option><option value="yes">Yes</option></select>
        </div>
        <div class="gg-field">
          <label>Refill cost ($)</label>
          <input type="number" id="gg-dailyRefillCost" min="0" placeholder="1725000">
        </div>
      </div>
    </div>
    <div id="gg-plan-jump-opts" style="display:none;margin-top:8px">
      <div class="gg-sec" style="margin-top:0;font-size:10px">Energy Sources</div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>Xanax</label>
          <select id="gg-hjXanaxCount">
            <option value="0">None</option>
            <option value="1">1 (~400E)</option>
            <option value="2">2 (~650E)</option>
            <option value="3">3 (~900E)</option>
            <option value="4">4 (1000E)</option>
          </select>
        </div>
        <div class="gg-field">
          <label>LSD (50E, no OD)</label>
          <select id="gg-hjLSD">
            ${Array.from({length:6},(_,i)=>`<option value="${i}">${i===0?'None':i+' (+'+i*50+'E)'}</option>`).join('')}
          </select>
        </div>
      </div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>FHC</label>
          <select id="gg-hjFHC">${Array.from({length:6},(_,i)=>`<option value="${i}">${i===0?'None':i+' FHC (+'+i*FHC_HAPPY+' happy)'}</option>`).join('')}</select>
        </div>
        <div class="gg-field">
          <label>Points Refill after</label>
          <select id="gg-hjRefill"><option value="yes">Yes</option><option value="no">No</option></select>
        </div>
      </div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>Energy Cans</label>
          <select id="gg-hjCanType">
            <option value="0">None</option>
            ${CAN_TYPES.map((c,i)=>`<option value="${i+1}">${c.e}E — ${c.label}</option>`).join('')}
          </select>
        </div>
        <div class="gg-field">
          <label>Number of Cans</label>
          <input type="number" id="gg-hjCans" min="0" max="50" placeholder="0">
        </div>
      </div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>Faction Can Bonus</label>
          <select id="gg-hjCanFactionPerk">
            ${[0,10,20,30,40,50].map(v=>`<option value="${v}">${v===0?'None':'+'+v+'%'}</option>`).join('')}
          </select>
        </div>
        <div class="gg-field">
          <label>Voracity hrs</label>
          <input type="number" id="gg-hjVoracity" min="0" max="24" placeholder="0">
        </div>
      </div>
      <div class="gg-sec" style="font-size:10px">Happy Boosters</div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>eDVDs per jump</label>
          <input type="number" id="gg-hjEDVDs" min="0" max="12" placeholder="0">
        </div>
        <div class="gg-field">
          <label>Ecstasy</label>
          <select id="gg-hjEcstasy"><option value="yes">Yes (x2)</option><option value="no">No</option></select>
        </div>
      </div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>10star Adult Novelties</label>
          <select id="gg-hjANJob"><option value="no">No</option><option value="yes">Yes (x2 eDVD)</option></select>
        </div>
        <div class="gg-field">
          <label>Candy type</label>
          <select id="gg-hjCandyType">
            <option value="0">None</option>
            ${CANDY_TYPES.map((c,i)=>`<option value="${i+1}">${c.happy}★ — ${c.label}</option>`).join('')}
          </select>
        </div>
      </div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>Candies</label>
          <input type="number" id="gg-hjCandies" min="0" max="48" placeholder="0">
        </div>
        <div class="gg-field">
          <label>Candy Voracity</label>
          <select id="gg-hjCandyVoracity">${[0,5,10,15,20,25,30,35,40,45,50].map(v=>`<option value="${v}">${v===0?'None':'+'+v+'%'}</option>`).join('')}</select>
        </div>
      </div>
      <div class="gg-sg">
        <div class="gg-field">
          <label>7star Grocery Absorption</label>
          <select id="gg-hjCandyAbsorption"><option value="no">No</option><option value="yes">Yes (+10%)</option></select>
        </div>
        <div class="gg-field">
          <label>Happy before boosters</label>
          <input type="number" id="gg-hjBaseHappy" min="0" placeholder="0 = prop max">
        </div>
      </div>
      <div class="gg-collapsible" id="gg-od-panel">
        <div class="gg-collapsible-header">OD Risk Settings <span class="gg-collapsible-toggle">v</span></div>
        <div class="gg-collapsible-body">
          <div style="font-size:11px;color:#665533;margin-bottom:8px">Community estimates only.</div>
          <div class="gg-sg">
            <div class="gg-field"><label>Xanax OD %</label><input type="number" id="gg-hjXanaxOD" min="0" max="100" step="0.1"></div>
            <div class="gg-field"><label>Ecstasy OD %</label><input type="number" id="gg-hjEcstasyOD" min="0" max="100" step="0.1"></div>
          </div>
          <div class="gg-sg">
            <div class="gg-field">
              <label>Faction Toleration</label>
              <select id="gg-hjToleration">${Array.from({length:11},(_,i)=>`<option value="${i*3}">${i*3}%</option>`).join('')}</select>
            </div>
            <div class="gg-field">
              <label>7star Nightclub (-50% OD)</label>
              <select id="gg-hjNightclub"><option value="no">No</option><option value="yes">Yes</option></select>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="gg-btn-row" style="margin-top:12px">
      <button class="gg-btn gg-btn-calc" id="gg-plan-calc" style="flex:1">Calculate Plan</button>
    </div>
  </div>
</div>


<!-- ── STATUS + RESULTS ────────────────────────────── -->
<div class="gg-status" id="gg-status"></div>
<div class="gg-results" id="gg-daily-results" style="display:none"></div>
<div class="gg-results" id="gg-jump-results" style="display:none"></div>
<div class="gg-results" id="gg-compare-results" style="display:none"></div>

<!-- ── ITEM COSTS & PRICES ────────────────────────── -->
<div class="gg-collapsible" id="gg-item-info-panel" style="margin-top:10px">
  <div class="gg-collapsible-header">💰 Item Costs &amp; Prices <span class="gg-collapsible-toggle">▼</span></div>
  <div class="gg-collapsible-body">
    <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
      <span style="font-size:11px;color:#556">Enter costs manually or fetch live.</span>
      <button class="gg-btn gg-btn-fill" id="gg-fetch-prices" style="font-size:11px;padding:5px 12px;flex:none">⟳ Live Prices</button>
    </div>
    <div class="gg-sg">
      <div class="gg-field"><label>Xanax <span id="gg-xanax-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjXanaxCost" min="0" placeholder="$"></div>
      <div class="gg-field"><label>FHC <span id="gg-fhc-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjFHCCost" min="0" placeholder="$"></div>
    </div>
    <div class="gg-sg">
      <div class="gg-field"><label>eDVD <span id="gg-edvd-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjEDVDCost" min="0" placeholder="$"></div>
      <div class="gg-field"><label>Ecstasy <span id="gg-ecstasy-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjEcstasyCost" min="0" placeholder="$"></div>
    </div>
    <div class="gg-sg">
      <div class="gg-field"><label>LSD <span id="gg-lsd-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjLSDCost" min="0" placeholder="$"></div>
      <div class="gg-field"><label>Energy Can <span id="gg-can-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjCanCost" min="0" placeholder="$"></div>
    </div>
    <div class="gg-sg">
      <div class="gg-field"><label>Candy <span id="gg-candy-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjCandyCost" min="0" placeholder="$"></div>
    </div>
    <div id="gg-price-breakdown" style="margin-top:8px"></div>
  </div>
</div>

<!-- ── SETTINGS & API KEY ─────────────────────────── -->
<div class="gg-collapsible" id="gg-settings-panel" style="margin-top:8px">
  <div class="gg-collapsible-header">⚙ Settings &amp; API Key <span class="gg-collapsible-toggle">▼</span></div>
  <div class="gg-collapsible-body">
    <button class="gg-btn gg-btn-fill" id="gg-autofill-settings" style="width:100%;margin-bottom:10px">⟳ Auto-fill from API</button>
    <div class="gg-field">
      <label>Torn API Key <span style="font-size:10px;color:#556">— stored locally only</span></label>
      <div class="gg-api-row">
        <input type="password" id="gg-apikey" placeholder="Paste 16-character key…" autocomplete="off">
        <button class="gg-api-btn" id="gg-apikey-save">Save</button>
      </div>
      <div style="margin-top:6px;padding:6px 10px;background:#0e0e0e;border:1px solid #252525;border-radius:4px;font-size:11px;line-height:1.7;color:#556">
        Needs <strong style="color:#668">Normal Access</strong> (gym, perks, stats).<br>
        <a href="https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=AIO+Planner&type=3" target="_blank" style="color:#4a7aaa;text-decoration:none">🔑 Auto-create key →</a>
        <span style="margin-left:8px;color:#444">Prices via weav3r.dev (no key needed)</span>
      </div>
    </div>
    <div class="gg-sec">Cache</div>
    <div class="gg-sg">
      <button class="gg-btn" id="gg-copy-logs" style="background:#1a1a2a;border-color:#2a2a48;color:#7a7acc;font-size:12px">Copy Logs</button>
      <button class="gg-btn" id="gg-clear-cache" style="background:#2a1a1a;border-color:#4a2a2a;color:#bf7a7a;font-size:12px">Clear Cache</button>
    </div>
    <div id="gg-debug-status" style="font-size:10px;color:#445;margin-top:4px;display:none"></div>
  </div>
</div>

<!-- ── GAINS LOGGER ───────────────────────────────── -->
<div class="gg-collapsible" id="gg-logger-panel" style="margin-top:8px">
  <div class="gg-collapsible-header">
    📓 Gains Logger <span id="gg-logger-badge" style="font-size:10px;color:#4a7a4a;margin-left:4px"></span>
    <span class="gg-collapsible-toggle">▼</span>
  </div>
  <div class="gg-collapsible-body" id="gg-logger-body">

    <!-- Auto-log toggle -->
    <div id="gg-autolog-row" style="display:flex;align-items:center;gap:8px;padding:8px 10px;background:#141414;border:1px solid #252525;border-radius:4px;margin-bottom:10px;cursor:pointer">
      <span id="gg-autolog-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0"></span>
      <div style="flex:1">
        <div id="gg-autolog-lbl" style="font-size:12px;font-weight:bold"></div>
        <div style="font-size:10px;color:#445;margin-top:1px">Watches for train results — logs every gym session automatically</div>
      </div>
      <span style="font-size:10px;color:#333;flex-shrink:0">tap</span>
    </div>

    <!-- Manual entry -->
    <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
      <div style="font-size:10px;font-weight:bold;color:#445;text-transform:uppercase;letter-spacing:.06em">Manual Entry</div>
      <button id="gg-log-autofill" style="background:#182028;border:1px solid #2a3a50;color:#6a9acc;border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer;-webkit-tap-highlight-color:transparent">⚡ Fill from page</button>
    </div>
    <div class="gg-sg">
      <div class="gg-field"><label>Actual Gain</label><input type="number" id="gg-log-gain" placeholder="e.g. 31355" min="0"></div>
      <div class="gg-field"><label>Energy Used</label><input type="number" id="gg-log-energy" placeholder="e.g. 1000" min="0"></div>
    </div>
    <div class="gg-sg">
      <div class="gg-field"><label>Pre-train Stat</label><input type="number" id="gg-log-stat" placeholder="stat before" min="0"></div>
      <div class="gg-field"><label>Happy at Train</label><input type="number" id="gg-log-happy" placeholder="e.g. 35050" min="0"></div>
    </div>
    <button class="gg-btn gg-btn-fill" id="gg-log-add" style="width:100%;margin-bottom:12px">+ Log Entry</button>

    <!-- Log table -->
    <div id="gg-log-entries"></div>

    <!-- Calibration -->
    <div id="gg-log-calibration"></div>

    <!-- Actions -->
    <div class="gg-sg" style="margin-top:8px">
      <button class="gg-btn" id="gg-log-copy" style="background:#182028;border-color:#2a3a50;color:#6a9acc;font-size:12px">📋 Copy Log</button>
      <button class="gg-btn" id="gg-log-clear" style="background:#1a1010;border-color:#301818;color:#aa5a5a;font-size:12px">🗑 Clear Log</button>
    </div>
    <div id="gg-log-copy-status" style="display:none;margin-top:6px;font-size:11px;color:#7abf7a;padding:6px 8px;background:#141a14;border:1px solid #253025;border-radius:4px"></div>
  </div>
</div>

</div>`;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // MOUNT
    // ─────────────────────────────────────────────────────────────────────────
    const styleEl = document.createElement("style");
    styleEl.textContent = STYLES;
    document.head.appendChild(styleEl);

    const wrap = document.createElement("div");
    wrap.className = "gg-wrap";
    try {
        wrap.innerHTML = buildHTML();
    } catch (e) {
        // If HTML render fails, show a visible error so the user knows something's wrong
        wrap.innerHTML = `<div style="background:#2a1010;border:2px solid #802020;color:#f88;padding:12px;font-family:monospace;font-size:12px;border-radius:4px;margin:8px;line-height:1.5">
            <strong style="color:#f44">⚠ Gym Planner: HTML render failed</strong><br>
            ${e.message?.replace(/[<>]/g, '') || 'Unknown error'}<br>
            <small style="color:#a66">Please report this with a screenshot.</small>
        </div>`;
        console.error('[GymPlanner] buildHTML failed:', e);
    }
    const mt = document.querySelector('.content-wrapper') || document.body;
    mt.insertBefore(wrap, mt.firstChild);

    const q = sel => wrap.querySelector(sel);

    let gymConfirmed = false;

    function rebuildGymDropdown() {
        const sel = q("#gg-gym"); if (!sel) return;
        const cur = sel.value;
        sel.innerHTML = buildGymOptions();
        if (cur && GYMS.some(g => String(g.id) === cur)) { setGymSelect(sel, cur); }
        else { const rd = detectGymFromDOM(); if (rd) { setGymSelect(sel, String(rd)); gymConfirmed = true; if (el.gymBadge) el.gymBadge.textContent = "✓ page"; } }
        if (typeof updateDotsDisplay === 'function') updateDotsDisplay();
    }

    function setGymSelect(sel, idStr) {
        sel.value = idStr;
        for (const opt of sel.options) { if (opt.value === idStr) { opt.selected = true; break; } }
    }

    const el = {
        gym:              q("#gg-gym"),             gymBadge:       q("#gg-gym-badge"),
        stat:             q("#gg-stat"),             subscriber:     q("#gg-subscriber"),
        statTotal:        q("#gg-statTotal"),        statGoal:       q("#gg-statGoal"),
        happy:            q("#gg-happy"),
        factionPerk:      q("#gg-factionPerk"),      propertyPerk:   q("#gg-propertyPerk"),
        eduStatPerk:      q("#gg-eduStatPerk"),      eduGenPerk:     q("#gg-eduGenPerk"),
        jobPerk:          q("#gg-jobPerk"),          bookPerk:       q("#gg-bookPerk"),
        steroids:         q("#gg-steroids"),         sportsSneakers: q("#gg-sportsSneakers"),
        energy:           q("#gg-energy"),           dailyRefill:    q("#gg-dailyRefill"),
        dailyRefillCost:  q("#gg-dailyRefillCost"),
        hjBaseHappy:      q("#gg-hjBaseHappy"),
        hjXanaxCount:     q("#gg-hjXanaxCount"),     hjEDVDs:        q("#gg-hjEDVDs"),
        hjANJob:          q("#gg-hjANJob"),          hjEcstasy:      q("#gg-hjEcstasy"),
        hjRefill:         q("#gg-hjRefill"),         hjVoracity:     q("#gg-hjVoracity"),
        hjFHC:            q("#gg-hjFHC"),            hjFHCCost:      q("#gg-hjFHCCost"),
        hjLSD:            q("#gg-hjLSD"),            hjLSDCost:      q("#gg-hjLSDCost"),
        hjCans:           q("#gg-hjCans"),           hjCanType:      q("#gg-hjCanType"),
        hjCanCost:        q("#gg-hjCanCost"),        hjCanFactionPerk: q("#gg-hjCanFactionPerk"),
        hjCandies:        q("#gg-hjCandies"),        hjCandyType:    q("#gg-hjCandyType"),
        hjCandyCost:      q("#gg-hjCandyCost"),      hjCandyVoracity:q("#gg-hjCandyVoracity"),
        hjCandyAbsorption:q("#gg-hjCandyAbsorption"),
        candyBadge:       q("#gg-candy-price"),
        hjXanaxCost:      q("#gg-hjXanaxCost"),      hjEDVDCost:     q("#gg-hjEDVDCost"),
        hjEcstasyCost:    q("#gg-hjEcstasyCost"),    fhcBadge:       q("#gg-fhc-price"),
        lsdBadge:         q("#gg-lsd-price"),        canBadge:       q("#gg-can-price"),
        hjXanaxOD:        q("#gg-hjXanaxOD"),        hjEcstasyOD:    q("#gg-hjEcstasyOD"),
        hjToleration:     q("#gg-hjToleration"),     hjNightclub:    q("#gg-hjNightclub"),
        xanaxBadge:       q("#gg-xanax-price"),      edvdBadge:      q("#gg-edvd-price"),
        ecstasyBadge:     q("#gg-ecstasy-price"),    fetchPricesBtn: q("#gg-fetch-prices"),
        autofill:         q("#gg-autofill"),         calc:           q("#gg-calc"),
        sessionDrug:      q("#gg-session-drug"),      sessionRefill:  q("#gg-session-refill"),
        sessionHappy:     q("#gg-session-happy"),
        compare:          q("#gg-compare"),          copy:           q("#gg-copy"),
        perksBadge:       q("#gg-perks-badge"),      status:         q("#gg-status"),
        dailyResults:     q("#gg-daily-results"),    jumpResults:    q("#gg-jump-results"),
        compareResults:   q("#gg-compare-results"),  autofillBadge:  q("#gg-autofill-badge"),
        energyBadge:      q("#gg-energy-badge"),
        apikey:           q("#gg-apikey"),           apikeyBtn:      q("#gg-apikey-save"),
        dotsDisplay:      q("#gg-dots-display"),     bestGymPanel:   q("#gg-bestgym-panel"),
        bestGymBody:      q("#gg-bestgym-body"),     infobar:        q("#gg-infobar"),
        infoEnergy:       q("#gg-info-energy"),      infoHappy:      q("#gg-info-happy"),
        infoSubWrap:      q("#gg-info-sub-wrap"),    infoSub:        q("#gg-info-sub"),
        lastUpdated:      q("#gg-last-updated"),     valStat:        q("#gg-val-stat"),
        valGoal:          q("#gg-val-goal"),         valHappy:       q("#gg-val-happy"),
        xgXanaxCount:     q("#gg-xgXanaxCount"),      xgXanaxCost:    q("#gg-xgXanaxCost"),
        xgRefill:         q("#gg-xgRefill"),           xgRefillCost:   q("#gg-xgRefillCost"),
    };

    // ── Restore all saved inputs ──────────────────────────────────────────────
    const EL_MAP = {
        stat:"stat", subscriber:"subscriber", happy:"happy",
        statTotal:"statTotal", statGoal:"statGoal",
        factionPerk:"factionPerk", propertyPerk:"propertyPerk",
        eduStatPerk:"eduStatPerk", eduGenPerk:"eduGenPerk",
        jobPerk:"jobPerk", bookPerk:"bookPerk",
        steroids:"steroids", sportsSneakers:"sportsSneakers",
        energy:"energy", dailyRefill:"dailyRefill", dailyRefillCost:"dailyRefillCost",
        hjXanaxCount:"hjXanaxCount", hjEDVDs:"hjEDVDs", hjANJob:"hjANJob",
        hjEcstasy:"hjEcstasy", hjRefill:"hjRefill", hjVoracity:"hjVoracity",
        hjFHC:"hjFHC", hjFHCCost:"hjFHCCost",
        hjLSD:"hjLSD", hjLSDCost:"hjLSDCost",
        hjCans:"hjCans", hjCanType:"hjCanType", hjCanCost:"hjCanCost",
        hjCanFactionPerk:"hjCanFactionPerk",
        hjCandies:"hjCandies", hjCandyType:"hjCandyType", hjCandyCost:"hjCandyCost",
        hjCandyVoracity:"hjCandyVoracity", hjCandyAbsorption:"hjCandyAbsorption",
        hjXanaxCost:"hjXanaxCost", hjEDVDCost:"hjEDVDCost", hjEcstasyCost:"hjEcstasyCost",
        hjXanaxOD:"hjXanaxOD", hjEcstasyOD:"hjEcstasyOD",
        hjToleration:"hjToleration", hjNightclub:"hjNightclub",
        xgXanaxCount:"xgXanaxCount", xgXanaxCost:"xgXanaxCost",
        xgRefill:"xgRefill", xgRefillCost:"xgRefillCost",
    };
    Object.entries(EL_MAP).forEach(([k, ek]) => {
        if (!el[ek]) return;
        el[ek].value = gg_load(k);
        el[ek].addEventListener("change", () => {
            gg_save(k, el[ek].value);
            if (k === "statTotal" || k === "statGoal") updateGoalProgress();
        });
    });

    updateGoalProgress();

    // ── Header collapse ───────────────────────────────────────────────────────
    wrap.querySelector(".gg-header").addEventListener("click", () => {
        gg_save("ggCollapsed", wrap.classList.toggle("open") ? "no" : "yes");
    });
    if (gg_load("ggCollapsed") !== "yes") wrap.classList.add("open");

    // Show onboarding banner if user has never auto-filled successfully
    if (!_load("hasAutofilled", "")) {
        const ob = q("#gg-onboard");
        if (ob) ob.style.display = "block";
    } else {
        // Returning user — also collapse the profile panel by default
        // (they've already set it up). Show it if they want to edit.
        // Profile collapsible starts closed by default, that's fine.
    }

    // ── API key (in settings panel collapsible) ───────────────────────────────
    const savedKey = (_load("apiKey","") || "").trim();
    el.apikey.value = savedKey.length === 16 ? savedKey : "";
    el.apikey.placeholder = savedKey.length === 16 ? "Key saved ✓" : "Paste 16-character key…";
    el.apikeyBtn.addEventListener("click", () => {
        const k = el.apikey.value.trim();
        if (k.length !== 16) { showStatus("warn", `⚠ Key must be 16 characters (got ${k.length}).`); return; }
        _save("apiKey", k);
        el.apikey.placeholder = "Key saved ✓";
        el.apikey.value = "";
        showStatus("ok", "✓ Key saved. Click Auto-fill to load your data.");
        loadGymsFromAPI(k);
    });
    q("#gg-autofill-settings")?.addEventListener("click", autofill);

    // ── Panel visibility ──────────────────────────────────────────────────────
    // Single primary screen (gym/stat/session inputs) with two overlay panels:
    // 🎖 Bonuses & Perks, and 📋 Plan ahead. Only one shows at a time.
    // New UX: no tabs, just overlay panels toggled by buttons
    let activePanel = null; // "bonuses" | "plan" | null

    function showPanel(name) {
        activePanel = activePanel === name ? null : name;
        q("#gg-bonuses-inputs")?.classList.toggle("active", activePanel === "bonuses");
        q("#gg-plan-inputs")?.classList.toggle("active",    activePanel === "plan");
        // Highlight active button
        q("#gg-tab-bonuses-btn")?.classList.toggle("active", activePanel === "bonuses");
        q("#gg-tab-plan")?.classList.toggle("active",        activePanel === "plan");
        if (activePanel === "bonuses") updateBonusSummary();
        if (activePanel === "plan")    updatePlanMethodOpts();
    }

    q("#gg-tab-bonuses-btn")?.addEventListener("click", () => showPanel("bonuses"));
    q("#gg-tab-plan")?.addEventListener("click", () => showPanel("plan"));

    // Plan method sub-options toggle
    function updatePlanMethodOpts() {
        const m = q("#gg-plan-method")?.value || "daily";
        q("#gg-plan-xanax-opts") && (q("#gg-plan-xanax-opts").style.display = m==="xanax" ? "" : "none");
        q("#gg-plan-daily-opts") && (q("#gg-plan-daily-opts").style.display = m==="daily" ? "" : "none");
        q("#gg-plan-jump-opts")  && (q("#gg-plan-jump-opts").style.display  = m==="jump"  ? "" : "none");
    }
    q("#gg-plan-method")?.addEventListener("change", updatePlanMethodOpts);
    updatePlanMethodOpts();

    // Plan-ahead calculate button → routes to the right calculator
    q("#gg-plan-calc")?.addEventListener("click", () => {
        const m = q("#gg-plan-method")?.value || "daily";
        showPanel(null); // close plan panel
        if      (m === "xanax") calculateXanaxGrind();
        else if (m === "jump")  calculateHappyJump();
        else                    calculateDaily();
    });

    // ── Bonus summary — live combined multiplier shown on Bonuses tab ────────
    function updateBonusSummary() {
        const el_ = q("#gg-bonus-summary");
        if (!el_) return;
        const stat   = el.stat.value;
        const bonus  = calcBonus(stat);
        const pct    = ((bonus - 1) * 100).toFixed(1);
        const parts  = [];
        if (getPerk(el.factionPerk)    > 0) parts.push(`Faction +${el.factionPerk.value}%`);
        if (getPerk(el.propertyPerk)   > 0) parts.push(`Property +${el.propertyPerk.value}%`);
        if (getPerk(el.eduStatPerk)    > 0) parts.push(`Edu(stat) +${el.eduStatPerk.value}%`);
        if (getPerk(el.eduGenPerk)     > 0) parts.push(`Edu(all) +${el.eduGenPerk.value}%`);
        if (getPerk(el.jobPerk)        > 0) parts.push(`Job +${el.jobPerk.value}%`);
        if (getPerk(el.bookPerk)       > 0) parts.push(`Book +${el.bookPerk.value}%`);
        if (getPerk(el.steroids)       > 0) parts.push(`Steroids +${el.steroids.value}%`);
        if (stat === "speed" && getPerk(el.sportsSneakers) > 0) parts.push(`Sneakers +${el.sportsSneakers.value}%`);
        if (parts.length === 0) {
            el_.style.display = "none";
            return;
        }
        el_.style.display = "block";
        el_.innerHTML = `<span style="color:#9aaa9a">Combined: ×${bonus.toFixed(4)} (+${pct}%)</span><br><span style="color:#556;font-size:10px">${parts.join(" · ")}</span>`;
    }

    // Wire all perk inputs to refresh summary
    [el.factionPerk, el.propertyPerk, el.eduStatPerk, el.eduGenPerk,
     el.jobPerk, el.bookPerk, el.steroids, el.sportsSneakers].forEach(e => {
        e?.addEventListener("change", updateBonusSummary);
    });
    el.stat?.addEventListener("change", updateBonusSummary);
    const wireCollapsible = id => {
        q("#"+id)?.querySelector(".gg-collapsible-header")
            ?.addEventListener("click", e => { e.stopPropagation(); q("#"+id)?.classList.toggle("open"); });
    };
    ["gg-od-panel","gg-item-info-panel","gg-settings-panel","gg-profile-panel"].forEach(wireCollapsible);
    // Profile panel: open for new users (so they see what to fill), collapsed for returning
    if (!_load("hasAutofilled", "")) {
        q("#gg-profile-panel")?.classList.add("open");
    } else {
        // Returning user — populate the profile summary line with what's set
        const ps = q("#gg-profile-summary");
        const stat = parseInt(_load("statTotal","0"))||0;
        const goal = parseInt(_load("statGoal","0"))||0;
        // Note: cannot use `fmt` here — it's defined later (TDZ).
        const _fmt = n => Math.round(n).toLocaleString();
        if (ps && stat && goal) ps.textContent = `— ${_fmt(stat)} → ${_fmt(goal)}`;
    }
    q("#gg-logger-panel")?.querySelector(".gg-collapsible-header")?.addEventListener("click", e => {
        e.stopPropagation();
        q("#gg-logger-panel")?.classList.toggle("open");
        renderLogger();
    });

    // ── Load cached prices on startup ─────────────────────────────────────────
    (function loadCachedPrices() {
        const cached = _load(PRICE_CACHE_KEY, null); if (!cached) return;
        try {
            const { ts, data } = JSON.parse(cached);
            if (Date.now() - ts < PRICE_CACHE_TTL && data?.cans && Object.keys(data.cans).length > 0)
                applyPriceData(data, gg_load("subscriber") || "no", true);
        } catch(_) {}
    })();

    // ── Auto-switcher toggle (injected into Settings panel) ───────────────────
    let switcherEnabled = _load('autoSwitchEnabled', true);
    const _swEl = document.createElement('label');
    _swEl.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;margin-top:12px;font-size:12px;user-select:none;-webkit-tap-highlight-color:transparent';
    const _swDot = document.createElement('span');
    _swDot.style.cssText = 'display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0';
    const _swLbl = document.createElement('span');
    const _updateSw = () => {
        _swDot.style.background = switcherEnabled ? '#5a9f5a' : '#555';
        _swLbl.style.color      = switcherEnabled ? '#7abf7a' : '#556';
        _swLbl.textContent = 'Auto gym switcher: ' + (switcherEnabled ? 'ON' : 'OFF');
    };
    _updateSw();
    _swEl.append(_swDot, _swLbl);
    _swEl.addEventListener('click', () => {
        switcherEnabled = !switcherEnabled;
        _save('autoSwitchEnabled', switcherEnabled);
        _updateSw();
        if (!switcherEnabled) hideSwitchBanner();
    });
    q("#gg-settings-panel .gg-collapsible-body")?.appendChild(_swEl);

    // ── Candy/can type → auto-fill cost ──────────────────────────────────────
    q("#gg-hjCandyType")?.addEventListener("change", () => {
        const idx = parseInt(q("#gg-hjCandyType").value);
        if (idx > 0) {
            const candy = CANDY_TYPES[idx-1], p = candyPrices[candy.label];
            if (p && el.hjCandyCost) {
                el.hjCandyCost.value = p; gg_save("hjCandyCost", String(p));
                if (el.candyBadge) el.candyBadge.textContent = p>=1e6?`$${(p/1e6).toFixed(1)}m`:`$${(p/1000).toFixed(0)}k`;
            }
        }
        maybeRecalc();
    });
    q("#gg-hjCanType")?.addEventListener("change", () => {
        const idx = parseInt(q("#gg-hjCanType").value);
        if (idx > 0) {
            const can = CAN_TYPES[idx-1], p = canPrices[can.label];
            if (p && el.hjCanCost) {
                el.hjCanCost.value = p; gg_save("hjCanCost", String(p));
                if (el.canBadge) el.canBadge.textContent = `$${(p/1000).toFixed(0)}k`;
            }
        }
        renderPriceBreakdown(el.subscriber?.value); maybeRecalc();
    });
    q("#gg-subscriber")?.addEventListener("change",        () => renderPriceBreakdown(el.subscriber?.value));
    q("#gg-hjCanFactionPerk")?.addEventListener("change",  () => renderPriceBreakdown(el.subscriber?.value));
    q("#gg-hjCandyVoracity")?.addEventListener("change",   () => renderPriceBreakdown(el.subscriber?.value));
    q("#gg-hjCandyAbsorption")?.addEventListener("change", () => renderPriceBreakdown(el.subscriber?.value));

    el.stat.addEventListener("change", () => {
        gg_save("stat", el.stat.value);
        tryAutoFillStat(); updateDotsDisplay(); updateBestGymPanel(); updateGoalProgress(); maybeRecalc();
    });

    // ── Goal progress badge ───────────────────────────────────────────────────
    function updateGoalProgress() {
        const badge = q("#gg-goal-progress"); if (!badge) return;
        const cur = parseFloat(el.statTotal?.value) || 0;
        const goal = parseFloat(el.statGoal?.value) || 0;
        if (!cur || !goal || goal <= cur) { badge.textContent = ""; return; }
        const pct  = Math.min(100, cur/goal*100).toFixed(1);
        const left = goal - cur;
        const fmt  = n => n>=1e6?(n/1e6).toFixed(2)+"m":n>=1000?Math.round(n/1000)+"k":String(Math.round(n));
        badge.textContent = `${pct}% · ${fmt(left)} to go`;
        badge.style.color = pct >= 90 ? "#66cc88" : pct >= 50 ? "#aaaa5a" : "#556";
    }

    // ── Button wiring ─────────────────────────────────────────────────────────
    el.autofill.addEventListener("click", () => {
        try { autofill(); }
        catch(e) { showStatus("warn", "✗ Auto-fill error: " + e.message); }
    });
    el.fetchPricesBtn.addEventListener("click", () => fetchPrices(!!_load(PRICE_CACHE_KEY, null)));
    el.calc.addEventListener("click", calculateSession);
    el.compare.addEventListener("click",        () => { try { calculateCompare(); } catch(e) { showStatus("warn", "✗ "+e.message); } });
    el.copy.addEventListener("click",           copyResults);
    wrap.addEventListener("keydown", e => { if (e.key === "Enter" && document.activeElement?.tagName !== "BUTTON") calculateSession(); });

    // ── Auto-recalc on input change ───────────────────────────────────────────
    const RECALC_INPUTS = ["gym","stat","subscriber","statTotal","statGoal","happy",
        "factionPerk","propertyPerk","eduStatPerk","eduGenPerk","jobPerk","bookPerk",
        "steroids","sportsSneakers","energy","dailyRefill","hjXanaxCount","hjEDVDs",
        "hjANJob","hjEcstasy","hjRefill","hjVoracity","hjBaseHappy",
        "hjFHC","hjLSD","hjCans","hjCanType","hjCanFactionPerk",
        "hjCandies","hjCandyType","hjCandyVoracity","hjCandyAbsorption"];
    RECALC_INPUTS.forEach(k => {
        const input = el[k]; if (!input) return;
        input.addEventListener("change", () => {
            if (k === "gym" && el.gym.value) gymConfirmed = true;
            updateDotsDisplay(); updateBestGymPanel(); validateInputs(); maybeRecalc();
        });
    });

    q("#gg-bestgym-panel")?.querySelector(".gg-bestgym-header")
        ?.addEventListener("click", () => { q("#gg-bestgym-panel").classList.toggle("open"); updateBestGymPanel(); });

    // ── Startup: detect gym, restore stats, load gym API data ─────────────────
    (function startup() {
        el.gym.value = "";
        const domGymId = detectGymFromDOM();
        if (domGymId) { setGymSelect(el.gym, String(domGymId)); el.gymBadge.textContent = "✓ page"; gymConfirmed = true; }
    })();
    setTimeout(() => {
        const d = detectGymFromDOM();
        if (d) { setGymSelect(el.gym, String(d)); el.gymBadge.textContent = "✓ page"; gymConfirmed = true; }
        tryAutoFillStat(); updateDotsDisplay(); validateInputs(); updateBestGymPanel();
    }, 800);
    setTimeout(() => {
        if (!gymConfirmed) {
            const d = detectGymFromDOM();
            if (d) { setGymSelect(el.gym, String(d)); el.gymBadge.textContent = "✓ page"; gymConfirmed = true; updateDotsDisplay(); updateBestGymPanel(); }
        }
    }, 2500);
    setTimeout(() => {
        const k = (_load("apiKey","") || "").trim();
        if (k.length === 16) loadGymsFromAPI(k).then(() => { updateDotsDisplay(); updateBestGymPanel(); });
    }, 100);
    const _storedTs = _load("lastAutofillTs", 0);
    if (_storedTs && el.lastUpdated) {
        const _ago = Math.round((Date.now()-_storedTs)/60000);
        el.lastUpdated.textContent = _ago < 2 ? "updated recently" : `updated ${_ago}m ago`;
    }
    setInterval(() => {
        const ts = _load("lastAutofillTs", 0);
        if (ts && el.lastUpdated) {
            const ago = Math.round((Date.now()-ts)/60000);
            el.lastUpdated.textContent = ago < 1 ? "updated just now" : `updated ${ago}m ago`;
        }
    }, 60000);



    function updateDotsDisplay() {
        const gym  = getGymData();
        const stat = el.stat.value;
        const disp = el.dotsDisplay;
        if (!disp) return;
        if (!gym) { disp.innerHTML = ""; return; }
        const key  = STAT_KEYS[stat];
        const dots = gym[key];
        if (!dots) {
            disp.innerHTML = `<span class="gg-dot-na">✗ ${gym.name} doesn't train ${STAT_LABELS[stat]}</span>`;
            return;
        }
        // Render dot pips (max 9 dots, each dot = 1.0)
        const MAX_DOTS = 9;
        const fullDots = Math.floor(dots);
        const halfDot  = (dots - fullDots) >= 0.3;
        let pips = "";
        for (let i = 0; i < MAX_DOTS; i++) {
            const cls = i < fullDots ? "on" : (i === fullDots && halfDot) ? "half" : "off";
            pips += `<span class="gg-dot ${cls}" title="${dots} dots"></span>`;
        }
        // Best gym for stat — use epsilon comparison to avoid floating point issues
        const allDots  = GYMS.map(g => g[key] || 0);
        const bestDots = Math.max(...allDots);
        const bestGym  = GYMS.find(g => Math.abs((g[key] || 0) - bestDots) < 0.01);
        const isBest   = bestGym && gym.id === bestGym.id;
        const bestLabel = bestGym ? (bestGym.name || `Gym ${bestGym.id}`) : null;
        disp.innerHTML = `${pips}<span class="gg-dot-val">${dots} dots</span>`
            + (isBest ? `<span style="margin-left:6px;font-size:10px;color:#aaaa5a">★ best for ${STAT_LABELS[stat]}</span>`
             : bestLabel ? `<span style="margin-left:6px;font-size:10px;color:#446">best: ${bestLabel} (${bestDots})</span>` : "");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // BEST GYM PANEL
    // ─────────────────────────────────────────────────────────────────────────
    // BEST GYM PANEL — shows best unlocked gym for the selected stat,
    // updates whenever stat or gym changes
    // ─────────────────────────────────────────────────────────────────────────
    function updateBestGymPanel() {
        const body = el.bestGymBody;
        if (!body || !q("#gg-bestgym-panel").classList.contains("open")) return;

        // If gym hasn't been confirmed from page or API, show a prompt
        if (!gymConfirmed && !el.gym.value) {
            body.innerHTML = `<div style="font-size:11px;color:#bf9f5a;padding:6px 2px;line-height:1.6">
                ⚠ Gym not detected yet.<br>
                Click <strong>⟳ Auto-fill</strong> to detect your current gym and see the best gym recommendation.
            </div>`;
            return;
        }

        const stat       = el.stat.value;
        const statKey    = STAT_KEYS[stat];
        const statLabel  = STAT_LABELS[stat];
        const currentGym = getGymData();
        const currentId  = currentGym?.id || 0;
        const hasStats   = allStats.strength > 0 || allStats.speed > 0 || allStats.defense > 0 || allStats.dexterity > 0;
        const manualOwned = JSON.parse(_load('manualGymOwned','[]'));
        const currentPos = STANDARD_UNLOCK_ORDER[currentId] ?? 0;

        // A gym is "owned" if:
        //  - Standard: sequential position ≤ current position (unlocks automatically)
        //  - Specialist: manually confirmed via + button
        //  - Unknown/new gym: treat as owned if it appears in API data (user clearly has access)
        function isOwned(g) {
            if (SPECIALIST_IDS.has(g.id)) return manualOwned.includes(g.id);
            const knownPos = STANDARD_UNLOCK_ORDER[g.id];
            if (knownPos !== undefined) return knownPos <= currentPos;
            // Unknown gym ID (new gym added to Torn after this script was written)
            // If it came from the API, the user likely has access — treat as owned
            return true;
        }

        // Check stat ratio for specialist gyms
        function ratioMet(gymId) {
            const req = SPECIALIST_REQS[gymId];
            if (!req) return true;
            if (!hasStats) return false; // can't check without stats
            return req.check(allStats);
        }

        // Check if specialist prereq gym is owned
        function prereqOwned(gymId) {
            const req = SPECIALIST_REQS[gymId];
            if (!req) return true;
            const prereqPos = STANDARD_UNLOCK_ORDER[req.prereqId] ?? 99;
            return prereqPos <= currentPos;
        }

        // Build categorised gym list for this stat
        const ownedGyms      = []; // owned + trains this stat
        const purchasable    = []; // ratio met, prereq owned, but not purchased yet
        const ratioNeeded    = []; // specialist, prereq owned, but ratio not met
        const prereqNeeded   = []; // specialist, prereq not unlocked yet

        for (const g of GYMS) {
            const dots = g[statKey] || 0;
            if (!dots) continue; // doesn't train this stat

            if (isOwned(g)) {
                ownedGyms.push({ gym:g, dots });
            } else if (SPECIALIST_IDS.has(g.id)) {
                if (!prereqOwned(g.id)) {
                    prereqNeeded.push({ gym:g, dots, req: SPECIALIST_REQS[g.id] });
                } else if (!ratioMet(g.id)) {
                    ratioNeeded.push({ gym:g, dots, req: SPECIALIST_REQS[g.id] });
                } else {
                    purchasable.push({ gym:g, dots, req: SPECIALIST_REQS[g.id] });
                }
            }
            // Standard gyms above current position are just skipped (not unlocked yet)
        }

        // Sort all by dots descending
        [ownedGyms, purchasable, ratioNeeded, prereqNeeded].forEach(a => a.sort((x,y) => y.dots - x.dots));

        const bestOwned    = ownedGyms[0];
        const currentDots  = currentGym?.[statKey] || 0;

        let html = "";

        // ── Best available gym ────────────────────────────────────────────────
        if (bestOwned) {
            const isCurrent   = bestOwned.gym.id === currentId;
            const gymName_    = bestOwned.gym.name || `Gym ${bestOwned.gym.id}`;
            const tierLabel   = bestOwned.gym.tier === "J" ? gymName_ : `[${bestOwned.gym.tier}] ${gymName_}`;
            const pct         = currentDots > 0 && bestOwned.dots > currentDots
                ? `+${(((bestOwned.dots/currentDots)-1)*100).toFixed(0)}% vs current`
                : "";
            const drugNote    = bestOwned.gym.id === 31 ? " · ≤50 Xanax+Ecstasy" : "";
            const useBtn      = !isCurrent
                ? `<button class="gg-gym-use-btn" data-gymid="${bestOwned.gym.id}" style="margin-left:auto;font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a4a2a;background:#182018;color:#7abf7a;cursor:pointer">Use</button>` : "";

            html += `<div style="padding:4px 0 8px">
  <div style="font-size:10px;color:#4a8a4a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">★ Best for ${statLabel}</div>
  <div class="gg-gym-row best" style="background:#181e14;border:1px solid #2a3a20">
    <span class="gg-gym-rank">★</span>
    <span class="gg-gym-name best">${tierLabel}${isCurrent?" ← you're here":""}</span>
    <span class="gg-gym-e">${bestOwned.gym.energy}E</span>
    <span class="gg-gym-dots-val">${bestOwned.dots}</span>
    ${useBtn}
  </div>
  ${pct ? `<div style="font-size:11px;color:#5a9f5a;margin-top:4px;padding-left:8px">Switch for <strong>${pct}</strong> more ${statLabel} per energy${drugNote}</div>` : `<div style="font-size:11px;color:#446;margin-top:4px;padding-left:8px">You're already in the best gym${drugNote}</div>`}
</div>`;
        } else {
            html += `<div style="font-size:11px;color:#556;padding:4px 0 8px">No owned gyms found that train ${statLabel}.</div>`;
        }

        // ── Your other unlocked gyms (compact) ───────────────────────────────
        const others = ownedGyms.slice(1);
        if (others.length) {
            html += `<div class="gg-bestgym-sec">Your other unlocked gyms for ${statLabel}</div>`;
            others.forEach((e, i) => {
                const isCurrent = e.gym.id === currentId;
                const tierLabel = e.gym.tier === "J" ? e.gym.name : `[${e.gym.tier}] ${e.gym.name}`;
                const useBtn    = !isCurrent
                    ? `<button class="gg-gym-use-btn" data-gymid="${e.gym.id}">Use</button>` : "";
                html += `<div class="gg-gym-row ${isCurrent?"current":""}">
  <span class="gg-gym-rank">#${i+2}</span>
  <span class="gg-gym-name ${isCurrent?"current":""}">${tierLabel}${isCurrent?" ←":""}</span>
  <span class="gg-gym-e">${e.gym.energy}E</span>
  <span class="gg-gym-dots-val dim">${e.dots}</span>
  ${useBtn}
</div>`;
            });
        }

        // ── Purchasable specialists ───────────────────────────────────────────
        if (purchasable.length) {
            html += `<div class="gg-bestgym-sec" style="margin-top:8px">💰 Your stats qualify — purchase to unlock</div>`;
            purchasable.forEach(e => {
                const tierLabel = `[${e.gym.tier}] ${e.gym.name}`;
                const drugNote  = e.gym.id === 31 ? " (drug-limited)" : "";
                const ownBtn    = `<button class="gg-gym-own-btn" data-gymid="${e.gym.id}" style="font-size:10px;padding:2px 7px;border-radius:3px;border:1px solid #2a5a2a;background:#182518;color:#6a9f6a;cursor:pointer">I own it</button>`;
                html += `<div class="gg-gym-row">
  <span class="gg-gym-rank">💰</span>
  <span class="gg-gym-name">${tierLabel}${drugNote}</span>
  <span class="gg-gym-e">${e.gym.energy}E</span>
  <span class="gg-gym-dots-val">${e.dots}</span>
  ${ownBtn}
</div>`;
            });
        }

        // ── Ratio not met ─────────────────────────────────────────────────────
        if (ratioNeeded.length) {
            html += `<div class="gg-bestgym-sec" style="margin-top:8px">📊 Better gyms — stat ratio not yet met</div>`;
            ratioNeeded.forEach(e => {
                const tierLabel = `[${e.gym.tier}] ${e.gym.name}`;
                html += `<div class="gg-gym-row" title="${e.req?.desc||''}">
  <span class="gg-gym-rank">📊</span>
  <span class="gg-gym-name">${tierLabel}</span>
  <span class="gg-gym-e">${e.gym.energy}E</span>
  <span class="gg-gym-dots-val dim">${e.dots}</span>
  <span class="gg-gym-locked" title="${e.req?.desc||''}" style="font-size:10px;color:#5a4a2a;max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${e.req?.desc||''}</span>
</div>`;
            });
        }

        // ── Prereq not unlocked ───────────────────────────────────────────────
        if (prereqNeeded.length) {
            html += `<div class="gg-bestgym-sec" style="margin-top:8px">🔒 Unlock ${GYM_BY_ID[prereqNeeded[0]?.req?.prereqId]?.name || "required gym"} first</div>`;
            prereqNeeded.forEach(e => {
                const tierLabel = `[${e.gym.tier}] ${e.gym.name}`;
                html += `<div class="gg-gym-row">
  <span class="gg-gym-rank">🔒</span>
  <span class="gg-gym-name" style="color:#444">${tierLabel}</span>
  <span class="gg-gym-e">${e.gym.energy}E</span>
  <span class="gg-gym-dots-val dim">${e.dots}</span>
</div>`;
            });
        }

        if (!hasStats) html += `<div class="gg-bestgym-note" style="margin-top:8px">⚠ Auto-fill with API key to check specialist gym eligibility based on your actual stat ratios.</div>`;

        body.innerHTML = html;

        // Wire "I own it" buttons — mark specialist gym as purchased
        body.querySelectorAll(".gg-gym-own-btn").forEach(btn => {
            btn.addEventListener("click", e => {
                e.stopPropagation();
                const gymId = parseInt(btn.dataset.gymid);
                const owned = JSON.parse(_load('manualGymOwned','[]'));
                if (!owned.includes(gymId)) owned.push(gymId);
                _save('manualGymOwned', JSON.stringify(owned));
                updateBestGymPanel();
            });
        });

        // Wire "Use" buttons — switch calculator to that gym
        body.querySelectorAll(".gg-gym-use-btn").forEach(btn => {
            btn.addEventListener("click", e => {
                e.stopPropagation();
                const gymId = btn.dataset.gymid;
                setGymSelect(el.gym, gymId);
                gg_save("gymId", gymId);
                updateDotsDisplay();
                updateBestGymPanel();
                maybeRecalc();
                showStatus("ok", `✓ Calculator switched to ${GYM_BY_ID[parseInt(gymId)]?.name || "selected gym"}.`);
            });
        });
    }


    // ─────────────────────────────────────────────────────────────────────────
    // INPUT VALIDATION
    // ─────────────────────────────────────────────────────────────────────────
    function validateInputs() {
        let valid = true;
        const statNow  = parseFloat(el.statTotal.value) || 0;
        const statGoal = parseFloat(el.statGoal.value)  || 0;
        const happy    = parseFloat(el.happy.value)     || 0;

        // Current stat
        const statBad = statNow < 100 && el.statTotal.value !== "";
        el.statTotal.classList.toggle("invalid", statBad);
        el.valStat.classList.toggle("show", statBad);
        if (statBad) valid = false;

        // Goal
        const goalBad = statGoal > 0 && statGoal <= statNow;
        el.statGoal?.classList.toggle("warn-input", goalBad);
        el.valGoal?.classList.toggle("show", goalBad);
        if (goalBad) valid = false;

        // Happy
        const happyBad = happy < 100 && el.happy.value !== "";
        el.happy.classList.toggle("invalid", happyBad);
        el.valHappy.classList.toggle("show", happyBad);
        if (happyBad) valid = false;

        // Gym + stat compatibility
        const gym  = getGymData();
        const dots = gym?.[STAT_KEYS[el.stat.value]] || 0;
        el.gym.classList.toggle("warn-input", gym && !dots);

        return valid;
    }

    // Auto-recalc only if results panel is visible
    let _recalcTimer = null;
    function maybeRecalc() {
        const dailyVisible   = el.dailyResults.style.display   !== "none";
        const jumpVisible    = el.jumpResults.style.display    !== "none";
        const compareVisible = el.compareResults.style.display !== "none";
        if (!dailyVisible && !jumpVisible && !compareVisible) return;
        clearTimeout(_recalcTimer);
        _recalcTimer = setTimeout(() => {
            try {
                if (compareVisible) calculateCompare();
                else if (jumpVisible) calculateHappyJump();
                else calculateSession();
            } catch(e) { /* silently ignore during auto-recalc */ }
        }, 400);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // INFOBAR — shows live page energy/happy and last autofill time
    // ─────────────────────────────────────────────────────────────────────────
    function updateInfobar(currentE, maxE, currentH, isSubscriber, timestamp) {
        if (!el.infobar) return;
        el.infobar.style.display = "flex";
        if (currentE != null) {
            el.infoEnergy.textContent = currentE > maxE
                ? `${currentE}/${maxE} (+${currentE-maxE} over)`
                : `${currentE}/${maxE}`;
            el.infoEnergy.className = "gg-infobar-val" + (currentE >= maxE ? " hi" : "");
        }
        if (currentH != null) el.infoHappy.textContent = currentH.toLocaleString();
        if (isSubscriber != null) {
            el.infoSubWrap.style.display = isSubscriber ? "" : "none";
        }
        if (timestamp) {
            const ago = Math.round((Date.now() - timestamp) / 60000);
            el.lastUpdated.textContent = ago < 1 ? "updated just now" : `updated ${ago}m ago`;
        }
    }

    // Update "X min ago" every minute
    setInterval(() => {
        const ts = _load("lastAutofillTs", 0);
        if (ts && el.lastUpdated) {
            const ago = Math.round((Date.now() - ts) / 60000);
            el.lastUpdated.textContent = ago < 1 ? "updated just now" : `updated ${ago}m ago`;
        }
    }, 60000);

    // ─────────────────────────────────────────────────────────────────────────
    // STATUS
    // ─────────────────────────────────────────────────────────────────────────
    function showStatus(type, msg) {
        el.status.className = `gg-status ${type}`;
        el.status.textContent = msg;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // API FETCH — user data (perks, stats, gym, bars, properties)
    // ─────────────────────────────────────────────────────────────────────────
    async function fetchFromAPI(stat) {
        const key = (_load("apiKey","") || "").trim();
        if (!key || key.length !== 16) return null;

        let data;
        try {
            data = await gmFetch(`https://api.torn.com/user/?selections=perks,battlestats,gym,bars,basic,properties&key=${key}&comment=GymGains`);
        } catch(e) {
            return { error: "Network error." };
        }
        if (data.error) return { error: `API ${data.error.code}: ${data.error.error}` };

        const statName = STAT_LABELS[stat].toLowerCase();
        const out = {
            factionPerk:0, propertyPerk:0, eduStatPerk:0, eduGenPerk:0, jobPerk:0, bookPerk:0,
            statValue:null, gymId:null, energyCap:null, subscriber:null, currentEnergy:null,
            propertyHappy:null, currentHappy:null, maxHappy:null, filled:[],
        };

        // Battle stats — capture all 4 for specialist gym eligibility checks
        if (data.battlestats) {
            const bs = data.battlestats;
            allStats = {
                strength:  Math.round(bs.strength  || 0),
                speed:     Math.round(bs.speed     || 0),
                defense:   Math.round(bs.defense   || 0),
                dexterity: Math.round(bs.dexterity || 0),
            };
            cache.set('battlestats', allStats);
            if (allStats[stat]) {
                out.statValue = allStats[stat];
                out.filled.push(`${STAT_LABELS[stat]} ${out.statValue.toLocaleString()}`);
            }
        }

        // Bars — energy cap (subscriber) + current energy + current/max happy
        if (data.energy?.maximum) {
            out.energyCap     = data.energy.maximum;
            out.subscriber    = data.energy.maximum >= 150 ? "yes" : "no";
            out.currentEnergy = data.energy.current ?? null;
            out.filled.push(`Subscriber: ${out.subscriber === "yes" ? "Yes" : "No"}`);
        }
        if (data.happy?.current != null) {
            out.currentHappy = data.happy.current;
            out.maxHappy     = data.happy.maximum;
        }

        // Gym — user?selections=gym returns the gym_id as a plain integer: { "gym": 13 }
        // Some API versions may return { gym: { gym_id: 13 } } — handle both
        const rawGym = data.gym;
        const gymId  = typeof rawGym === "number" ? rawGym
                     : typeof rawGym === "object" && rawGym !== null
                       ? (rawGym.gym_id ?? rawGym.id ?? null)
                       : null;
        if (gymId) {
            out.gymId = gymId;
            const g = GYM_BY_ID[gymId];
            if (g) out.filled.push(g.name);
        }

        // Property max happy — prefer bars.happy.maximum (reflects upgrades+staff)
        if (out.maxHappy && out.maxHappy > 0) {
            out.propertyHappy = out.maxHappy;
            out.filled.push(`Max Happy ${out.maxHappy.toLocaleString()}`);
        } else if (data.properties) {
            let max = 0;
            for (const p of Object.values(data.properties)) {
                if (p.happy && p.happy > max) max = p.happy;
            }
            if (max > 0) { out.propertyHappy = max; out.filled.push(`Max Happy ${max.toLocaleString()}`); }
        }
        if (out.currentHappy != null) out.filled.push(`Happy ${out.currentHappy.toLocaleString()}`);

        // Perks — API returns SEPARATE arrays per category, NOT a single flat array.
        // Each string is the perk text alone, e.g. "Increases dexterity gym gains by 8%"
        // Categories: faction_perks, property_perks, education_perks, company_perks, book_perks
        // Also handle legacy flat data.perks array (some API versions) with "Category: text" prefix
        function parsePerkList(arr, category) {
            if (!Array.isArray(arr)) return;
            for (const p of arr) {
                const lower = p.toLowerCase();
                if (!lower.includes('gym gain')) continue;
                const isGeneral  = lower.includes('all gym gain');
                const isThisStat = lower.includes(statName + ' gym gain');
                if (!isGeneral && !isThisStat) continue;
                const m = p.match(/(\d+(?:\.\d+)?)\s*%/);
                if (!m) continue;
                const pct = parseFloat(m[1]);
                switch (category) {
                    case 'faction':   out.factionPerk  += pct; break;
                    case 'property':  out.propertyPerk += pct; break;
                    case 'education': isThisStat ? (out.eduStatPerk += pct) : (out.eduGenPerk += pct); break;
                    case 'company':   out.jobPerk      += pct; break;
                    case 'book':      out.bookPerk     += pct; break;
                }
                perkCount++;
            }
        }

        let perkCount = 0;
        // Primary: separate named arrays (standard API v1 response)
        parsePerkList(data.faction_perks,   'faction');
        parsePerkList(data.property_perks,  'property');
        parsePerkList(data.education_perks, 'education');
        parsePerkList(data.company_perks,   'company');
        parsePerkList(data.book_perks,      'book');
        // Also parse job_perks if present
        parsePerkList(data.job_perks,       'company');

        // Fallback: legacy flat data.perks array with "Category: text" prefix format
        if (perkCount === 0 && Array.isArray(data.perks)) {
            for (const p of data.perks) {
                const lower = p.toLowerCase();
                if (!lower.includes('gym gain')) continue;
                const isGeneral  = lower.includes('all gym gain');
                const isThisStat = lower.includes(statName + ' gym gain');
                if (!isGeneral && !isThisStat) continue;
                const m = p.match(/(\d+(?:\.\d+)?)\s*%/);
                if (!m) continue;
                const pct = parseFloat(m[1]);
                if      (lower.startsWith('faction'))                          out.factionPerk  += pct;
                else if (lower.startsWith('property'))                         out.propertyPerk += pct;
                else if (lower.startsWith('education') && isThisStat)          out.eduStatPerk  += pct;
                else if (lower.startsWith('education') && isGeneral)           out.eduGenPerk   += pct;
                else if (lower.startsWith('job')||lower.startsWith('company')) out.jobPerk      += pct;
                else if (lower.startsWith('book'))                             out.bookPerk     += pct;
                perkCount++;
            }
        }

        if (perkCount > 0) out.filled.push(`${perkCount} gym perk${perkCount!==1?"s":""}`);

        // Store player identity from basic selection for use in logs
        if (data.player_id) { _save('playerId', String(data.player_id)); }
        if (data.name)      { _save('playerName', data.name); }

        return out;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PRICE FETCH — weav3r.dev bazaar (live) + torn/items market_value (fallback)
    // ─────────────────────────────────────────────────────────────────────────
    async function fetchItemPrice(itemId) {
        if (!itemId) return null;
        try {
            const d = await gmFetch("https://weav3r.dev/api/marketplace/"+itemId);
            let price = null;
            if (d?.listings?.length > 0) {
                const s = d.listings.map(l=>l.price).filter(p=>p>100).sort((a,b)=>a-b);
                if (s.length) price = s[0];
            }
            if (!price && d?.market_price > 100) price = d.market_price;
            return price || null;
        } catch(_) { return null; }
    }

    async function fetchPrices(forceRefresh = false) {
        const apiKey     = (_load("apiKey","") || "").trim();
        const subscriber = el.subscriber?.value || _load("subscriber","no");
        if (forceRefresh) {
            _save(PRICE_CACHE_KEY, null);
            _save("canItemIds", null); _save("canItemIdsTs", 0);
            CAN_TYPES.forEach(c => c.id = null); CANDY_TYPES.forEach(c => c.id = null);
        }
        if (!forceRefresh) {
            const cached = _load(PRICE_CACHE_KEY, null);
            if (cached) {
                try {
                    const { ts, data } = JSON.parse(cached);
                    if (Date.now()-ts < PRICE_CACHE_TTL) {
                        applyPriceData(data, subscriber, true);
                        showStatus("ok", "✓ Prices cached (" + Math.round((Date.now()-ts)/60000) + "m ago) — click ⟳ to refresh");
                        return;
                    }
                } catch(_) {}
            }
        }
        showStatus("ok", "⟳ Fetching live prices…");
        await resolveCanIDs(apiKey);
        const MAIN = [
            {key:"xanax",   id:ITEM_IDS.xanax,   label:"Xanax",   badge:el.xanaxBadge,   save:"hjXanaxCost",   input:el.hjXanaxCost},
            {key:"edvd",    id:ITEM_IDS.edvd,     label:"eDVD",    badge:el.edvdBadge,    save:"hjEDVDCost",    input:el.hjEDVDCost},
            {key:"ecstasy", id:ITEM_IDS.ecstasy,  label:"Ecstasy", badge:el.ecstasyBadge, save:"hjEcstasyCost", input:el.hjEcstasyCost},
            {key:"fhc",     id:ITEM_IDS.fhc,      label:"FHC",     badge:el.fhcBadge,     save:"hjFHCCost",     input:el.hjFHCCost},
            {key:"lsd",     id:ITEM_IDS.lsd,      label:"LSD",     badge:el.lsdBadge,     save:"hjLSDCost",     input:el.hjLSDCost},
        ];
        MAIN.forEach(m => { if (m.badge) { m.badge.className = "gg-price-badge loading"; m.badge.textContent = "⟳"; } });
        const rC = CAN_TYPES.filter(c => c.id), rD = CANDY_TYPES.filter(c => c.id);
        const [mR,cR,dR] = await Promise.all([
            Promise.all(MAIN.map(m => fetchItemPrice(m.id))),
            Promise.all(rC.map(c => fetchItemPrice(c.id))),
            Promise.all(rD.map(c => fetchItemPrice(c.id))),
        ]);
        const snap = { main:{}, cans:{}, candies:{} };
        const fmt  = p => p >= 1e6 ? "$" + (p/1e6).toFixed(1) + "m" : "$" + Math.round(p/1000) + "k";
        MAIN.forEach((m,i) => {
            const p = mR[i];
            if (p) { snap.main[m.key]=p; gg_save(m.save,p); if(m.input) m.input.value=p; if(m.badge){m.badge.className="gg-price-badge";m.badge.textContent=fmt(p);} }
            else if (m.badge) { m.badge.className="gg-price-badge err"; m.badge.textContent="—"; }
        });
        rC.forEach((c,i) => { if (cR[i]) snap.cans[c.label]    = cR[i]; });
        rD.forEach((c,i) => { if (dR[i]) snap.candies[c.label] = dR[i]; });
        _save(PRICE_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: snap }));
        applyPriceData(snap, subscriber, false);
        const mc=Object.keys(snap.main).length, cc=Object.keys(snap.cans).length, dc=Object.keys(snap.candies).length;
        showStatus("ok", (!apiKey && (cc===0||dc===0))
            ? "✓ " + mc + " main prices · Add API key in ⚙ for can/candy prices"
            : "✓ " + mc + " items · " + cc + "/" + CAN_TYPES.length + " cans · " + dc + "/" + CANDY_TYPES.length + " candy · 1h cache");
    }

    // Apply a price snapshot to inputs, badges, and breakdown panel
    function applyPriceData(snapshot, subscriber, fromCache) {
        const mainDefs = {
            xanax:   { e: 250, label: "Xanax",   saveKey: "hjXanaxCost",   inputEl: el.hjXanaxCost,   badge: el.xanaxBadge },
            edvd:    { e: 0,   label: "eDVD",    saveKey: "hjEDVDCost",    inputEl: el.hjEDVDCost,    badge: el.edvdBadge },
            ecstasy: { e: 0,   label: "Ecstasy", saveKey: "hjEcstasyCost", inputEl: el.hjEcstasyCost, badge: el.ecstasyBadge },
            fhc:     { e: subscriber === "yes" ? 150 : 100, label: "FHC", saveKey: "hjFHCCost", inputEl: el.hjFHCCost, badge: el.fhcBadge },
            lsd:     { e: 50,  label: "LSD",     saveKey: "hjLSDCost",     inputEl: el.hjLSDCost,     badge: el.lsdBadge },
        };

        // Apply main item prices
        for (const [key, def] of Object.entries(mainDefs)) {
            const p = snapshot.main?.[key];
            if (p) {
                allItemPrices[key] = p;
                if (def.inputEl) { def.inputEl.value = p; gg_save(def.saveKey, String(p)); }
                if (def.badge) {
                    def.badge.className = "gg-price-badge";
                    const inK = p >= 1e6 ? `$${(p/1e6).toFixed(1)}m` : `$${(p/1000).toFixed(0)}k`;
                    def.badge.textContent = inK;
                }
                // Also sync Xanax price to Xanax Grind cost field
                if (key === 'xanax') {
                    if (el.xgXanaxCost) { el.xgXanaxCost.value = p; gg_save("xgXanaxCost", String(p)); }
                    const xgBadge = wrap.querySelector('#gg-xg-xanax-price');
                    if (xgBadge) {
                        xgBadge.className = "gg-price-badge";
                        xgBadge.textContent = p >= 1e6 ? `$${(p/1e6).toFixed(1)}m` : `$${(p/1000).toFixed(0)}k`;
                    }
                }
            }
        }

        // Apply can prices
        canPrices = {};
        CAN_TYPES.forEach(c => {
            const p = snapshot.cans?.[c.label];
            if (p) { canPrices[c.label] = p; allItemPrices['can_' + c.label] = p; }
        });

        // Apply candy prices
        candyPrices = {};
        CANDY_TYPES.forEach(c => {
            const p = snapshot.candies?.[c.label];
            if (p) { candyPrices[c.label] = p; allItemPrices['candy_' + c.label] = p; }
        });

        // Update selected candy cost input
        const selCandyIdx = gi(el.hjCandyType);
        if (selCandyIdx > 0) {
            const selCandy = CANDY_TYPES[selCandyIdx - 1];
            const p = candyPrices[selCandy.label];
            if (p && el.hjCandyCost) {
                el.hjCandyCost.value = p;
                gg_save("hjCandyCost", String(p));
                if (el.candyBadge) {
                    el.candyBadge.textContent = p >= 1e6 ? `$${(p/1e6).toFixed(1)}m` : `$${(p/1000).toFixed(0)}k`;
                }
            }
        }

        // Update selected can cost input
        const selCanIdx = gi(el.hjCanType);
        if (selCanIdx > 0) {
            const selCan = CAN_TYPES[selCanIdx - 1];
            const p = canPrices[selCan.label];
            if (p && el.hjCanCost) {
                el.hjCanCost.value = p;
                gg_save("hjCanCost", String(p));
                if (el.canBadge) {
                    const inK = p >= 1e6 ? `$${(p/1e6).toFixed(1)}m` : `$${(p/1000).toFixed(0)}k`;
                    el.canBadge.textContent = inK;
                }
            }
        }

        renderPriceBreakdown(subscriber);
    }

    // ── Unified price breakdown ───────────────────────────────────────────────
    function renderPriceBreakdown(subscriber) {
        const panel = q("#gg-price-breakdown");
        if (!panel) return;

        const sub         = subscriber || el.subscriber?.value || gg_load("subscriber") || "no";
        const cap         = sub === "yes" ? 150 : 100;
        const canFacPct   = gi(el.hjCanFactionPerk) / 100;
        const candyVorPct = gi(el.hjCandyVoracity)  / 100;
        const candyAbsPct = el.hjCandyAbsorption?.value === "yes" ? 0.10 : 0;
        const candyMult   = 1 + candyVorPct + candyAbsPct;
        const xanPrice    = parseFloat(el.hjXanaxCost?.value) || 0;
        const fhcPrice    = parseFloat(el.hjFHCCost?.value)   || 0;
        const lsdPrice    = parseFloat(el.hjLSDCost?.value)   || 0;

        const fmtM = p => p >= 1e6 ? `$${(p/1e6).toFixed(2)}m` : p >= 1000 ? `$${Math.round(p/1000)}k` : `$${p}`;

        // Energy sources
        const eRows = [];
        if (xanPrice > 0) eRows.push({ name:"Xanax",         e:250,  price:xanPrice, cd:"6–8h", note:"drug" });
        if (lsdPrice > 0) eRows.push({ name:"LSD",           e:50,   price:lsdPrice, cd:"~3h",  note:"no OD" });
        if (fhcPrice > 0) eRows.push({ name:"FHC",           e:cap,  price:fhcPrice, cd:"6h",   note:`+500 happy` });
        eRows.push(                   { name:"Points Refill", e:cap,  price:1725000,  cd:"daily" });
        CAN_TYPES.forEach(c => {
            const p = canPrices[c.label]; if (!p) return;
            eRows.push({ name:c.label, e:Math.round(c.e*(1+canFacPct)), price:p, cd:"2h" });
        });

        // Candy
        const cRows = [];
        CANDY_TYPES.forEach(c => {
            const p = candyPrices[c.label]; if (!p) return;
            cRows.push({ name:c.label, happy:Math.round(c.happy*candyMult), price:p });
        });

        const hasE = eRows.length > 1;
        const hasC = cRows.length > 0;

        if (!hasE && !hasC) {
            panel.innerHTML = `<div style="font-size:11px;color:#445;padding:6px 0">Click ⟳ Live Prices to load rankings.</div>`;
            return;
        }

        let cacheAge = "";
        try {
            const { ts } = JSON.parse(_load(PRICE_CACHE_KEY, "{}"));
            if (ts) { const m=Math.round((Date.now()-ts)/60000); cacheAge=m<1?"updated just now":`updated ${m}m ago`; }
        } catch(_) {}

        const secHdr = (icon, title, note="") => `
<div style="display:flex;align-items:baseline;gap:6px;margin:12px 0 5px;padding-bottom:4px;border-bottom:1px solid #252535">
  <span style="font-size:11px;font-weight:bold;color:#8a9aaa">${icon} ${title}</span>
  ${note?`<span style="font-size:10px;color:#445">${note}</span>`:""}
</div>`;
        const hdrRow = (cols) => `<div style="display:grid;grid-template-columns:${cols};gap:0 8px;margin-bottom:2px">`;
        const hdrCell = (t,a="right") => `<div style="font-size:9px;color:#445;text-transform:uppercase;letter-spacing:.04em;text-align:${a};padding-bottom:3px;border-bottom:1px solid #1e1e2a">${t}</div>`;

        let html = "";

        if (hasE) {
            eRows.forEach(r => r.perE = r.price / r.e);
            eRows.sort((a,b) => a.perE - b.perE);
            const bestPerE = eRows[0].perE;
            const noteStr = canFacPct > 0 ? `+${Math.round(canFacPct*100)}% faction applied` : "";
            html += secHdr("⚡","Energy Sources — ranked cheapest $/E", noteStr);
            html += hdrRow("1fr 44px 62px 40px");
            html += hdrCell("Item","left")+hdrCell("Energy")+hdrCell("$/E")+hdrCell("CD");
            html += `</div>`;
            eRows.forEach((r,i) => {
                const ratio=r.perE/bestPerE, isBest=i===0;
                const col = isBest?"#7abf7a":ratio<1.3?"#9abf6a":ratio<2?"#bf9f5a":"#667";
                const badge = isBest?` <span style="font-size:9px;color:#3a7a3a;background:#182018;border:1px solid #2a4a2a;border-radius:2px;padding:0 3px">BEST</span>`:"";
                const note = r.note?` <span style="font-size:9px;color:#4a6a5a">${r.note}</span>`:"";
                html += `<div style="display:grid;grid-template-columns:1fr 44px 62px 40px;gap:0 8px;padding:4px 0;border-bottom:1px solid #1a1a22;align-items:baseline">
  <div style="font-size:12px;color:${col};font-weight:${isBest?'bold':'normal'}">${r.name}${badge}${note}</div>
  <div style="font-size:11px;color:#667;text-align:right">+${r.e}E</div>
  <div style="font-size:12px;color:${col};text-align:right;font-weight:${isBest?'bold':'normal'}">${fmt(Math.round(r.perE))}</div>
  <div style="font-size:10px;color:#556;text-align:right">${r.cd}</div>
</div>`;
            });
        }

        if (hasC) {
            cRows.forEach(r => r.perH = r.price / r.happy);
            cRows.sort((a,b) => a.perH - b.perH);
            const bestPerH = cRows[0].perH;
            const modParts=[];
            if (candyVorPct>0) modParts.push(`+${Math.round(candyVorPct*100)}% Voracity`);
            if (candyAbsPct>0) modParts.push(`+10% Absorption`);
            const modStr = modParts.length ? modParts.join(", ")+" applied" : "30min CD · max 48";
            html += secHdr("🍬","Candy — ranked cheapest $/happy", modStr);
            html += hdrRow("1fr 50px 64px");
            html += hdrCell("Item","left")+hdrCell("Happy")+hdrCell("$/happy");
            html += `</div>`;
            cRows.forEach((r,i) => {
                const ratio=r.perH/bestPerH, isBest=i===0;
                const col = isBest?"#7abf7a":ratio<1.3?"#9abf6a":ratio<2.5?"#bf9f5a":"#667";
                const badge = isBest?` <span style="font-size:9px;color:#3a7a3a;background:#182018;border:1px solid #2a4a2a;border-radius:2px;padding:0 3px">BEST</span>`:"";
                html += `<div style="display:grid;grid-template-columns:1fr 50px 64px;gap:0 8px;padding:4px 0;border-bottom:1px solid #1a1a22;align-items:baseline">
  <div style="font-size:12px;color:${col};font-weight:${isBest?'bold':'normal'}">${r.name}${badge}</div>
  <div style="font-size:11px;color:#667;text-align:right">+${r.happy}</div>
  <div style="font-size:12px;color:${col};text-align:right;font-weight:${isBest?'bold':'normal'}">${fmt(Math.round(r.perH))}</div>
</div>`;
            });
            if (cRows[0]) {
                const b=cRows[0];
                html += `<div style="font-size:10px;color:#4a6a5a;margin-top:5px">Best for a jump: ${b.name} ×49 = ${fmtM(49*b.price)} → +${fmt(49*b.happy)} happy</div>`;
            }
        }

        if (hasE) {
            html += secHdr("📊",`Cost to get ${cap}E`);
            eRows.forEach(r => {
                const n=Math.ceil(cap/r.e), total=fmtM(n*r.price);
                const cdStr = r.cd==="daily"?"":r.cd==="6–8h"?` · ${r.cd} CD`:` · ${r.name==="FHC"?n*6:n*2}h CD`;
                html += `<div style="display:flex;justify-content:space-between;align-items:baseline;padding:3px 0;border-bottom:1px solid #1a1a22">
  <span style="font-size:12px;color:#8a9aaa">${r.name}${n>1?` ×${n}`:""}</span>
  <span style="font-size:12px;color:#aab">${total}<span style="font-size:10px;color:#445">${cdStr}</span></span>
</div>`;
            });
        }

        if (cacheAge) html += `<div style="font-size:9px;color:#334;text-align:right;margin-top:8px;padding-top:4px;border-top:1px solid #1a1a22">${cacheAge} · 1h cache · ⟳ to refresh</div>`;

        panel.innerHTML = html;
    }


    // ─────────────────────────────────────────────────────────────────────────
    // DOM STAT DETECTION (fallback / supplement when no API key)
    // ─────────────────────────────────────────────────────────────────────────
    function tryAutoFillStat() {
        const stat = el.stat.value;
        const parseN = t => { const n = parseInt((t||"").replace(/[^0-9]/g,""), 10); return isNaN(n)||n<100 ? null : n; };

        // Method 1: Torn's canonical stat element IDs (confirmed from gym page DOM)
        // e.g. <span id="strength-val">528,412</span>
        const idEl = document.getElementById(stat + '-val');
        if (idEl) { const v = parseN(idEl.textContent); if (v) { setStatValue(v); return v; } }

        // Method 2: abbreviated label + value pattern  e.g. "DEX 528,412"
        const abbrev = STAT_ABBREV[stat];
        const allEls = document.querySelectorAll('h3,h4,span,div,[class*="stat"],[class*="Stat"]');
        for (const node of allEls) {
            if (wrap.contains(node)) continue;
            const txt = node.textContent.trim();
            const m = txt.match(new RegExp('^' + abbrev + '\\s+([\\d,]+)$'));
            if (m) { const v = parseN(m[1]); if (v) { setStatValue(v); return v; } }
            if (txt === abbrev) {
                const sib = node.nextElementSibling;
                if (sib) { const v = parseN(sib.textContent); if (v) { setStatValue(v); return v; } }
            }
        }

        // Method 3: look for full stat name with value — TornPDA sometimes renders
        // "Dexterity\n528,412" or "Dexterity: 528,412" in gym cards
        const fullName = stat.charAt(0).toUpperCase() + stat.slice(1);
        for (const node of allEls) {
            if (wrap.contains(node)) continue;
            const txt = node.textContent.trim();
            const m2 = txt.match(new RegExp(fullName + '[:\\s]+([\\d,]+)', 'i'));
            if (m2) { const v = parseN(m2[1]); if (v) { setStatValue(v); return v; } }
        }

        // Method 4: innerText scan of gym root — last resort
        const root = document.querySelector('#gymroot,[class*="gymRoot"],[class*="gym-root"],.content-wrapper');
        if (root) {
            for (const pat of [
                new RegExp('\\b' + abbrev + '\\s+([\\d,]+)'),
                new RegExp(fullName + '[:\\s]+([\\d,]+)', 'i'),
                new RegExp(stat + '-val[^>]*>([\\d,]+)', 'i'),
            ]) {
                const m = root.innerText.match(pat);
                if (m) { const v = parseN(m[1]); if (v) { setStatValue(v); return v; } }
            }
        }

        el.autofillBadge.style.display = "none";
        return null;
    }

    function setStatValue(v) {
        el.statTotal.value = v;
        gg_save("statTotal", String(v));
        el.autofillBadge.style.display = "inline";
    }

    // ─────────────────────────────────────────────────────────────────────────
    // DOM GYM DETECTION
    // The gym name appears on the page as standalone text (e.g. "Racing Fitness")
    // followed by "You have X/Y energy". We find it by scanning all text nodes.
    // ─────────────────────────────────────────────────────────────────────────
    function detectGymFromDOM() {
        const gymNames = GYMS.map(g => ({ id: g.id, lower: g.name.toLowerCase(), name: g.name }));

        // Method 1: Torn's gym-name element ID (desktop web — confirmed stable)
        // The gym card has a heading with the gym name; sometimes in #gym-root h1/h2/h3
        const headingEls = document.querySelectorAll(
            'h1,h2,h3,[class*="gymName"],[class*="gym-name"],[class*="title"],[id*="gym"]'
        );
        for (const el_ of headingEls) {
            if (wrap.contains(el_)) continue;
            const t = el_.textContent.trim().toLowerCase();
            const g = gymNames.find(g => g.lower === t);
            if (g) return g.id;
        }

        // Method 2: Walk all text nodes (robust but slow — handles any rendering)
        const walker = document.createTreeWalker(
            document.body, NodeFilter.SHOW_TEXT,
            { acceptNode: n => {
                if (wrap.contains(n.parentElement)) return NodeFilter.FILTER_REJECT;
                const t = n.textContent.trim();
                return (t.length >= 4 && t.length <= 60) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
            }}
        );

        const candidates = [];
        let node;
        while ((node = walker.nextNode())) {
            const t = node.textContent.trim().toLowerCase();
            const g = gymNames.find(g => g.lower === t);
            if (g) candidates.push({ id: g.id, node });
        }

        if (!candidates.length) return null;

        // Prefer candidate near energy text
        for (const c of candidates) {
            const container = c.node.parentElement?.closest('div,section,article,li') || c.node.parentElement;
            if (container && /you have\s+[\d,]+\s*\/\s*[\d,]+\s*energy/i.test(container.textContent))
                return c.id;
        }

        // Prefer candidate near "train" button
        for (const c of candidates) {
            const container = c.node.parentElement?.closest('div,section,li') || c.node.parentElement;
            if (container) {
                const hasTrain = [...container.querySelectorAll('button,a')].some(
                    b => b.textContent.trim().toUpperCase() === 'TRAIN'
                );
                if (hasTrain) return c.id;
            }
        }

        return candidates[0].id;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // AUTO-FILL
    // ─────────────────────────────────────────────────────────────────────────
    function autofill() {
        _save("lastAutofillTs", Date.now()); // throttle silent page-load autofill
        const stat   = el.stat.value;
        const apiKey = (_load("apiKey","")||"").trim();

        // Visible loading state on the Auto-fill button itself
        const btn = el.autofill;
        const origText = btn?.textContent;
        const origDisabled = btn?.disabled;
        if (btn) {
            btn.disabled = true;
            btn.textContent = "⟳ Loading…";
            btn.style.opacity = "0.7";
        }

        showStatus("ok", "⟳ Fetching data…");

        const gymLoadPromise = loadGymsFromAPI(apiKey);
        const apiPromise = apiKey.length===16 ? fetchFromAPI(stat) : Promise.resolve(null);
        const domStat  = tryAutoFillStat();
        const domGymId = detectGymFromDOM();

        const restoreBtn = (success) => {
            if (!btn) return;
            btn.disabled = origDisabled;
            btn.textContent = origText;
            btn.style.opacity = "1";
            // Brief green/red flash so user sees the click registered
            if (success) {
                btn.style.transition = "background-color 0.5s ease-out, border-color 0.5s ease-out";
                btn.style.backgroundColor = "#1a3a1a";
                btn.style.borderColor = "#5a9f5a";
                setTimeout(() => {
                    btn.style.backgroundColor = "";
                    btn.style.borderColor = "";
                    setTimeout(() => { btn.style.transition = ""; }, 600);
                }, 50);
            }
        };

        Promise.all([gymLoadPromise, apiPromise]).then(([_, api]) => {
            if (api?.error) { showStatus("warn", `⚠ ${api.error}`); restoreBtn(false); return; }

            const filled=[], missed=[];

            if (api) {
                // Gym from API gym_id — most reliable
                if (api.gymId) {
                    const g = GYM_BY_ID[api.gymId];
                    setGymSelect(el.gym, String(api.gymId));
                    gymConfirmed = true;
                    if (g) {
                        el.gymBadge.textContent = "✓ API";
                        filled.push(g.name);
                    } else {
                        filled.push(`Gym #${api.gymId}`);
                    }
                } else if (domGymId) {
                    setGymSelect(el.gym, String(domGymId));
                    gymConfirmed = true;
                    el.gymBadge.textContent = "✓ DOM";
                }

                // Stat
                if (api.statValue) setStatValue(api.statValue);
                else if (!domStat) missed.push(STAT_LABELS[stat]);

                // Subscriber + happy + current energy
                if (api.subscriber) { el.subscriber.value=api.subscriber; gg_save("subscriber",api.subscriber); }
                if (api.propertyHappy) { el.happy.value=api.propertyHappy; gg_save("happy",String(api.propertyHappy)); }
                if (api.currentEnergy != null && el.energy) {
                    el.energy.value = api.currentEnergy;
                    if (el.energyBadge) el.energyBadge.textContent = "✓ live";
                    filled.push(`Energy ${api.currentEnergy}`);
                }
                if (api.currentHappy != null) {
                    // Set hjBaseHappy to current happy for this session only — don't persist
                    el.hjBaseHappy.value = api.currentHappy;
                }

                // Perks
                const PERK_FIELDS = {factionPerk:"factionPerk",propertyPerk:"propertyPerk",
                    eduStatPerk:"eduStatPerk",eduGenPerk:"eduGenPerk",jobPerk:"jobPerk",bookPerk:"bookPerk"};
                let anyPerk = false;
                for (const [k,ek] of Object.entries(PERK_FIELDS)) {
                    const v = Math.min(Math.round(api[k]||0), 100);
                    if (el[ek]) { el[ek].value=String(v); gg_save(k,String(v)); }
                    if (v>0) anyPerk=true;
                }
                if (anyPerk) { el.perksBadge.textContent="✓ API"; updateBonusSummary(); }
                else el.perksBadge.textContent = "";

                filled.push(...api.filled.filter(f => !filled.includes(f)));
            } else {
                // No API — DOM fallback
                if (domGymId) {
                    const g = GYM_BY_ID[domGymId];
                    setGymSelect(el.gym, String(domGymId));
                    gymConfirmed = true;
                    el.gymBadge.textContent = "✓ DOM";
                    filled.push(g?.name || "Gym detected");
                } else missed.push("gym");
                if (domStat) filled.push(`${STAT_LABELS[stat]} ${domStat.toLocaleString()}`);
                else missed.push(STAT_LABELS[stat]);
                // Subscriber from page
                const em = document.body.innerText.match(/\d+\s*\/\s*(\d+)\s*energy/i);
                if (em) { const sub=parseInt(em[1])>=150?"yes":"no"; el.subscriber.value=sub; gg_save("subscriber",sub); }
                missed.push("perks (add API key)");
            }

            // Scrape current energy from page for infobar (works with or without API)
            const eMatch = document.body.innerText.match(/you have\s+(\d+)\s*\/\s*(\d+)\s*energy/i);
            if (eMatch) {
                updateInfobar(parseInt(eMatch[1]), parseInt(eMatch[2]), api?.currentHappy ?? null,
                    api?.subscriber === "yes", Date.now());
            } else if (api?.currentHappy != null) {
                updateInfobar(null, null, api.currentHappy, api.subscriber === "yes", Date.now());
            }
            _save("lastAutofillTs", Date.now());

            updateDotsDisplay();
            validateInputs();
            updateBestGymPanel();

            const msg = filled.length
                ? "✓ " + filled.join(" · ") + (missed.length?` — missing: ${missed.join(", ")}` : "")
                : "⚠ Could not detect data. Check your API key or enter manually.";
            showStatus(filled.length?"ok":"warn", msg);

            // Onboarding: hide welcome banner, collapse profile panel after first successful autofill
            if (filled.length) {
                _save("hasAutofilled", "1");
                const ob = q("#gg-onboard");
                if (ob) ob.style.display = "none";
                // Update profile summary to show what was filled
                const ps = q("#gg-profile-summary");
                if (ps) {
                    const stat = parseInt(el.statTotal?.value)||0;
                    const goal = parseInt(el.statGoal?.value)||0;
                    if (stat && goal) ps.textContent = `— ${fmt(stat)} → ${fmt(goal)}`;
                }
            }

            if (apiKey.length===16) fetchPrices();

            // Visible success feedback on the button
            restoreBtn(filled.length > 0);
        }).catch(e => {
            showStatus("warn", `✗ Auto-fill error: ${e.message}`);
            restoreBtn(false);
        });
    }

    // ─────────────────────────────────────────────────────────────────────────
    let switchBanner = null;
    function showSwitchBanner(fromGym, toGym, statLabel) {
        hideSwitchBanner();
        switchBanner = document.createElement('div');
        switchBanner.style.cssText = [
            'position:fixed;top:60px;left:50%;transform:translateX(-50%)',
            'background:#1a2a1a;border:1px solid #3a6a3a;border-radius:6px',
            'padding:10px 16px;font-size:13px;color:#7abf7a',
            'z-index:99999;max-width:90vw;text-align:center',
            'box-shadow:0 4px 20px rgba(0,0,0,.6);font-family:Arial,sans-serif',
        ].join(';');
        switchBanner.innerHTML = `
            🏋️ <strong>Switched to ${toGym}</strong> for better ${statLabel} gains<br>
            <span style="font-size:11px;color:#4a8a4a">(was: ${fromGym}) — tap Train again to train</span>`;
        document.body.appendChild(switchBanner);
        setTimeout(hideSwitchBanner, 5000);
    }
    function hideSwitchBanner() {
        if (switchBanner) { switchBanner.remove(); switchBanner = null; }
    }

    // Map stat label text from the Torn UI to our stat keys
    // Torn shows "Strength", "Speed", "Defense", "Dexterity" as headings above Train buttons
    const TORN_STAT_LABELS = {
        'strength':'str', 'speed':'spd', 'defense':'def', 'dexterity':'dex',
        'str':'str', 'spd':'spd', 'def':'def', 'dex':'dex',
    };

    // Find which stat a Train button is for by looking at nearby headings
    function getStatForTrainButton(btn) {
        // TornPDA / Torn desktop: aria-label="Train speed", "Train strength" etc.
        const ariaLabel = btn.getAttribute('aria-label') || '';
        const ariaMatch = ariaLabel.match(/^Train\s+(strength|speed|defense|dexterity)/i);
        if (ariaMatch) return STAT_KEYS[ariaMatch[1].toLowerCase()];

        // Fallback: walk up the DOM looking for a stat label heading near the button
        let node = btn;
        for (let i = 0; i < 8; i++) {
            node = node.parentElement;
            if (!node) break;
            const headings = node.querySelectorAll('h3, h4, strong, span, div');
            for (const h of headings) {
                if (wrap.contains(h)) continue;
                const t = h.textContent.trim().toLowerCase().replace(/\s+gains?$/,'').trim();
                if (TORN_STAT_LABELS[t]) return TORN_STAT_LABELS[t];
            }
        }
        return null;
    }

    function findSwitchButtonForGym(gymName) {
        const allBtns = document.querySelectorAll('button, a, [role="button"]');
        for (const btn of allBtns) {
            if (wrap.contains(btn)) continue;
            const t = btn.textContent.trim().toLowerCase();
            if (t !== 'switch' && t !== 'enter' && t !== 'use') continue;
            const container = btn.closest('li, div, tr, [class*="gym"]') || btn.parentElement;
            if (!container) continue;
            if (container.textContent.toLowerCase().includes(gymName.toLowerCase())) return btn;
        }
        return null;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PAGE READERS — extract live values (stat / energy / happy) from Torn DOM.
    // All readers skip our own widget (`wrap`) so we don't read our own values.
    // ─────────────────────────────────────────────────────────────────────────

    // Shared text-walker. `predicate(text, parentEl)` returns a value (truthy →
    // returned to caller) or null/undefined to keep walking.
    function walkPageText(predicate) {
        const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
            acceptNode: n => wrap.contains(n.parentElement) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
        });
        let node;
        while ((node = walker.nextNode())) {
            const result = predicate(node.textContent.trim(), node.parentElement);
            if (result != null) return result;
        }
        return null;
    }

    // Read current stat value (e.g. SPD: 431,802) from the page
    function readStatFromPage(statKey) {
        const labelMap = { str:'STR', spd:'SPD', def:'DEF', dex:'DEX',
                           strength:'STR', speed:'SPD', defense:'DEF', dexterity:'DEX' };
        const abbrev = labelMap[statKey] || statKey.toUpperCase().slice(0,3);
        return walkPageText((t, parent) => {
            if (t === abbrev) {
                // Value usually in next sibling element
                const sib = parent?.nextElementSibling;
                if (sib) {
                    const v = parseFloat(sib.textContent.replace(/,/g,''));
                    if (v > 0) return v;
                }
                // Or as a number elsewhere in the same parent block
                const grandparent = parent?.parentElement;
                if (grandparent) {
                    const nums = grandparent.textContent.match(/[\d,]{3,}/g);
                    if (nums) {
                        const v = parseFloat(nums[0].replace(/,/g,''));
                        if (v > 0) return v;
                    }
                }
            }
            // "STR 33,401" combined-text form
            const m = t.match(new RegExp(`^${abbrev}\\s+([\\d,]+)$`));
            if (m) return parseFloat(m[1].replace(/,/g,''));
            return null;
        });
    }

    // Read current energy from the page (e.g. "125 / 150")
    function readEnergyFromPage() {
        // TornPDA: <p class="bar-value___NTdce">125 / 150</p> in .energy___hsTnO
        for (const node of document.querySelectorAll('[class*="energy"] [class*="bar-value"], [class*="bar-value"]')) {
            if (wrap.contains(node)) continue;
            const m = node.textContent.trim().match(/^(\d{1,4})\s*\/\s*\d{2,4}$/);
            if (m) {
                const v = parseInt(m[1]);
                if (v >= 0 && v <= 1500) return v;
            }
        }
        // Fallback: any "X / 150" or "X / 100" with energy-related ancestor
        return walkPageText((t, parent) => {
            const m = t.match(/^(\d{1,4})\s*\/\s*(150|100)$/);
            if (m && parent?.closest('[class*="energy"], [id*="energy"]')) {
                const v = parseInt(m[1]);
                if (v >= 0 && v <= 1500) return v;
            }
            return null;
        });
    }

    // Read current happy from the page (sidebar or top bar)
    function readHappyFromPage() {
        return walkPageText((t, parent) => {
            if (!/^\d{2,6}$/.test(t.replace(/,/g,''))) return null;
            const ctx = parent?.closest('[class*="happy"], [id*="happy"]') ||
                        parent?.previousElementSibling?.textContent?.toLowerCase().includes('happy') ||
                        parent?.parentElement?.textContent?.toLowerCase().includes('happiness');
            if (!ctx) return null;
            const v = parseInt(t.replace(/,/g,''));
            return (v > 0 && v < 100000) ? v : null;
        });
    }

    // ─────────────────────────────────────────────────────────────────────────
    // SESSION CLASSIFIER — tag each logged session by what kind of train it was.
    // ─────────────────────────────────────────────────────────────────────────
    function classifySession(energy, happy, propHappy) {
        if (energy == null || happy == null) return "unknown";
        const happyMult = propHappy > 0 ? happy / propHappy : 1;
        if (happyMult > JUMP_HAPPY_RATIO) {
            return energy >= 500 ? "jump-full" : "jump-partial";
        }
        if (energy >= 500) return "xanax-stack";   // multiple xanax stacked
        if (energy >= 200) return "xanax-burst";   // one xanax + regen
        if (energy >= 140) return "refill-or-cap"; // points refill or full cap
        if (energy >= 50)  return "regen";          // partial natural regen
        return "small";                             // tail-end train
    }


    // ─────────────────────────────────────────────────────────────────────────
    // AUTO LOGGER — captures gym sessions from result text + click snapshot
    // Each train result is logged independently. Click-time snapshots provide
    // authoritative energy/happy values; result text provides the gain.
    // ─────────────────────────────────────────────────────────────────────────

    function logTrainEvent(gain, newStat, statKey, energyUsed) {
        if (!gain || !statKey) return;
        if (!window._ggAutoLogEnabled) return;

        // Click-gate: only log if a real TRAIN button was clicked recently.
        // This eliminates ALL false positives from page-load event log replays —
        // no click = no log.
        if (!lastTrainClickTs || Date.now() - lastTrainClickTs > LOG_CLICK_GATE_MS) return;
        const stat    = Object.keys(STAT_KEYS).find(k => STAT_KEYS[k] === statKey) || statKey;
        const gym     = getGymData();
        const gymDots = gym?.[STAT_KEYS[stat]] || 0;
        if (!gymDots) return;

        // Energy used: prefer the click-time snapshot from the page (authoritative —
        // we know exactly what was on screen when TRAIN was pressed). Fall back to
        // parsed-from-text energyUsed only if the snapshot is missing.
        // This fixes the "1000E jump logged as 10E" bug.
        const finalEnergyUsed = (lastTrainedEnergy != null && lastTrainedEnergy > 0)
            ? lastTrainedEnergy
            : energyUsed;

        // If newStat wasn't in the result text (TornPDA short format),
        // try reading it from the page DOM. If that fails too, derive from
        // the pre-train stat field in our calculator.
        let resolvedNewStat = newStat;
        if (!resolvedNewStat) {
            resolvedNewStat = Math.round(readStatFromPage(STAT_KEYS[stat]) || 0);
        }
        if (!resolvedNewStat) {
            // Last resort: use calculator's current stat + gain as an approximation
            resolvedNewStat = Math.round(gf(el.statTotal) + gain);
        }

        const preStat = Math.round(resolvedNewStat - gain);
        if (preStat <= 0) return;

        // Happy: use the snapshot taken at TRAIN click (most accurate), fall back
        // to live read, then fall back to propHappy field value.
        const happyAtTrain = lastTrainedHappy || readHappyFromPage() || gf(el.happy) || 5025;

        const entry = {
            ts:         Date.now(),
            scriptVer:  "3.34",
            playerId:   _load('playerId', null),
            playerName: _load('playerName', null),
            stat,
            gymId:      gym?.id,
            gymName:    gym?.name,
            gymDots,
            ePerTrain:  gym?.energy || 10,
            subscriber: el.subscriber?.value || "?",
            preStat,
            newStat:    Math.round(resolvedNewStat),
            happy:      happyAtTrain,
            propHappy:  gf(el.happy) || 5025,
            energyUsed: finalEnergyUsed,
            actualGain: Math.round(gain),
            sessionType: lastSessionType || "unknown",
            // Individual perks — broken out for formula research
            perkFaction:  parseFloat(el.factionPerk?.value)    || 0,
            perkProperty: parseFloat(el.propertyPerk?.value)   || 0,
            perkEduStat:  parseFloat(el.eduStatPerk?.value)    || 0,
            perkEduGen:   parseFloat(el.eduGenPerk?.value)     || 0,
            perkJob:      parseFloat(el.jobPerk?.value)        || 0,
            perkBook:     parseFloat(el.bookPerk?.value)       || 0,
            perkSteroids: parseFloat(el.steroids?.value)       || 0,
            perkSneakers: parseFloat(el.sportsSneakers?.value) || 0,
            bonus:        calcBonus(stat), // combined multiplier (derived from above)
            auto:         true,
        };

        const entries = logPrune(logLoad());
        // Deduplicate: skip if a very similar entry exists within last 30s
        const isDupe = entries.some(e =>
            e.stat === entry.stat &&
            Math.abs(e.ts - entry.ts) < LOG_DEDUPE_MS &&
            Math.abs(e.actualGain - entry.actualGain) < 100
        );
        if (isDupe) return;

        entries.push(entry);
        logSave(entries);
        renderLogger();

        const banner = document.createElement('div');
        banner.style.cssText = [
            'position:fixed;top:60px;left:50%;transform:translateX(-50%)',
            'background:#141a14;border:1px solid #2a4a2a;border-radius:5px',
            'padding:8px 14px;font-size:12px;color:#7abf7a;z-index:99999',
            'font-family:Arial,sans-serif;box-shadow:0 3px 12px rgba(0,0,0,.5)',
        ].join(';');
        const sessionLabel = {
            "jump-full":      "🎉 Happy Jump",
            "jump-partial":   "⚡ Jump train",
            "xanax-stack":    "💊 Xanax stack",
            "xanax-burst":    "💊 Xanax burst",
            "refill-or-cap":  "🔋 Refill/cap",
            "regen":          "🌿 Regen",
            "small":          "↳ Tail train",
            "unknown":        "📓",
        }[entry.sessionType] || "📓";
        banner.textContent = `${sessionLabel} +${fmt(entry.actualGain)} ${STAT_LABELS[stat]} (${finalEnergyUsed}E)`;
        document.body.appendChild(banner);
        setTimeout(() => banner.remove(), 4000);
    }

    // Watch the DOM for train result text — Torn uses several formats:
    //   "You used 1000 energy and 5 happiness training your dexterity 999 times
    //    in Racing Fitness increasing it by 31,887.00 to 453,315.00"
    //   "You have gained 35,158.00 dexterity"   (TornPDA short form)
    //   "You gained 35,158 strength"
    // Torn sometimes UPDATES existing text nodes (characterData mutation) rather than
    // inserting new ones — we observe both to catch the refill result reliably.
    const resultObserver = new MutationObserver(mutations => {
        for (const mut of mutations) {
            // Only childList mutations — new DOM insertions only
            const nodes = [...mut.addedNodes];

            for (const node of nodes) {
                // Skip anything inside our own widget
                const el_ = node.nodeType === 3 ? node.parentElement : node;
                if (!el_) continue;
                if (wrap.contains(el_)) continue;

                // Skip results inside Torn's event log, notifications, or history panels.
                // These contain old train results that would create false entries.
                const inTornLog = el_.closest(
                    '[class*="log-"], [class*="-log"], [id*="log"],' +
                    '[class*="event"], [id*="event"],' +
                    '[class*="notification"], [class*="activity"],' +
                    '[class*="timeline"], [class*="history"]'
                );
                if (inTornLog) continue;

                const text = node.textContent || '';
                if (!text || text.length < 20) continue;

                let gain = null, newStat = null, statKey = null, energyUsed = null;

                // Pattern 1 — Desktop Torn full result (most reliable, contains all data):
                // "You used 150 energy and 76 happiness training your speed 15 times
                //  in Complete Cardio increasing it by 2,200.52 to 326,067.73"
                const mFull = text.match(/you used\s+([\d,]+)\s+energy[^.]*?training (?:your\s+)?(strength|speed|defense|dexterity)[^.]*?increasing it by\s+([\d,]+(?:\.\d+)?)\s+to\s+([\d,]+(?:\.\d+)?)/i);
                if (mFull) {
                    energyUsed = parseInt(mFull[1].replace(/,/g,''));
                    statKey    = STAT_KEYS[mFull[2].toLowerCase()];
                    gain       = parseFloat(mFull[3].replace(/,/g,''));
                    newStat    = Math.round(parseFloat(mFull[4].replace(/,/g,'')));
                }

                // Pattern 2 — TornPDA short result:
                // "You gained 2,247.56 speed" or "You have gained 2,247.56 speed"
                if (!gain) {
                    const mPDA = text.match(/you (?:have )?gained\s+([\d,]+(?:\.\d+)?)\s+(strength|speed|defense|dexterity)/i);
                    if (mPDA) {
                        gain    = parseFloat(mPDA[1].replace(/,/g,''));
                        statKey = STAT_KEYS[mPDA[2].toLowerCase()];
                        // newStat not available — derive after logTrainEvent from preStat+gain
                        newStat = 0; // will be computed inside logTrainEvent
                        // Energy: use current gym's ePerTrain × trains in result if available
                        const mTrains = text.match(/(\d+)\s+times/i);
                        const gymE    = getGymData()?.energy || 10;
                        energyUsed    = mTrains ? parseInt(mTrains[1]) * gymE : gymE;
                    }
                }

                if (!gain || !statKey || !energyUsed) continue;
                if (!newStat) newStat = Math.round(readStatFromPage(statKey) || 0);
                logTrainEvent(gain, newStat, statKey, energyUsed);
            }
        }
    });
    resultObserver.observe(document.body, { childList: true, subtree: true });


    // Track which stat was last clicked so we can attribute results correctly
    let lastTrainedStat   = null;
    let lastTrainedHappy  = null; // happy snapshotted at TRAIN click, before regen
    let lastTrainedEnergy = null; // energy snapshotted at TRAIN click — authoritative
    let lastTrainClickTs  = null; // timestamp of last TRAIN button click — gate for auto-log
    let lastSessionType   = null; // classified session type ("jump-full", "xanax-burst", etc.)

    function handleTrainClick(e, btn) {
        // Snapshot stat AND happy AND energy at the moment of click — before the train
        // processes and before any regen ticks. This is the only reliable moment.
        const statKey = getStatForTrainButton(btn);
        if (statKey) lastTrainedStat = statKey;
        lastTrainedHappy  = readHappyFromPage();
        lastTrainedEnergy = readEnergyFromPage();
        lastTrainClickTs  = Date.now(); // arm the log gate
        // Classify the session for context — fixes "10E logged for a 1000E jump" bug
        const propH = gf(el.happy) || 5025;
        lastSessionType = classifySession(lastTrainedEnergy, lastTrainedHappy, propH);

        if (!switcherEnabled) return;
        if (!statKey) return;

        const statLabel   = Object.keys(STAT_KEYS).find(k => STAT_KEYS[k] === statKey);
        const currentPos  = STANDARD_UNLOCK_ORDER[parseInt(el.gym.value)] ?? 0;
        const manualOwned = JSON.parse(_load('manualGymOwned','[]'));

        const owned = GYMS
            .filter(g => {
                const dots = g[statKey] || 0;
                if (!dots) return false;
                if (SPECIALIST_IDS.has(g.id)) return manualOwned.includes(g.id);
                return (STANDARD_UNLOCK_ORDER[g.id] ?? 99) <= currentPos;
            })
            .sort((a, b) => (b[statKey]||0) - (a[statKey]||0));

        if (!owned.length) return;

        const bestGym   = owned[0];
        const currentId = detectGymFromDOM();

        // Can't determine current gym — don't switch, would cause wrong behaviour
        if (!currentId) return;

        // Already in the best gym — let train proceed
        if (currentId === bestGym.id) return;

        const switchBtn = findSwitchButtonForGym(bestGym.name);
        if (!switchBtn) {
            showSwitchBanner(GYM_BY_ID[currentId]?.name || 'current gym', bestGym.name + ' (switch manually)', STAT_LABELS[statLabel]);
            return;
        }

        e.preventDefault();
        e.stopImmediatePropagation();
        switchBtn.click();
        showSwitchBanner(GYM_BY_ID[currentId]?.name || 'current gym', bestGym.name, STAT_LABELS[statLabel]);

        setTimeout(() => {
            const newGymId = detectGymFromDOM();
            if (newGymId) {
                setGymSelect(el.gym, String(newGymId));
                gymConfirmed = true;
                el.gymBadge.textContent = "✓ page";
                updateDotsDisplay();
                updateBestGymPanel();
            }
        }, 1500);
    }

    const hookedButtons = new WeakSet();

    function hookTrainButtons() {
        // Primary selector from TornPDA DOM analysis:
        //   <button type="button" class="torn-btn" aria-label="Train speed" disabled="">TRAIN</button>
        // Also covers desktop Torn which uses the same aria-label pattern.
        // We use aria-label^="Train" as the definitive hook — much more precise
        // than matching text content which can catch unrelated buttons.
        const byAria = document.querySelectorAll('button[aria-label^="Train"]');
        for (const btn of byAria) {
            if (wrap.contains(btn)) continue;
            if (hookedButtons.has(btn)) continue;
            hookedButtons.add(btn);
            btn.addEventListener('click', e => handleTrainClick(e, btn), true);
        }

        // Fallback: text-content match for any platform that doesn't set aria-label
        const byText = document.querySelectorAll('button.torn-btn, button[class*="train"], a[class*="train"]');
        for (const btn of byText) {
            if (wrap.contains(btn)) continue;
            if (hookedButtons.has(btn)) continue;
            const t = btn.textContent.trim().toUpperCase();
            if (!t.startsWith('TRAIN')) continue;
            hookedButtons.add(btn);
            btn.addEventListener('click', e => handleTrainClick(e, btn), true);
        }
    }

    setTimeout(hookTrainButtons, 500);
    const trainObserver = new MutationObserver(() => hookTrainButtons());
    trainObserver.observe(document.body, { childList: true, subtree: true });

    // ─────────────────────────────────────────────────────────────────────────
    // HELPERS
    // ─────────────────────────────────────────────────────────────────────────
    const gf      = e => parseFloat(e?.value)||0;
    const gi      = e => parseInt(e?.value)  ||0;
    const fmt     = n => Math.round(n).toLocaleString();
    const fmtD    = (n,d=2) => n.toFixed(d);
    const getPerk = e => (parseFloat(e?.value)||0)/100;
    const dateIn  = n => { const d=new Date(); d.setDate(d.getDate()+Math.round(n)); return d.toLocaleDateString("en-GB",{day:"numeric",month:"short",year:"numeric"}); };
    const row     = (l,v,c="") => `<div class="gg-row ${c}"><span class="gg-rl">${l}</span><span class="gg-rv">${v}</span></div>`;
    const rsec    = l => `<div class="gg-rsec">${l}</div>`;

    // Current gym data object
    function getGymData() {
        const id = parseInt(el.gym.value);
        return GYM_BY_ID[id] || null;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // VLADAR FORMULA
    // dS = (S*ROUND(1+0.07*ROUND(LN(1+H/250),4),4) + 8*H^1.05 + (1-(H/99999)^2)*A + B)
    //      * (1/200000) * G * E * perks
    // S is actual stat (no cap since Aug 2022 stat cap removal)
    // G = gym dots (already stored as actual value e.g. 5.2, not ×10)
    // ─────────────────────────────────────────────────────────────────────────
    function calcGain(dots, stat, happy, bonus, energy, consts) {
        const H = happy;
        const lnPart = Math.round((1 + 0.07 * Math.round(Math.log(1+H/250)*10000)/10000)*10000)/10000;
        // FORMULA_CORRECTION (×1.027) is an empirical scalar derived from logged
        // sessions where the raw Vladar formula consistently underpredicted by
        // +2.70% (mean across 20+ entries, ±0.44% std).
        return (stat*lnPart + 8*Math.pow(H,1.05) + (1-Math.pow(H/99999,2))*consts.A + consts.B)
               * (1/200000) * dots * energy * bonus * FORMULA_CORRECTION;
    }

    // Happy decay per train: Vladar formula dH = ROUND((1/10) * E * RANDBETWEEN(4,6))
    // Midpoint = E * 0.5 (i.e. 5 per 10E train, 12.5 per 25E train, etc.)
    function happyDecay(energy) { return energy * 0.5; }

    function simulateBlock(dots, startStat, startHappy, bonus, energy, consts, numTrains, happyFloor=0) {
        let stat=startStat, happy=startHappy, gain=0;
        for (let i=0; i<numTrains; i++) {
            const g = calcGain(dots,stat,happy,bonus,energy,consts);
            gain += g; stat += g;
            happy = Math.max(happyFloor, happy - happyDecay(energy));
        }
        return { totalGain:gain, finalStat:stat, finalHappy:happy };
    }

    function calcBonus(stat) {
        let m = (1+getPerk(el.factionPerk))*(1+getPerk(el.propertyPerk))*
                (1+getPerk(el.eduStatPerk))*(1+getPerk(el.eduGenPerk))*
                (1+getPerk(el.jobPerk))*(1+getPerk(el.bookPerk))*(1+getPerk(el.steroids));
        if (stat==="speed") m *= (1+getPerk(el.sportsSneakers));
        return m;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // DAILY GRIND
    // ─────────────────────────────────────────────────────────────────────────
    function calculateDaily() {
        const gym = getGymData();
        if (!gym) throw new Error("Invalid gym selection.");
        const stat = el.stat.value;
        const dots = gym[STAT_KEYS[stat]];
        if (!dots) throw new Error(`${gym.name} does not train ${STAT_LABELS[stat]}.`);

        const consts     = STAT_CONSTS[stat];
        const propHappy  = gf(el.happy);
        const bonus      = calcBonus(stat);
        const statNow    = gf(el.statTotal);
        const statGoal   = gf(el.statGoal) || statNow;
        const initEnergy = gf(el.energy);
        const ePerTrain  = gym.energy;

        // ── "Right Now" snapshot — spends whatever energy you have currently ──
        // This is separate from the daily total; just shows what this session gives.
        const numTrains  = Math.floor(initEnergy / ePerTrain);
        const initBlock  = simulateBlock(dots, statNow, propHappy, bonus, ePerTrain, consts, numTrains);
        const singleGain = calcGain(dots, statNow, propHappy, bonus, ePerTrain, consts);
        const errSingle  = consts.C * (1/200000) * dots * ePerTrain * bonus;

        // ── Daily total — natural regen (720E/day sub, 480E/day non-sub) + optional refill ──
        // initEnergy is NOT added here — the daily regen is the base, always.
        const natEPerHr  = NATURAL_E[el.subscriber.value] || NATURAL_E.no;
        const natEPerDay = natEPerHr * 24;   // 720E sub, 480E non-sub
        const natTrains  = Math.floor(natEPerDay / ePerTrain);
        const wastedE    = natEPerDay - natTrains * ePerTrain;
        const dayBlock   = simulateBlock(dots, statNow, propHappy, bonus, ePerTrain, consts, natTrains, propHappy);

        // Daily refill
        const useRefill      = el.dailyRefill.value === "yes";
        const refillCost     = gf(el.dailyRefillCost) || 1725000;
        const refillE        = E_CAP[el.subscriber.value];
        const refillTrains   = Math.floor(refillE / ePerTrain);
        const refillBlock    = useRefill
            ? simulateBlock(dots, dayBlock.finalStat, propHappy, bonus, ePerTrain, consts, refillTrains, propHappy)
            : { totalGain:0 };
        const totalDailyGain  = dayBlock.totalGain + refillBlock.totalGain;
        const dailyCostPerDay = useRefill ? refillCost : 0;

        // Goal projection
        let cur = initBlock.finalStat, days = 0;
        while (cur < statGoal && days < MAX_ITER) {
            const d = simulateBlock(dots,cur,propHappy,bonus,ePerTrain,consts,natTrains,propHappy);
            cur = d.finalStat;
            if (useRefill) cur += simulateBlock(dots,cur,propHappy,bonus,ePerTrain,consts,refillTrains,propHappy).totalGain;
            days++;
        }
        const goalUnreach    = days >= MAX_ITER;
        const dailyCostToGoal = dailyCostPerDay * days;
        const gymName        = gym.name;

        const remaining = Math.max(0, statGoal - statNow);
        const pctDone   = statGoal > 0 ? ((statNow/statGoal)*100).toFixed(1) : "—";

        let html = `<div class="gg-tldr">
  <div class="gg-tldr-title">📊 Daily Grind — ${STAT_LABELS[stat]} at ${gymName}</div>
  <div class="gg-tldr-grid">
    <div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmtD(singleGain,1)}</div><div class="gg-tldr-lbl">per train</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmt(totalDailyGain)}</div><div class="gg-tldr-lbl">per day${useRefill?" (regen+refill)":""}</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">${goalUnreach?"∞":`~${days}d`}</div><div class="gg-tldr-lbl">days to goal</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">${goalUnreach?"—":dateIn(days)}</div><div class="gg-tldr-lbl">done by</div></div>
  </div>
</div>`;

        // ── Snapshot ──────────────────────────────────────────────────────────
        html += rsec("Your Stats Right Now");
        html += row(`${STAT_LABELS[stat]}`, `${fmt(statNow)} (${pctDone}% of goal)`);
        html += row("Goal",        `${fmt(statGoal)} — need ${fmt(remaining)} more`);
        html += row("Happy",       `${fmt(propHappy)} — property max happy`);
        html += row("Total bonus", `${((bonus-1)*100).toFixed(1)}% from all perks combined`);

        // ── What you get per train ────────────────────────────────────────────
        html += rsec("Per Train");
        html += row("Gain",  `~${fmtD(singleGain,2)} ${STAT_LABELS[stat]} per ${ePerTrain}E train`, "hi");
        html += row("Range", `${fmtD(singleGain-errSingle,2)} – ${fmtD(singleGain+errSingle,2)} (±${fmtD(errSingle,2)} random variance)`);

        // ── Current energy snapshot ───────────────────────────────────────────
        if (numTrains > 0) {
            html += rsec(`This Session — Spending Your ${initEnergy}E Now`);
            html += row("Trains",   `${numTrains} trains × ${ePerTrain}E`);
            html += row("Gain",     `+${fmtD(initBlock.totalGain,1)} ${STAT_LABELS[stat]}`, "hi");
            html += row("New stat", `${fmt(initBlock.finalStat)} after this session`);
        }

        // ── Daily natural regen ───────────────────────────────────────────────
        html += rsec(`Daily Natural Regen — ${natEPerDay}E/day (${natEPerHr}E/hr × 24h)`);
        html += row("Trains/day",   `${natTrains} trains × ${ePerTrain}E`);
        if (wastedE > 0) html += row("Wasted energy", `${wastedE}E/day lost — regen doesn't divide evenly into ${ePerTrain}E trains`, "a");
        html += row("Gain/day",     `+${fmt(dayBlock.totalGain)} ${STAT_LABELS[stat]}/day from natural regen`, "hi");

        // ── Daily refill ──────────────────────────────────────────────────────
        if (useRefill) {
            html += rsec("Points Refill (+150E once/day)");
            html += row("Extra trains",  `${refillTrains} trains × ${ePerTrain}E`);
            html += row("Extra gain",    `+${fmt(refillBlock.totalGain)} ${STAT_LABELS[stat]}`, "hi");
            html += row("Cost",          `$${fmt(refillCost)}/day`, "b");
        }

        // ── Total ─────────────────────────────────────────────────────────────
        html += rsec("Daily Total");
        html += row("Gain/day",   `+${fmt(totalDailyGain)} ${STAT_LABELS[stat]}${useRefill?" (regen + refill)":""}`, "hi");
        if (useRefill) html += row("Cost/day", `$${fmt(dailyCostPerDay)}`, "b");

        // ── Goal projection ───────────────────────────────────────────────────
        html += rsec("Reaching Your Goal");
        if (goalUnreach) {
            html += row("⚠ Unreachable", "Increase your happy or add a refill — gains are too small vs your goal", "a");
        } else {
            html += row("Days needed",   `~${days} days`, "g");
            html += row("Finish date",   dateIn(days), "g");
            if (useRefill) html += row("Total spend", `$${fmt(dailyCostToGoal)} in Points refills over ${days} days`, "b");
            html += row("Tip", days > 30 ? "Consider Happy Jumps — they can cut this time significantly" : "You're close! Keep grinding 💪", "");
        }

        el.dailyResults.innerHTML = html;
        el.dailyResults.style.display = "";
        el.jumpResults.style.display  = "none";
        el.compareResults.style.display = "none";
        el.copy.style.display = "inline-block";
        el._lastResults = html;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // HAPPY JUMP
    // ─────────────────────────────────────────────────────────────────────────
    // ── Shared jump parameter extraction ───────────────────────────────────────
    function readJumpParams() {
        const gym        = getGymData();
        if (!gym) throw new Error("Invalid gym selection.");
        const stat       = el.stat.value;
        const dots       = gym[STAT_KEYS[stat]];
        if (!dots) throw new Error(`${gym.name} does not train ${STAT_LABELS[stat]}.`);

        const energyCap   = E_CAP[el.subscriber.value];
        const natEPerHr   = NATURAL_E[el.subscriber.value];
        const propHappy   = gf(el.happy);
        const statNow     = gf(el.statTotal);
        const statGoal    = gf(el.statGoal) || statNow;
        const bonus       = calcBonus(stat);
        const ePerTrain   = gym.energy;

        // Energy sources
        const xanaxCount  = gi(el.hjXanaxCount);
        const fhcCount    = gi(el.hjFHC);
        const lsdCount    = gi(el.hjLSD);
        const canCount    = gi(el.hjCans);
        const canTypeIdx  = gi(el.hjCanType);
        const canFacPerk  = gi(el.hjCanFactionPerk) / 100;
        const canType     = canTypeIdx > 0 ? CAN_TYPES[canTypeIdx - 1] : null;
        const canEach     = canType ? Math.round(canType.e * (1 + canFacPerk)) : 0;
        const canCD       = canType ? canType.cd : 2;

        // Happy boosters
        const edvdCount     = gi(el.hjEDVDs);
        const anJob         = el.hjANJob.value === "yes";
        const useEcstasy    = el.hjEcstasy.value === "yes";
        const useRefill     = el.hjRefill.value === "yes";
        const voracity      = gi(el.hjVoracity);
        const fhcCount_     = fhcCount; // alias for clarity
        const fhcHappyBoost = fhcCount * FHC_HAPPY;
        const candyCount    = gi(el.hjCandies);
        const candyTypeIdx  = gi(el.hjCandyType);
        const candyType     = candyTypeIdx > 0 ? CANDY_TYPES[candyTypeIdx - 1] : null;
        const candyVorPct   = gi(el.hjCandyVoracity) / 100;
        const candyAbsPct   = el.hjCandyAbsorption?.value === "yes" ? 0.10 : 0;
        const candyMult     = 1 + candyVorPct + candyAbsPct;
        const candyHappyEach= candyType ? Math.round(candyType.happy * candyMult) : 0;
        const candyHappyTotal = candyCount * candyHappyEach;

        // Happy calculation
        const hjBase      = gf(el.hjBaseHappy);
        const baseHappy   = hjBase > 0 ? hjBase : propHappy;
        const edvdHappyPer = anJob ? EDVD_HAPPY * 2 : EDVD_HAPPY;
        let jumpHappy = baseHappy + edvdCount * edvdHappyPer + fhcHappyBoost + candyHappyTotal;
        if (useEcstasy) jumpHappy = Math.min(jumpHappy * 2, 99999);

        // Energy calculation
        const xanaxEnergy  = xanaxCount === 4 ? 1000 : Math.min(1000, energyCap + xanaxCount * 250);
        const xanStackE    = Math.min(1000, xanaxEnergy + lsdCount * 50 + canCount * canEach);
        const fhcEnergy    = fhcCount * energyCap;
        const stackTrains  = Math.floor(xanStackE / ePerTrain);
        const fhcTrains    = Math.floor(fhcEnergy / ePerTrain);

        // CD calculation
        const maxCD        = 24 + voracity;
        const xanCD        = XAN_CD_AVG * xanaxCount;
        const edvdCDTotal  = edvdCount * EDVD_CD;
        const fhcCDTotal   = fhcCount * FHC_CD;
        const canCDTotal   = canCount * canCD;
        const candyCDTotal = candyCount * CANDY_CD_HRS;
        const effectiveCD  = Math.min(xanCD + edvdCDTotal + fhcCDTotal + canCDTotal + candyCDTotal, maxCD);
        const cycleBest    = Math.min(XAN_CD_MIN * xanaxCount + edvdCDTotal + fhcCDTotal + canCDTotal + candyCDTotal, maxCD) + (useRefill ? energyCap / natEPerHr : 0);
        const cycleWorst   = Math.min(XAN_CD_MAX * xanaxCount + edvdCDTotal + fhcCDTotal + canCDTotal + candyCDTotal, maxCD) + (useRefill ? energyCap / natEPerHr : 0);
        const cycleAvg     = effectiveCD + (useRefill ? energyCap / natEPerHr : 0);

        // Costs
        const xanaxCost   = gf(el.hjXanaxCost);
        const edvdCost    = gf(el.hjEDVDCost);
        const ecstasyCost = useEcstasy ? gf(el.hjEcstasyCost) : 0;
        const fhcCost     = gf(el.hjFHCCost);
        const lsdCost     = gf(el.hjLSDCost);
        const canCost     = canCount > 0 ? gf(el.hjCanCost) : 0;
        const candyCost   = candyCount > 0 ? gf(el.hjCandyCost) : 0;
        const costTotal   = xanaxCount * xanaxCost + edvdCount * edvdCost + ecstasyCost
                          + fhcCount * fhcCost + lsdCount * lsdCost
                          + canCount * canCost + candyCount * candyCost
                          + (useRefill ? 1725000 : 0);

        return {
            gym, stat, dots, energyCap, natEPerHr, propHappy, statNow, statGoal,
            bonus, ePerTrain, xanaxCount, fhcCount, lsdCount, canCount, canType,
            canEach, canCD, edvdCount, anJob, useEcstasy, useRefill, voracity,
            fhcHappyBoost, candyCount, candyType, candyHappyTotal, candyHappyEach,
            baseHappy, jumpHappy, xanaxEnergy, xanStackE, fhcEnergy,
            stackTrains, fhcTrains, maxCD, xanCD, edvdCDTotal, fhcCDTotal,
            canCDTotal, candyCDTotal, effectiveCD, cycleBest, cycleWorst, cycleAvg,
            xanaxCost, edvdCost, ecstasyCost, fhcCost, lsdCost, canCost, candyCost,
            costTotal, edvdHappyPer, canFacPerk, candyMult,
        };
    }

    function calculateHappyJump() {
        const p = readJumpParams();
        const { gym, stat, dots, energyCap, natEPerHr, propHappy, statNow, statGoal,
                bonus, ePerTrain, xanaxCount, fhcCount, lsdCount, canCount, canType,
                canEach, edvdCount, useEcstasy, useRefill, fhcHappyBoost,
                candyCount, candyType, candyHappyTotal, baseHappy, jumpHappy,
                xanStackE, fhcEnergy, stackTrains, fhcTrains, maxCD, xanCD,
                edvdCDTotal, fhcCDTotal, canCDTotal, candyCDTotal, effectiveCD,
                cycleBest, cycleWorst, cycleAvg, xanaxCost, edvdCost, ecstasyCost,
                fhcCost, lsdCost, canCost, candyCost, costTotal, edvdHappyPer } = p;

        const consts = STAT_CONSTS[stat];

        // Simulate gains
        const stackBlock = simulateBlock(dots, statNow, jumpHappy, bonus, ePerTrain, consts, stackTrains);
        const fhcBlock   = fhcCount > 0
            ? simulateBlock(dots, statNow + stackBlock.totalGain, stackBlock.finalHappy + fhcHappyBoost, bonus, ePerTrain, consts, fhcTrains)
            : { totalGain: 0 };
        const gainStack  = stackBlock.totalGain + fhcBlock.totalGain;
        const gainRefill = useRefill
            ? simulateBlock(dots, statNow + gainStack, propHappy, bonus, ePerTrain, consts, Math.floor(energyCap / ePerTrain)).totalGain
            : 0;
        const gainTotal  = gainStack + gainRefill;
        const jumpsPerWeek = (24 * 7) / cycleAvg;
        const weeklyGain   = gainTotal * jumpsPerWeek;
        const costPerKStat = gainTotal > 0 ? (costTotal / gainTotal) * 1000 : 0;

        // Goal projection
        let jJumps = 0, cur = statNow;
        while (cur < statGoal && jJumps < MAX_ITER) {
            const sb = simulateBlock(dots, cur, jumpHappy, bonus, ePerTrain, consts, stackTrains);
            const fb = fhcCount > 0 ? simulateBlock(dots, cur + sb.totalGain, sb.finalHappy + fhcHappyBoost, bonus, ePerTrain, consts, fhcTrains) : { totalGain: 0 };
            cur += sb.totalGain + fb.totalGain;
            if (useRefill) cur += simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, Math.floor(energyCap / ePerTrain)).totalGain;
            jJumps++;
        }
        const jumpGoalUnreach = jJumps >= MAX_ITER;
        const daysToGoal      = jJumps * (cycleAvg / 24);
        const totalCost       = costTotal * jJumps;

        // OD risk
        const xODBase = parseFloat(el.hjXanaxOD.value) / 100 || 0.03;
        const eODBase = parseFloat(el.hjEcstasyOD.value) / 100 || 0.05;
        const tolPct  = parseFloat(el.hjToleration.value) / 100 || 0;
        const odMult  = (1 - tolPct) * (el.hjNightclub.value === "yes" ? 0.5 : 1);
        const xODEff  = xODBase * odMult;
        const eODEff  = eODBase * odMult;
        const pClean  = Math.pow(1 - xODEff, xanaxCount) * (useEcstasy ? 1 - eODEff : 1);
        const odExtraCost = pClean > 0 ? (1/pClean - 1) * costTotal : 0;

        // Energy/happy summary
        const gymName = gym.name;
        const statLabel = STAT_LABELS[stat];
        const energyParts = [];
        if (xanaxCount > 0) energyParts.push(`${xanaxCount}× Xanax (${p.xanaxEnergy}E)`);
        if (lsdCount > 0)   energyParts.push(`${lsdCount}× LSD (+${lsdCount*50}E)`);
        if (canCount > 0)   energyParts.push(`${canCount}× ${canType?.label||'cans'} (+${canCount*canEach}E)`);
        if (fhcCount > 0)   energyParts.push(`${fhcCount}× FHC (${fhcEnergy}E + +${fhcHappyBoost} happy)`);
        if (useRefill)      energyParts.push(`Points refill (+${energyCap}E)`);
        if (candyCount > 0) energyParts.push(`${candyCount}× ${candyType?.label||'candy'} (+${candyHappyTotal} happy)`);

        const happyParts = [`${Math.round(baseHappy)} base`];
        if (edvdCount > 0)   happyParts.push(`+${edvdCount}×eDVD (+${edvdCount*edvdHappyPer})`);
        if (fhcCount > 0)    happyParts.push(`+${fhcCount}×FHC (+${fhcHappyBoost})`);
        if (candyCount > 0)  happyParts.push(`+${candyCount}×${candyType?.label||'candy'} (+${candyHappyTotal})`);
        if (useEcstasy)      happyParts.push(`×2 ecstasy`);

        let html = `<div class="gg-tldr">
  <div class="gg-tldr-title">⚡ Happy Jump — ${statLabel} at ${gymName}</div>
  <div class="gg-tldr-grid">
    <div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmtD(gainTotal,1)}</div><div class="gg-tldr-lbl">per jump</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmt(weeklyGain)}</div><div class="gg-tldr-lbl">per week</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">${jumpGoalUnreach?"∞":`~${fmtD(daysToGoal,1)}d`}</div><div class="gg-tldr-lbl">days to goal</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">$${fmt(costTotal)}</div><div class="gg-tldr-lbl">per jump</div></div>
  </div>
</div>`;
        html += rsec("Energy Setup");
        html += row("Sources", energyParts.join(', ') || 'Natural regen only');
        html += row("Total energy", `${fmt(p.xanStackE + fhcEnergy)}E (${Math.floor((p.xanStackE + fhcEnergy)/ePerTrain)} trains)`);
        html += rsec("Happy");
        html += row("Breakdown", happyParts.join(' '));
        html += row("Jump happy", `${fmt(jumpHappy)}`, "hi");
        html += rsec("Gains Per Jump");
        if (stackTrains > 0) html += row("Main stack", `+${fmtD(stackBlock.totalGain,1)} (${stackTrains} trains at ${fmt(jumpHappy)} happy)`, "hi");
        if (fhcCount > 0)    html += row("FHC trains", `+${fmtD(fhcBlock.totalGain,1)} (${fhcTrains} trains)`, "hi");
        if (useRefill)       html += row("Refill", `+${fmtD(gainRefill,1)} (${Math.floor(energyCap/ePerTrain)} trains at ${fmt(propHappy)})`, "hi");
        html += row("Total / jump", `+${fmtD(gainTotal,1)} ${statLabel}`, "hi");
        html += row("Stat after", fmt(statNow + gainTotal));
        html += rsec("Cycle Timing");
        const cdParts = [xanaxCount>0?`Xanax:${xanCD}h`:null, edvdCount>0?`eDVD:${edvdCDTotal}h`:null,
                         fhcCount>0?`FHC:${fhcCDTotal}h`:null, canCount>0?`Cans:${canCDTotal}h`:null,
                         candyCount>0?`Candy:${candyCDTotal}h`:null].filter(Boolean);
        html += row("CD breakdown", cdParts.join(' + ') || '—');
        html += row("Effective CD", `${fmtD(effectiveCD,1)}h (cap ${maxCD}h)`);
        if (useRefill) html += row("Refill wait", `+${fmtD(energyCap/natEPerHr,1)}h`);
        html += row("Cycle avg", `~${fmtD(cycleAvg,1)}h (best:${fmtD(cycleBest,1)} worst:${fmtD(cycleWorst,1)})`);
        html += row("Jumps/week", `~${fmtD(jumpsPerWeek,2)}`);
        html += row("Gain/week", `+${fmt(weeklyGain)} ${statLabel}`, "hi");
        html += rsec("Cost Per Jump");
        if (xanaxCount > 0) html += row("Xanax",   `${xanaxCount}× $${fmt(xanaxCost)} = $${fmt(xanaxCount*xanaxCost)}`);
        if (edvdCount > 0)  html += row("eDVD",    `${edvdCount}× $${fmt(edvdCost)} = $${fmt(edvdCount*edvdCost)}`);
        if (useEcstasy)     html += row("Ecstasy",  `$${fmt(ecstasyCost)}`);
        if (fhcCount > 0)   html += row("FHC",     `${fhcCount}× $${fmt(fhcCost)} = $${fmt(fhcCount*fhcCost)}`);
        if (lsdCount > 0)   html += row("LSD",     `${lsdCount}× $${fmt(lsdCost)} = $${fmt(lsdCount*lsdCost)}`);
        if (canCount > 0)   html += row("Cans",    `${canCount}× $${fmt(canCost)} = $${fmt(canCount*canCost)}`);
        if (candyCount > 0) html += row("Candy",   `${candyCount}× ${candyType?.label||''} $${fmt(candyCost)} = $${fmt(candyCount*candyCost)}`);
        if (useRefill)      html += row("Refill",   "$1,725,000");
        html += row("Total", `$${fmt(costTotal)}`, "b");
        html += row("Per 1k stat", `$${fmt(costPerKStat)}`);
        html += rsec("Goal: " + fmt(statGoal));
        if (jumpGoalUnreach) {
            html += row("⚠ Unreachable", "Add more energy/happy sources.", "a");
        } else {
            html += row("Jumps needed", `${jJumps}`, "g");
            html += row("Time", `~${fmtD(daysToGoal,1)} days`, "g");
            html += row("Finish date", dateIn(daysToGoal), "g");
            html += row("Total spend", `$${fmt(totalCost)}`, "b");
        }
        if (xanaxCount > 0) {
            html += rsec("OD Risk ⚠ Community Estimates");
            html += row("Per-Xanax OD%", `${(xODEff*100).toFixed(2)}%`);
            if (useEcstasy) html += row("Ecstasy OD%", `${(eODEff*100).toFixed(2)}%`);
            html += row("Clean jump odds", `${(pClean*100).toFixed(1)}%`);
            html += row("OD cost impact", `~$${fmt(odExtraCost)} extra/jump avg`, "a");
            html += row("Effective gain/wk", `~${fmt(weeklyGain*pClean)} after OD probability`, "hi");
        }
        el.jumpResults.innerHTML = html;
        el.jumpResults.style.display    = "";
        el.dailyResults.style.display   = "none";
        el.compareResults.style.display = "none";
        el.copy.style.display           = "inline-block";
        el._lastResults = html;
    }


    function calculateCompare() {
        const p = readJumpParams();
        const { gym, stat, dots, energyCap, natEPerHr, propHappy, statNow, statGoal,
                bonus, ePerTrain, xanaxCount, fhcCount, canCount,
                canEach, edvdCount, anJob, useEcstasy, useRefill, fhcHappyBoost,
                candyCount, candyType, candyHappyTotal, jumpHappy,
                xanStackE, fhcEnergy, stackTrains, fhcTrains,
                edvdCDTotal, fhcCDTotal, canCDTotal, candyCDTotal, cycleAvg,
                costTotal } = p;

        const consts    = STAT_CONSTS[stat];
        const statLabel = STAT_LABELS[stat];

        // ── Daily Grind ───────────────────────────────────────────────────────
        // Daily Grind = pure natural regen across 24h + optional Points Refill.
        // The "Current energy" field is irrelevant here (that's a one-time snapshot,
        // not a long-term training pattern). Compare must reflect long-term steady
        // state, otherwise typing 99999 in Current Energy would project absurd gains.
        const dailyUseRefill    = el.dailyRefill.value === "yes";
        const dailyRefillCost   = dailyUseRefill ? gf(el.dailyRefillCost) : 0;
        const dailyRefillTrains = Math.floor(energyCap / ePerTrain);
        const dailyNatTrains    = Math.floor(natEPerHr * 24 / ePerTrain);

        // Simulate one full Daily Grind day: full natural regen + optional refill
        const _dailyNat     = simulateBlock(dots, statNow, propHappy, bonus, ePerTrain, consts, dailyNatTrains, propHappy);
        const _dailyRefill  = dailyUseRefill
            ? simulateBlock(dots, _dailyNat.finalStat, propHappy, bonus, ePerTrain, consts, dailyRefillTrains, propHappy)
            : { totalGain: 0 };
        const gainDaily    = _dailyNat.totalGain + _dailyRefill.totalGain;
        const weeklyDaily  = gainDaily * 7;
        const cycleDaily   = 24; // one cycle = one day

        // ── Happy Jump ────────────────────────────────────────────────────────
        const _stackRes  = simulateBlock(dots, statNow, jumpHappy, bonus, ePerTrain, consts, stackTrains);
        const gainStack   = _stackRes.totalGain;
        const gainFHC     = fhcCount > 0 ? simulateBlock(dots, statNow+gainStack, _stackRes.finalHappy+fhcHappyBoost, bonus, ePerTrain, consts, fhcTrains).totalGain : 0;
        const gainRefill  = useRefill ? simulateBlock(dots, statNow+gainStack+gainFHC, propHappy, bonus, ePerTrain, consts, Math.floor(energyCap/ePerTrain)).totalGain : 0;
        const gainJump    = gainStack + gainFHC + gainRefill;
        const weeklyJump  = gainJump * (24 * 7 / cycleAvg);

        // ── Xanax Grind ───────────────────────────────────────────────────────
        // Same model as calculateXanaxGrind: each CD cycle has two trains:
        //   1. mid-CD nat (150E fills in 5h, CD is 7h avg)
        //   2. Xanax burst (2h × 30E/hr = 60E + 250E = 310E)
        const xgCount       = gi(el.xgXanaxCount);
        const xgCost        = gf(el.xgXanaxCost);
        const xgUseRefill   = el.xgRefill?.value === "yes";
        const xgRefillCost  = gf(el.xgRefillCost) || 1725000;
        const xgRefillTrains = Math.floor(energyCap / ePerTrain);
        const xgCdHrs       = xgCount >= 4 ? XAN_CD_MIN : XAN_CD_AVG;
        const xgFillHrs     = energyCap / natEPerHr;
        const xgMidCdTrains = Math.floor(energyCap / ePerTrain);
        const xgAtXanE      = Math.min(energyCap, (xgCdHrs - xgFillHrs) * natEPerHr);
        const xgCycleE      = xgAtXanE + 250;
        const xgCycleTrains = Math.floor(xgCycleE / ePerTrain);
        const xgHrsUsed     = xgCount * xgCdHrs;
        const xgRemainHrs   = Math.max(0, 24 - xgHrsUsed);
        const xgNatLeftoverE = Math.min(xgRemainHrs * natEPerHr, energyCap);
        const xgLeftoverTrains = Math.floor(xgNatLeftoverE / ePerTrain);
        let xgGainXan = 0, xgGainMid = 0, xgCur = statNow;
        for (let i = 0; i < xgCount; i++) {
            const mb = simulateBlock(dots, xgCur, propHappy, bonus, ePerTrain, consts, xgMidCdTrains, propHappy);
            xgGainMid += mb.totalGain; xgCur = mb.finalStat;
            const xb = simulateBlock(dots, xgCur, propHappy, bonus, ePerTrain, consts, xgCycleTrains, propHappy);
            xgGainXan += xb.totalGain; xgCur = xb.finalStat;
        }
        const xgGainRefill = xgUseRefill ? simulateBlock(dots, xgCur, propHappy, bonus, ePerTrain, consts, xgRefillTrains, propHappy).totalGain : 0;
        if (xgUseRefill) xgCur += xgGainRefill;
        const xgGainNat  = simulateBlock(dots, xgCur, propHappy, bonus, ePerTrain, consts, xgLeftoverTrains, propHappy).totalGain;
        const gainXG     = xgGainXan + xgGainMid + xgGainRefill + xgGainNat;
        const weeklyXG   = gainXG * 7;
        const costXG     = xgCount * xgCost + (xgUseRefill ? xgRefillCost : 0);

        // ── Goal projections ──────────────────────────────────────────────────
        let dJumps=0, dCur=statNow;
        while (dCur<statGoal && dJumps<MAX_ITER) {
            const _n = simulateBlock(dots,dCur,propHappy,bonus,ePerTrain,consts,dailyNatTrains,propHappy);
            dCur = _n.finalStat;
            if (dailyUseRefill) dCur += simulateBlock(dots,dCur,propHappy,bonus,ePerTrain,consts,dailyRefillTrains,propHappy).totalGain;
            dJumps++;
        }
        let jJumps=0, jCur=statNow;
        while (jCur<statGoal && jJumps<MAX_ITER) {
            const sb = simulateBlock(dots,jCur,jumpHappy,bonus,ePerTrain,consts,stackTrains);
            const fb = fhcCount>0 ? simulateBlock(dots,jCur+sb.totalGain,sb.finalHappy+fhcHappyBoost,bonus,ePerTrain,consts,fhcTrains) : {totalGain:0};
            jCur += sb.totalGain + fb.totalGain;
            if (useRefill) jCur += simulateBlock(dots,jCur,propHappy,bonus,ePerTrain,consts,Math.floor(energyCap/ePerTrain)).totalGain;
            jJumps++;
        }
        let xgDays=0, xgDayCur=statNow;
        while (xgDayCur<statGoal && xgDays<MAX_ITER) {
            let s=xgDayCur;
            for (let i=0; i<xgCount; i++) {
                s+=simulateBlock(dots,s,propHappy,bonus,ePerTrain,consts,xgMidCdTrains,propHappy).totalGain;
                s+=simulateBlock(dots,s,propHappy,bonus,ePerTrain,consts,xgCycleTrains,propHappy).totalGain;
            }
            if (xgUseRefill) s+=simulateBlock(dots,s,propHappy,bonus,ePerTrain,consts,xgRefillTrains,propHappy).totalGain;
            s+=simulateBlock(dots,s,propHappy,bonus,ePerTrain,consts,xgLeftoverTrains,propHappy).totalGain;
            xgDayCur=s; xgDays++;
        }
        const daysDaily = dJumps * (cycleDaily / 24);
        const daysJump  = jJumps * (cycleAvg / 24);
        // xgDays already in days

        // ── Build 3-column table ──────────────────────────────────────────────
        const best3 = (d, j, x, lowerBetter=false) => {
            const vals = [d, j, x];
            const best = lowerBetter ? Math.min(...vals.filter(v=>isFinite(v))) : Math.max(...vals.filter(v=>isFinite(v)));
            return vals.map(v => v === best && isFinite(v));
        };
        const cell = (text, win) => `<div class="gg-cmp-val${win?" win":" lose"}">${text}</div>`;
        const xgLabel = xgCount > 0 ? `💊 Xanax Grind (${xgCount}/day)` : "💊 Xanax Grind";

        let html = `<div class="gg-cmp">
  <div class="gg-cmp-title">⚖ Training Method Comparison — ${statLabel} at ${gym.name}</div>
  <div class="gg-cmp-grid">
    <div class="gg-cmp-hdr"></div>
    <div class="gg-cmp-hdr daily">📅 Daily<br>Grind</div>
    <div class="gg-cmp-hdr jump">⚡ Happy<br>Jump</div>
    <div class="gg-cmp-hdr" style="color:#bf9f7a">💊 Xanax<br>Grind</div>`;

        const rows_ = [
            { label:"Gain / session",   vals:[gainDaily, gainJump, gainXG],                fmt_:(v)=>`+${fmtD(v,1)}`,                  lower:false },
            { label:"Gain / week",      vals:[weeklyDaily, weeklyJump, weeklyXG],           fmt_:(v)=>`+${fmt(v)}`,                     lower:false },
            { label:"Cost / session",   vals:[dailyRefillCost, costTotal, costXG],          fmt_:(v)=>v===0?"free":`$${fmt(v)}`,        lower:true  },
            { label:"Sessions to goal", vals:[dJumps, jJumps, xgDays],                     fmt_:(v)=>v>=MAX_ITER?"∞":String(v),        lower:true  },
            { label:"Days to goal",     vals:[daysDaily, daysJump, xgDays],                 fmt_:(v)=>v>=MAX_ITER?"∞":`${fmtD(v,1)}d`, lower:true  },
        ];

        // Total cost to goal
        const totalCostDaily = dailyRefillCost * dJumps;
        const totalCostJump  = costTotal * jJumps;
        const totalCostXG    = costXG    * xgDays;
        rows_.push({ label:"Total cost to goal", vals:[totalCostDaily, totalCostJump, totalCostXG], fmt_:(v)=>v===0?"free":`$${fmt(v)}`, lower:true });

        for (const {label, vals, fmt_, lower} of rows_) {
            const wins = best3(vals[0], vals[1], vals[2], lower);
            html += `<div class="gg-cmp-label">${label}</div>
    ${cell(fmt_(vals[0]), wins[0])}${cell(fmt_(vals[1]), wins[1])}${cell(fmt_(vals[2]), wins[2])}`;
        }
        html += `</div>`;

        // ── Verdict — factor in both speed AND cost ──────────────────────────
        const weekly      = [weeklyDaily, weeklyJump, weeklyXG];
        const totalCosts  = [totalCostDaily, totalCostJump, totalCostXG];
        const days        = [daysDaily, daysJump, xgDays];
        const names       = ["Daily Grind", "Happy Jump", "Xanax Grind"];
        const speedWinIdx = days.indexOf(Math.min(...days.filter(d => d < MAX_ITER)));
        const costWinIdx  = totalCosts.indexOf(Math.min(...totalCosts.filter((_,i) => days[i] < MAX_ITER)));
        const hasCosts    = totalCosts.some(c => c > 0);

        // Cost-efficiency: stat gained per $1M spent
        const efficiency = weekly.map((w, i) => {
            const dailyCostForMethod = totalCosts[i] / Math.max(days[i], 1);  // cost per day
            return dailyCostForMethod > 0 ? (w / (dailyCostForMethod / 1e6)) : Infinity; // stat/week per $1M/day
        });
        const effWinIdx = efficiency.indexOf(Math.max(...efficiency));

        let verdictLines = names.map((n, i) => {
            const dStr   = days[i] >= MAX_ITER ? "∞" : `${fmtD(days[i],1)}d`;
            const cStr   = totalCosts[i] > 0 ? ` · $${fmt(Math.round(totalCosts[i]))} total` : "";
            const wStr   = `${fmt(Math.round(weekly[i]))} ${statLabel}/week`;
            const badges = [];
            if (i === speedWinIdx) badges.push("⚡ fastest");
            if (i === costWinIdx && hasCosts)  badges.push("💰 cheapest");
            if (i === effWinIdx && hasCosts)   badges.push("📈 best value");
            const badgeStr = badges.length ? ` <span style="font-size:10px;opacity:.8">[${badges.join(", ")}]</span>` : "";
            return `<div style="padding:2px 0"><strong style="color:${i===speedWinIdx?"#7abf7a":i===costWinIdx&&hasCosts?"#bf9f7a":"#8899aa"}">${n}${badgeStr}</strong> — ${wStr} · ${dStr}${cStr}</div>`;
        }).join("");

        // Summary recommendation
        let recommendation = "";
        if (speedWinIdx === costWinIdx || !hasCosts) {
            recommendation = `🏆 <strong>${names[speedWinIdx]}</strong> wins on all fronts.`;
        } else {
            const speedDays  = days[speedWinIdx];
            const cheapDays  = days[costWinIdx];
            const daysDiff   = (cheapDays - speedDays).toFixed(1);
            const costSaving = totalCosts[speedWinIdx] - totalCosts[costWinIdx];
            recommendation = `⚡ <strong>${names[speedWinIdx]}</strong> reaches goal ${daysDiff}d faster, but costs $${fmt(Math.round(costSaving))} more total. 💰 <strong>${names[costWinIdx]}</strong> is cheaper. Best value: <strong>${names[effWinIdx]}</strong>.`;
        }

        html += `<div class="gg-cmp-verdict">${verdictLines}<div style="margin-top:8px;padding-top:8px;border-top:1px solid #2a3a20;font-size:11px;color:#8abf7a">${recommendation}</div></div></div>`;

        el.compareResults.innerHTML  = html;
        el.compareResults.style.display = "";
        el.dailyResults.style.display   = "none";
        el.jumpResults.style.display    = "none";
        el.copy.style.display           = "inline-block";
        el._lastResults = html;
    }


    // ─────────────────────────────────────────────────────────────────────────
    // XANAX GRIND
    // Natural regen + N xanax/day each used at cap energy.
    // No happy boosters — just property max happy throughout.
    // ─────────────────────────────────────────────────────────────────────────
    function calculateXanaxGrind() {
        const gym = getGymData();
        if (!gym) throw new Error("Invalid gym selection.");
        const stat = el.stat.value;
        const dots = gym[STAT_KEYS[stat]];
        if (!dots) throw new Error(`${gym.name} does not train ${STAT_LABELS[stat]}.`);

        const consts    = STAT_CONSTS[stat];
        const propHappy = gf(el.happy);
        const bonus     = calcBonus(stat);
        const statNow   = gf(el.statTotal);
        const statGoal  = gf(el.statGoal) || statNow;
        const ePerTrain = gym.energy;
        const sub       = el.subscriber.value;
        const energyCap = E_CAP[sub];
        const natEPerHr = NATURAL_E[sub];
        const natEPerDay= natEPerHr * 24;

        // Xanax config from xg- inputs
        const xanCount    = gi(el.xgXanaxCount);   // xanax per day
        const xanCost     = gf(el.xgXanaxCost);    // $ per xanax
        const useRefill   = el.xgRefill?.value === "yes";
        const refillCost  = gf(el.xgRefillCost) || 1725000;
        const refillTrains = Math.floor(energyCap / ePerTrain);

        // ── Time-simulation model ─────────────────────────────────────────────
        // Subscriber fills 150E in exactly 5h (30E/hr). Xanax CD averages 7h.
        // Each Xanax cycle actually contains TWO training opportunities:
        //   1. At t=0: train whatever E has regened since last train + 250E Xanax
        //   2. At t=5h: energy cap full again — train 150E (nat train mid-CD)
        //   3. At t=7h: CD over → take next Xanax, repeat
        //
        // So 3 Xanax/day = 3 Xanax bursts + 3 mid-CD nat trains
        // Then remaining hours after 3×7=21h → 3h left, 3×30=90E, not enough for a train
        //
        // For N Xanax/day, mid-CD nat trains = N (one per cycle) provided
        // energyFillHours (5h) < cdHours (7h) — which is always true for subscribers.
        // Non-subscribers fill in 100/20 = 5h too, same situation.

        // CD averages 7h (6-8h range). With 4 xanax/day the CD MUST be 6h flat
        // (4×6=24), so use min CD for that case. Otherwise use average.
        const xanCdHrs      = xanCount >= 4 ? XAN_CD_MIN : XAN_CD_AVG;
        const fillHrs       = energyCap / natEPerHr;  // 5h to fill cap (150/30 or 100/20)
        const midCdNatE     = energyCap;              // always fills fully between xanax
        const midCdTrains   = Math.floor(midCdNatE / ePerTrain);

        // At each Xanax moment: energy has regened for (cdHours - fillHours) = 2h after
        // the mid-CD train, so at Xanax time there's (cdHrs - fillHrs) × natEPerHr E built up
        const atXanE        = Math.min(energyCap, (xanCdHrs - fillHrs) * natEPerHr); // 2h × 30 = 60E
        const xanBurstE     = atXanE + 250;           // 60 + 250 = 310E per Xanax cycle
        const xanTrains     = Math.floor(xanBurstE / ePerTrain);

        // Hours used by N complete Xanax cycles
        const xanHrsUsed    = xanCount * xanCdHrs;   // 3 × 7 = 21h, or 4 × 6 = 24h
        const remainHrs     = Math.max(0, 24 - xanHrsUsed); // 3h or 0h
        // Remaining nat regen after all Xanax cycles (likely partial, < cap)
        const leftoverE     = Math.min(energyCap, remainHrs * natEPerHr); // 90E or 0E
        const leftoverTrains= Math.floor(leftoverE / ePerTrain);

        // Total daily energy breakdown for display
        const totalXanE     = xanCount * xanBurstE;
        const totalMidCdE   = xanCount * midCdNatE;
        const totalDailyE   = totalXanE + totalMidCdE + leftoverE + (useRefill ? energyCap : 0);

        // ── Simulate one full day ─────────────────────────────────────────────
        // For each Xanax: first train mid-CD nat (150E), then train Xanax burst
        let cur = statNow, xanGainTotal = 0, midCdGainTotal = 0;
        for (let i = 0; i < xanCount; i++) {
            // Mid-CD nat train (energy filled while waiting for CD)
            const midB = simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, midCdTrains, propHappy);
            midCdGainTotal += midB.totalGain;
            cur = midB.finalStat;
            // Xanax burst (partial regen + 250E drug)
            const xanB = simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, xanTrains, propHappy);
            xanGainTotal += xanB.totalGain;
            cur = xanB.finalStat;
        }
        // Points refill (once/day)
        const refillBlock = useRefill
            ? simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, refillTrains, propHappy)
            : { totalGain: 0, finalStat: cur };
        if (useRefill) cur = refillBlock.finalStat;
        // Leftover nat regen after all Xanax cycles
        const natBlock   = simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, leftoverTrains, propHappy);
        const dayGain    = xanGainTotal + midCdGainTotal + refillBlock.totalGain + natBlock.totalGain;

        // Cost per day
        const dailyCost  = xanCount * xanCost + (useRefill ? refillCost : 0);

        // ── Goal projection ───────────────────────────────────────────────────
        let gcur = statNow, gdays = 0;
        while (gcur < statGoal && gdays < MAX_ITER) {
            let s = gcur;
            for (let i = 0; i < xanCount; i++) {
                s += simulateBlock(dots, s, propHappy, bonus, ePerTrain, consts, midCdTrains, propHappy).totalGain;
                s += simulateBlock(dots, s, propHappy, bonus, ePerTrain, consts, xanTrains, propHappy).totalGain;
            }
            if (useRefill) s += simulateBlock(dots, s, propHappy, bonus, ePerTrain, consts, refillTrains, propHappy).totalGain;
            s += simulateBlock(dots, s, propHappy, bonus, ePerTrain, consts, leftoverTrains, propHappy).totalGain;
            gcur = s;
            gdays++;
        }
        const goalUnreach = gdays >= MAX_ITER;
        const totalCost   = dailyCost * gdays;

        // ── Xanax CD check ────────────────────────────────────────────────────
        // Xanax CD is 6–8h random. Max possible = 4/day at exactly 6h CD each (4×6=24h)
        const cdWarning   = xanCount === 4;

        // ── Per-train baseline (natural happy, natural energy) ─────────────────
        const singleGain  = calcGain(dots, statNow, propHappy, bonus, ePerTrain, consts);

        const gymName = gym.name;
        const statLabel = STAT_LABELS[stat];

        let html = `<div class="gg-tldr">
  <div class="gg-tldr-title">💊 Xanax Grind — ${statLabel} at ${gymName}</div>
  <div class="gg-tldr-grid">
    <div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmt(dayGain)}</div><div class="gg-tldr-lbl">gain / day</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">${xanCount}× xanax</div><div class="gg-tldr-lbl">+ natural regen</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">${goalUnreach?"∞":`~${gdays}d`}</div><div class="gg-tldr-lbl">days to goal</div></div>
    <div class="gg-tldr-cell"><div class="gg-tldr-val">${goalUnreach?"—":dateIn(gdays)}</div><div class="gg-tldr-lbl">done by</div></div>
  </div>
</div>`;

        if (cdWarning) {
            html += `<div style="padding:8px 10px;background:#201a08;border:1px solid #4a3a10;border-radius:4px;font-size:11px;color:#bf9f5a;margin-bottom:8px">
  ⚠ 4 xanax/day requires back-to-back 6h CDs with no slack — only achievable on a perfect day. Most players reliably fit 3/day.
</div>`;
        }

        html += rsec("Your Setup");
        html += row("Gym / Stat",    `${gymName} — ${statLabel} (${ePerTrain}E/train)`);
        html += row("Happy",         `${fmt(propHappy)} — property max, used throughout`);
        html += row("Total bonus",   `${((bonus-1)*100).toFixed(1)}% from all perks combined`);
        html += row("Per train",     `~${fmtD(singleGain,2)} ${statLabel} at this happy`, "hi");

        html += rsec(`Each Xanax Cycle — ×${xanCount}/day`);
        html += row("How it works",      `Energy fills to ${energyCap}E in ${fillHrs.toFixed(0)}h — train that, then wait ${(xanCdHrs-fillHrs).toFixed(0)}h more for CD`);
        html += row("Mid-CD nat train",  `${midCdNatE}E ÷ ${ePerTrain}E = ${midCdTrains} trains (energy full while waiting for CD)`);
        html += row("Xanax burst",       `${atXanE.toFixed(0)}E regen (${(xanCdHrs-fillHrs).toFixed(0)}h × ${natEPerHr}E/hr) + 250E = ${xanBurstE.toFixed(0)}E = ${xanTrains} trains`);
        html += row("Gain per cycle",    `+${fmtD((xanGainTotal + midCdGainTotal) / Math.max(1,xanCount), 0)} ${statLabel} avg (nat + xanax)`, "hi");
        html += row("Total from xanax cycles", `+${fmt(xanGainTotal + midCdGainTotal)} ${statLabel}/day`, "hi");
        if (xanCost > 0) html += row("Xanax cost",  `$${fmt(xanCost)} × ${xanCount} = $${fmt(xanCount*xanCost)}/day`, "b");

        if (useRefill) {
            html += rsec("Points Refill");
            html += row("Refill trains", `${energyCap}E ÷ ${ePerTrain}E = ${refillTrains} trains at ${fmt(propHappy)} happy`);
            html += row("Gain",          `+${fmt(refillBlock.totalGain)} ${statLabel}`, "hi");
            html += row("Cost",          `$${fmt(refillCost)}/day`, "b");
        }

        html += rsec("Leftover Regen");
        html += row("After ${xanCount} cycles", `24h − ${xanHrsUsed}h = ${remainHrs}h → ${leftoverE.toFixed(0)}E → ${leftoverTrains} trains`);
        html += row("Gain",            `+${fmt(natBlock.totalGain)} ${statLabel}`, "hi");

        html += rsec("Daily Total");
        html += row("Energy breakdown", `${xanCount}×(${midCdNatE}E nat + ${xanBurstE.toFixed(0)}E xanax) + ${leftoverE.toFixed(0)}E leftover${useRefill?` + ${energyCap}E refill`:""} = ${totalDailyE.toFixed(0)}E`);
        html += row("Total gain/day",   `+${fmt(dayGain)} ${statLabel}`, "hi");
        if (dailyCost > 0) {
            const costBreakdown = [
                xanCount > 0 && xanCost > 0 ? `${xanCount}× xanax $${fmt(xanCount*xanCost)}` : null,
                useRefill ? `refill $${fmt(refillCost)}` : null,
            ].filter(Boolean).join(' + ');
            html += row("Cost/day", `$${fmt(dailyCost)}${costBreakdown ? ` (${costBreakdown})` : ""}`, "b");
        }

        html += rsec("Reaching Your Goal");
        if (goalUnreach) {
            html += row("⚠ Unreachable", "Gains are too small vs your goal at this happy level", "a");
        } else {
            html += row("Days needed",  `~${gdays} days`, "g");
            html += row("Finish date",  dateIn(gdays), "g");
            if (totalCost > 0) html += row("Total xanax spend", `$${fmt(totalCost)} over ${gdays} days`, "b");
        }

        el.dailyResults.innerHTML     = html;
        el.dailyResults.style.display = "";
        el.jumpResults.style.display  = "none";
        el.compareResults.style.display = "none";
        el.copy.style.display         = "inline-block";
        el._lastResults               = html;
    }


    function calculateSession() {
        // "This Session" — what do I get RIGHT NOW with what I have?
        try {
            el.compareResults.style.display = "none";

            const stat     = el.stat.value;
            const statNow  = gi(el.statTotal);
            const statGoal = gi(el.statGoal);
            const propH    = gf(el.happy) || 5025;
            const gym      = GYMS.find(g => String(g.id) === String(el.gym.value));
            const statKey  = STAT_KEYS[stat];
            const dots     = (statKey && gym?.[statKey]) || 5.0;
            const ePerTrain= gym?.energy || 10;
            const bonus    = calcBonus(stat);
            const consts   = STAT_CONSTS[stat] || STAT_CONSTS.speed;
            const sub      = el.subscriber.value;

            if (!statNow) { showStatus("warn", "Enter your current stat first."); return; }
            if (!gym)     { showStatus("warn", "Select your gym first."); return; }

            // Session energy = current E + drug + refill
            const baseE    = gi(el.energy) || 0;
            const drug     = el.sessionDrug?.value || "none";
            const drugE    = drug === "xanax" ? 250 : drug === "lsd" ? 50 : 0;
            const hasRefill= el.sessionRefill?.value === "yes";
            const refillE  = hasRefill ? (sub === "yes" ? 150 : 100) : 0;
            const totalE   = baseE + drugE + refillE;

            if (totalE <= 0) { showStatus("warn", "Enter your current energy (or pick a drug/refill)."); return; }

            // Happy — use field if filled, else property max
            const rawH     = gf(el.sessionHappy);
            const startH   = rawH > 0 ? rawH : propH;
            const numTrains= Math.floor(totalE / ePerTrain);
            const block    = simulateBlock(dots, statNow, startH, bonus, ePerTrain, consts, numTrains, propH);

            // Goal progress
            const remaining = statGoal > statNow ? statGoal - statNow : 0;
            const pctDone   = statGoal > 0 ? Math.min(100, (statNow / statGoal * 100)).toFixed(1) : "?";
            const pctAfter  = statGoal > 0 ? Math.min(100, (block.finalStat / statGoal * 100)).toFixed(1) : "?";
            const pctOfGoal = remaining > 0 ? (block.totalGain / remaining * 100).toFixed(1) : "100";

            // Estimate sessions to goal from here (using this session type)
            let sessionsLeft = 0;
            if (statGoal > statNow && totalE > 0) {
                let cur = statNow;
                const MAX = 5000;
                while (cur < statGoal && sessionsLeft < MAX) {
                    cur += simulateBlock(dots, cur, startH, bonus, ePerTrain, consts, numTrains, propH).totalGain;
                    sessionsLeft++;
                }
                if (sessionsLeft >= MAX) sessionsLeft = null; // unreachable
            }

            const gymName = gym?.name || "?";
            const statLabel = STAT_LABELS[stat];
            const fmtE = e => e > 0 ? `+${e}E` : "";

            // Energy breakdown line
            const parts = [];
            if (baseE > 0) parts.push(`${baseE}E current`);
            if (drugE > 0) parts.push(`${fmtE(drugE)} ${drug}`);
            if (refillE > 0) parts.push(`${fmtE(refillE)} refill`);
            const eLine = parts.join(" + ");

            const html = `
<div class="gg-tldr">
  <div class="gg-tldr-title">⚡ This Session — ${statLabel} at ${gymName}</div>
  <div class="gg-tldr-grid">
    <div class="gg-tldr-cell">
      <div class="gg-tldr-val">+${fmt(Math.round(block.totalGain))}</div>
      <div class="gg-tldr-lbl">${statLabel} gained</div>
    </div>
    <div class="gg-tldr-cell">
      <div class="gg-tldr-val">${numTrains} trains</div>
      <div class="gg-tldr-lbl">${totalE}E total</div>
    </div>
    <div class="gg-tldr-cell">
      <div class="gg-tldr-val">${pctAfter}%</div>
      <div class="gg-tldr-lbl">of goal after</div>
    </div>
    <div class="gg-tldr-cell">
      <div class="gg-tldr-val">${sessionsLeft == null ? "∞" : sessionsLeft > 999 ? "999+" : sessionsLeft}</div>
      <div class="gg-tldr-lbl">sessions like this left</div>
    </div>
  </div>
</div>
${rsec("Energy breakdown")}
${row("Using", eLine || `${totalE}E`)}
${row("Per train", `~${fmtD(calcGain(dots,statNow,startH,bonus,ePerTrain,consts),1)} ${statLabel}`)}
${row("Starting happy", fmt(startH) + (rawH > 0 ? "" : " (property max)"))}
${rsec("Your goal")}
${row("Current", `${fmt(statNow)} — ${pctDone}% of goal`)}
${row("After session", `${fmt(Math.round(block.finalStat))} — ${pctAfter}% of goal`, "g")}
${row("This session = ", `${pctOfGoal}% of remaining ${fmt(remaining)}`, "hi")}
${statGoal > 0 && sessionsLeft != null ? row("Sessions left", `~${sessionsLeft} more sessions like this`, "") : ""}
${rsec("Quick tip")}
${row("💡", drugE === 0 && baseE <= 150 ? "No drug CD? Xanax adds +250E and roughly doubles this session's gain." : drug === "xanax" ? "Good — Xanax gives the best gain per session. Stack with a Points Refill for even more." : "Use 📋 Plan ahead to project your full training schedule.")}
`;
            el.dailyResults.innerHTML = html;
            el.dailyResults.style.display = "";
            el.jumpResults.style.display = "none";
        } catch(e) {
            showStatus("warn", "✗ " + e.message);
        }
    }

    function copyResults() {
        const text = (el._lastResults||"")
            .replace(/<div class="gg-rsec">([^<]+)<\/div>/g, "\n── $1 ──")
            .replace(/<div class="gg-row[^"]*"><span class="gg-rl">([^<]+)<\/span><span class="gg-rv">([^<]+)<\/span><\/div>/g, "$1: $2")
            .replace(/<[^>]+>/g,"").trim();
        navigator.clipboard.writeText(text)
            .then(()=>showStatus("ok","✓ Copied."))
            .catch(()=>showStatus("warn","⚠ Copy failed."));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // ─────────────────────────────────────────────────────────────────────────
    // GAINS LOGGER v2
    // Clean schema per entry:
    //   { ts, stat, gymName, gymId, gymDots, ePerTrain,
    //     preStat, happy, propHappy, energyUsed, actualGain, bonus, auto }
    // TTL: 30 days. No compression, no legacy compat cruft.
    // ─────────────────────────────────────────────────────────────────────────
    const LOG_KEY = "gainsLog_v2";
    const LOG_TTL = 30 * 24 * 60 * 60 * 1000;

    function logLoad()          { try { return JSON.parse(_load(LOG_KEY,"[]")); } catch(_){ return []; } }
    function logSave(e)         { _save(LOG_KEY, JSON.stringify(e)); }
    function logPrune(entries)  { const cut=Date.now()-LOG_TTL; return entries.filter(e=>e.ts>cut); }

    // Predict gain for one entry at a given dots value
    function predictEntry(e, dots) {
        const consts = STAT_CONSTS[e.stat] || STAT_CONSTS.speed;
        const trains = Math.round((e.energyUsed||e.ePerTrain||10) / (e.ePerTrain||10));
        return simulateBlock(dots, e.preStat, e.happy, e.bonus||1, e.ePerTrain||10, consts, trains, e.propHappy||0).totalGain;
    }

    // Golden-section search for best-fit dots value
    function calibrateDots(entries) {
        if (entries.length < 2) return null;
        const loss = d => entries.reduce((s,e) => { const diff=predictEntry(e,d)-e.actualGain; return s+diff*diff; }, 0);
        const phi = (Math.sqrt(5)-1)/2;
        let a=1.0, b=9.5, c=b-phi*(b-a), dd=a+phi*(b-a);
        for (let i=0; i<100; i++) {
            if (loss(c)<loss(dd)) b=dd; else a=c;
            c=b-phi*(b-a); dd=a+phi*(b-a);
            if (Math.abs(b-a)<0.001) break;
        }
        const bestDots = (a+b)/2;
        const meanA = entries.reduce((s,e)=>s+e.actualGain,0)/entries.length;
        const ssTot = entries.reduce((s,e)=>s+Math.pow(e.actualGain-meanA,2),0);
        const ssRes = entries.reduce((s,e)=>s+Math.pow(predictEntry(e,bestDots)-e.actualGain,2),0);
        const r2   = ssTot>0 ? 1-ssRes/ssTot : 1;
        const rmse = Math.sqrt(ssRes/entries.length);
        return { dots: Math.round(bestDots*100)/100, r2, rmse };
    }

    function renderLogger() {
        const all    = logPrune(logLoad());
        logSave(all);
        const badge  = q("#gg-logger-badge");
        if (badge) badge.textContent = all.length ? `${all.length} entries` : "";

        const entEl = q("#gg-log-entries");
        const calEl = q("#gg-log-calibration");
        if (!entEl) return;

        if (!all.length) {
            entEl.innerHTML = `<div style="font-size:11px;color:#445;padding:6px 0">No entries yet — auto-log is watching for train results.</div>`;
            if (calEl) calEl.innerHTML = "";
            return;
        }

        // ── Table ────────────────────────────────────────────────────────────
        // Two-line layout per entry — fits mobile without horizontal scroll:
        //   Line 1: age/source · stat · energyE · happy
        //   Line 2: Actual: X  vs  Pred: Y  →  diff (+Z%)
        let tableRows = "";
        for (let i = all.length-1; i >= 0; i--) {
            const e      = all[i];
            const pred   = predictEntry(e, e.gymDots);
            const diff   = e.actualGain - pred;
            const errPct = pred > 0 ? (diff / pred * 100) : 0;
            const isOutlier = Math.abs(errPct) > OUTLIER_THRESHOLD * 100;
            const errCol = isOutlier ? "#bf5a3a" : Math.abs(errPct) > 10 ? "#bf7a3a" : Math.abs(errPct) > 5 ? "#9a9a4a" : "#5a9f5a";
            const diffStr = `${diff >= 0 ? "+" : ""}${fmt(Math.round(diff))} (${errPct >= 0 ? "+" : ""}${errPct.toFixed(1)}%)`;
            const age    = Math.round((Date.now()-e.ts)/3600000);
            const ageS   = age < 1 ? "now" : age < 24 ? `${age}h` : `${Math.round(age/24)}d`;
            const srcDot = e.auto
                ? `<span style="color:#3a6a3a;font-size:10px" title="auto-logged">●</span>`
                : `<span style="color:#556;font-size:10px" title="manual">M</span>`;
            const statLabel = (e.stat||"").slice(0,3).toUpperCase();
            const rowBg = i % 2 === 0 ? "#191919" : "#161616";

            // Outlier warning — likely a partial jump log (energy understated)
            const outlierBanner = isOutlier ? `
  <div style="margin:3px 0;padding:3px 6px;background:#2a1a10;border:1px solid #5a3020;border-radius:3px;font-size:10px;color:#bf7a4a">
    ⚠ Likely bad log — gain vs energy mismatch (+${errPct.toFixed(0)}% off). Probably a jump session where only the last train was captured. Delete this entry.
  </div>` : "";

            const sessLabel = {
                "jump-full":"🎉jump", "jump-partial":"⚡jump",
                "xanax-stack":"💊stack", "xanax-burst":"💊xan",
                "refill-or-cap":"🔋full", "regen":"🌿regen", "small":"↳tail"
            }[e.sessionType] || "";
            const sessTag = sessLabel
                ? `<span style="color:#5a6a8a;font-size:9px;background:#161a22;padding:1px 4px;border-radius:2px">${sessLabel}</span>`
                : "";

            tableRows += `
<div style="padding:6px 8px;background:${rowBg};border-bottom:1px solid ${isOutlier?"#3a2010":"#1e1e2a"}">
  <!-- Row 1: meta -->
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px">
    <div style="display:flex;gap:6px;align-items:center;font-size:10px;flex-wrap:wrap">
      ${srcDot}
      <span style="color:#445">${ageS}</span>
      <span style="color:#6a8a6a;font-weight:bold">${statLabel}</span>
      <span style="color:#445">${e.energyUsed||"?"}E</span>
      <span style="color:#445">😊${fmt(e.happy||0)}</span>
      ${sessTag}
    </div>
    <button data-idx="${i}" class="gg-log-del" style="background:none;border:none;color:#553;cursor:pointer;font-size:14px;padding:0 4px;line-height:1">✕</button>
  </div>
  ${outlierBanner}
  <!-- Row 2: actual vs predicted -->
  <div style="display:flex;align-items:baseline;gap:0;font-family:monospace;font-size:12px;flex-wrap:wrap;gap:4px">
    <span style="color:#888;font-size:10px">Actual</span>
    <span style="color:#c8e6c8;font-weight:bold">+${fmt(e.actualGain)}</span>
    <span style="color:#334;font-size:11px">vs</span>
    <span style="color:#888;font-size:10px">Pred</span>
    <span style="color:#7a8a9a">+${fmt(Math.round(pred))}</span>
    <span style="color:#334;font-size:11px">→</span>
    <span style="color:${errCol};font-weight:bold">${diffStr}</span>
  </div>
</div>`;
        }

        entEl.innerHTML = `
<div style="margin-bottom:4px;font-size:9px;color:#334">● auto &nbsp; M manual &nbsp; diff = actual − predicted</div>
<div style="border:1px solid #252535;border-radius:4px;overflow:hidden;margin-bottom:8px">${tableRows}</div>`;

        entEl.querySelectorAll(".gg-log-del").forEach(btn => {
            btn.addEventListener("click", () => {
                const entries = logLoad();
                entries.splice(parseInt(btn.dataset.idx), 1);
                logSave(entries);
                renderLogger();
            });
        });

        // ── Calibration ───────────────────────────────────────────────────────
        if (!calEl) return;
        if (all.length < 2) {
            calEl.innerHTML = `<div style="font-size:11px;color:#445;padding:4px 0">Log 2+ entries to calibrate gym dots.</div>`;
            return;
        }

        // Exclude outlier entries (>OUTLIER_THRESHOLD error) from calibration —
        // these are bad logs (e.g. partial jump captures with wrong energy)
        const validEntries = all.filter(e => {
            const p = predictEntry(e, e.gymDots);
            return p > 0 && Math.abs((e.actualGain - p) / p) < OUTLIER_THRESHOLD;
        });
        const outlierCount = all.length - validEntries.length;

        if (validEntries.length < 2) {
            calEl.innerHTML = `<div style="font-size:11px;color:#7a5a3a;padding:4px 0">⚠ Need 2+ valid entries to calibrate. ${outlierCount} entr${outlierCount===1?"y":"ies"} excluded as outlier${outlierCount===1?"":"s"} (>50% error). Delete bad logs from the list above.</div>`;
            return;
        }

        const cal = calibrateDots(validEntries);
        if (!cal) return;
        const currentDots = getGymData()?.[STAT_KEYS[el.stat?.value]] ?? "?";
        const dotsMatch   = Math.abs(cal.dots - parseFloat(currentDots)) < 0.15;
        const conf        = validEntries.length >= 5 ? "High" : validEntries.length >= 3 ? "Medium" : "Low";
        const confCol     = validEntries.length >= 5 ? "#4a9a4a" : validEntries.length >= 3 ? "#9a9a4a" : "#9a7a3a";

        calEl.innerHTML = `
<div style="padding:10px;background:#141a14;border:1px solid #253025;border-radius:4px;margin-top:4px">
  <div style="font-size:10px;color:#4a7a4a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px">Calibrated Gym Dots</div>
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
    <div>
      <span style="font-size:22px;font-weight:bold;color:#7abf7a">${cal.dots}</span>
      <span style="font-size:11px;color:#556;margin-left:8px">vs API: ${currentDots} ${dotsMatch?"✓":"⚠ differs"}</span>
    </div>
    <div style="text-align:right;font-size:11px;color:#556;line-height:1.6">
      <div>R² ${(cal.r2*100).toFixed(1)}%</div>
      <div>RMSE ±${fmt(Math.round(cal.rmse))}</div>
      <div style="color:${confCol}">${conf} (${validEntries.length} valid entries)</div>
    </div>
  </div>
  ${outlierCount > 0 ? `<div style="font-size:10px;color:#7a5a3a;margin-bottom:6px">⚠ ${outlierCount} outlier${outlierCount>1?"s":""} excluded from calibration (>50% error — likely bad jump logs). Delete them from the list above.</div>` : ""}
  ${!dotsMatch && validEntries.length>=3 ? `<div style="font-size:11px;color:#9a9a4a;margin-bottom:8px">⚠ Calibrated dots differ from API — your real gym dots may differ from the fallback table. Run Auto-fill to refresh API data.</div>` : ""}
  ${validEntries.length>=5 ? `<button id="gg-log-apply" class="gg-btn" style="width:100%;background:#182018;border-color:#2a4a2a;color:#7abf7a;font-size:12px">Apply ${cal.dots} dots to calculator</button>` : `<div style="font-size:10px;color:#445">Log ${5-validEntries.length} more valid entries to unlock Apply.</div>`}
</div>`;

        q("#gg-log-apply")?.addEventListener("click", () => {
            const gymId  = parseInt(el.gym.value);
            const stat   = el.stat.value;
            const sk     = STAT_KEYS[stat];
            const idx    = GYMS.findIndex(g=>g.id===gymId);
            if (idx>=0 && sk) {
                GYMS[idx] = {...GYMS[idx], [sk]: cal.dots};
                buildGymMaps(); updateDotsDisplay(); updateBestGymPanel();
                showStatus("ok", `✓ Applied ${cal.dots} dots to ${GYMS[idx].name} ${STAT_LABELS[stat]}.`);
            }
        });
    }

    // Build plain-text log for sharing (tab-separated, easy to paste to Claude)
    function buildLogText(entries) {
        if (!entries.length) return "No entries logged.";
        const header = [
            "#","date","scriptVer","playerId","playerName",
            "stat","gym","gymId","dots","E/train","subscriber","sessionType",
            "preStat","newStat","happy","propHappy","energyUsed","actualGain",
            "perkFaction","perkProperty","perkEduStat","perkEduGen","perkJob","perkBook","perkSteroids","perkSneakers",
            "bonusCombined","predicted","err%","source"
        ].join("\t");
        const rows = entries.map((e, i) => {
            const pred   = predictEntry(e, e.gymDots);
            const errPct = pred > 0 ? ((e.actualGain - pred) / pred * 100).toFixed(2) : "?";
            const date   = new Date(e.ts).toISOString().slice(0,16).replace("T"," ");
            return [
                i+1,
                date,
                e.scriptVer  || "?",
                e.playerId   || "?",
                e.playerName || "?",
                e.stat,
                e.gymName    || "?",
                e.gymId      || "?",
                e.gymDots,
                e.ePerTrain  || 10,
                e.subscriber || "?",
                e.sessionType || "?",
                e.preStat,
                e.newStat    || (e.preStat + e.actualGain),
                e.happy,
                e.propHappy  || "?",
                e.energyUsed || "?",
                e.actualGain,
                e.perkFaction  ?? "?",
                e.perkProperty ?? "?",
                e.perkEduStat  ?? "?",
                e.perkEduGen   ?? "?",
                e.perkJob      ?? "?",
                e.perkBook     ?? "?",
                e.perkSteroids ?? "?",
                e.perkSneakers ?? "?",
                (e.bonus || 1).toFixed(4),
                pred.toFixed(1),
                errPct,
                e.auto ? "auto" : "manual",
            ].join("\t");
        });
        return [header, ...rows].join("\n");
    }

    // Wire up logger UI
    (function initLogger() {
        // ── Auto-log toggle ──────────────────────────────────────────────────
        let autoLogEnabled = _load('autoLogEnabled', true);
        const updateAutoLogUI = () => {
            const dot = q("#gg-autolog-dot");
            const lbl = q("#gg-autolog-lbl");
            if (!dot) return;
            dot.style.background   = autoLogEnabled ? '#5a9f5a' : '#554444';
            lbl.style.color        = autoLogEnabled ? '#7abf7a' : '#aa6a6a';
            lbl.textContent        = autoLogEnabled ? 'Auto-log: ON' : 'Auto-log: OFF';
        };
        q("#gg-autolog-row")?.addEventListener("click", () => {
            autoLogEnabled = !autoLogEnabled;
            _save('autoLogEnabled', autoLogEnabled);
            window._ggAutoLogEnabled = autoLogEnabled;
            updateAutoLogUI();
        });
        window._ggAutoLogEnabled = autoLogEnabled;
        updateAutoLogUI();

        // ── Fill from page ───────────────────────────────────────────────────
        // Scans the current DOM for the most recent train result text and
        // pre-fills all fields it can find. User only needs to confirm/correct.
        q("#gg-log-autofill")?.addEventListener("click", () => {
            let filled = [];

            // Try to find the latest result text anywhere on the page
            let gain = null, energyUsed = null, statKey = null, newStat = null;

            // Scan all text nodes for known result patterns
            const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
                acceptNode: n => wrap.contains(n.parentElement) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
            });
            let node, bestMatch = null, bestLen = 0;
            while ((node = walker.nextNode())) {
                const t = node.textContent || '';
                // Prefer longer matches (more data)
                if (t.length > bestLen && (
                    /you used\s+[\d,]+\s+energy.*increasing it by/i.test(t) ||
                    /you (?:have )?gained\s+[\d,]+(?:\.\d+)?\s+(?:strength|speed|defense|dexterity)/i.test(t)
                )) {
                    bestMatch = t;
                    bestLen = t.length;
                }
            }

            if (bestMatch) {
                // Pattern 1: full desktop format
                const m1 = bestMatch.match(/you used\s+([\d,]+)\s+energy[^.]*?training (?:your\s+)?(strength|speed|defense|dexterity)[^.]*?increasing it by\s+([\d,]+(?:\.\d+)?)\s+to\s+([\d,]+(?:\.\d+)?)/i);
                if (m1) {
                    energyUsed = parseInt(m1[1].replace(/,/g,''));
                    statKey    = m1[2].toLowerCase();
                    gain       = parseFloat(m1[3].replace(/,/g,''));
                    newStat    = Math.round(parseFloat(m1[4].replace(/,/g,'')));
                }
                // Pattern 2: TornPDA short format
                if (!gain) {
                    const m2 = bestMatch.match(/you (?:have )?gained\s+([\d,]+(?:\.\d+)?)\s+(strength|speed|defense|dexterity)/i);
                    if (m2) {
                        gain    = parseFloat(m2[1].replace(/,/g,''));
                        statKey = m2[2].toLowerCase();
                        const mTrains = bestMatch.match(/(\d+)\s+times/i);
                        const gymE    = getGymData()?.energy || 10;
                        energyUsed    = mTrains ? parseInt(mTrains[1]) * gymE : gymE;
                    }
                }
            }

            // Fill Actual Gain
            if (gain) {
                q("#gg-log-gain").value = Math.round(gain);
                filled.push("gain");
            }

            // Fill Energy Used
            if (energyUsed) {
                q("#gg-log-energy").value = energyUsed;
                filled.push("energy");
            }

            // Fill Pre-train Stat: use newStat - gain if available, else calculator field
            if (gain && newStat) {
                q("#gg-log-stat").value = Math.round(newStat - gain);
                filled.push("stat");
            } else {
                const calcStat = gf(el.statTotal);
                if (calcStat > 0) {
                    q("#gg-log-stat").value = calcStat;
                    filled.push("stat");
                }
            }

            // Fill Happy: use lastTrainedHappy snapshot, else live read, else propHappy
            const happy = lastTrainedHappy || readHappyFromPage() || gf(el.happy);
            if (happy) {
                q("#gg-log-happy").value = happy;
                filled.push("happy");
            }

            if (filled.length === 0) {
                const statusEl = q("#gg-log-copy-status");
                if (statusEl) {
                    statusEl.textContent = "⚠ No train result found on page — fill manually.";
                    statusEl.style.color = "#bf9f5a";
                    statusEl.style.display = "block";
                    setTimeout(() => statusEl.style.display = "none", 3000);
                }
            } else {
                const statusEl = q("#gg-log-copy-status");
                const missing = ["gain","energy","stat","happy"].filter(f => !filled.includes(f));
                const msg = `⚡ Filled: ${filled.join(", ")}${missing.length ? ` — enter ${missing.join(", ")} manually` : " — ready to log!"}`;
                if (statusEl) {
                    statusEl.textContent = msg;
                    statusEl.style.color = "#7abf7a";
                    statusEl.style.display = "block";
                    setTimeout(() => statusEl.style.display = "none", 4000);
                }
                // Focus the first empty field so user can type straight away
                const emptyFields = [
                    {id:"#gg-log-gain",    filled: !!gain},
                    {id:"#gg-log-energy",  filled: !!energyUsed},
                    {id:"#gg-log-stat",    filled: filled.includes("stat")},
                    {id:"#gg-log-happy",   filled: !!happy},
                ].find(f => !f.filled);
                if (emptyFields) q(emptyFields.id)?.focus();
            }
        });
        q("#gg-log-add")?.addEventListener("click", () => {
            const gain      = parseFloat(q("#gg-log-gain")?.value)   || 0;
            const energyUsed= parseFloat(q("#gg-log-energy")?.value) || 0;
            const preStat   = parseFloat(q("#gg-log-stat")?.value)   || 0;
            const happy     = parseFloat(q("#gg-log-happy")?.value)  || 0;

            if (!gain || !preStat || !happy || !energyUsed) {
                showStatus("warn", "⚠ Fill in all four fields to log an entry.");
                return;
            }
            const gym   = getGymData();
            const stat  = el.stat.value;
            const sk    = STAT_KEYS[stat];
            const dots  = gym?.[sk] || 0;
            if (!dots) { showStatus("warn", "⚠ Gym doesn't train this stat."); return; }

            const entry = {
                ts:         Date.now(),
                scriptVer:  "3.34",
                playerId:   _load('playerId', null),
                playerName: _load('playerName', null),
                stat,
                gymId:      gym?.id,
                gymName:    gym?.name,
                gymDots:    dots,
                ePerTrain:  gym?.energy || 10,
                subscriber: el.subscriber?.value || "?",
                preStat,
                newStat:    Math.round(preStat + gain),
                happy,
                propHappy:  gf(el.happy) || 5025,
                energyUsed,
                actualGain: Math.round(gain),
                perkFaction:  parseFloat(el.factionPerk?.value)    || 0,
                perkProperty: parseFloat(el.propertyPerk?.value)   || 0,
                perkEduStat:  parseFloat(el.eduStatPerk?.value)    || 0,
                perkEduGen:   parseFloat(el.eduGenPerk?.value)     || 0,
                perkJob:      parseFloat(el.jobPerk?.value)        || 0,
                perkBook:     parseFloat(el.bookPerk?.value)       || 0,
                perkSteroids: parseFloat(el.steroids?.value)       || 0,
                perkSneakers: parseFloat(el.sportsSneakers?.value) || 0,
                bonus:        calcBonus(stat),
                auto:         false,
            };
            const entries = logPrune(logLoad());
            entries.push(entry);
            logSave(entries);

            q("#gg-log-gain").value   = "";
            q("#gg-log-energy").value = "";
            q("#gg-log-stat").value   = "";
            q("#gg-log-happy").value  = "";

            renderLogger();
            showStatus("ok", `✓ Entry logged (${entries.length} total).`);
        });

        // ── Copy log ─────────────────────────────────────────────────────────
        q("#gg-log-copy")?.addEventListener("click", () => {
            const entries = logPrune(logLoad());
            if (!entries.length) { showStatus("warn", "⚠ Nothing to copy."); return; }
            const text = buildLogText(entries);

            const statusEl = q("#gg-log-copy-status");
            const showSt   = (msg) => { if (statusEl) { statusEl.textContent=msg; statusEl.style.display="block"; setTimeout(()=>statusEl.style.display="none", 3000); } };

            // Try GM_setClipboard first (always works in Tampermonkey/TornPDA)
            if (typeof GM_setClipboard !== "undefined") {
                GM_setClipboard(text);
                showSt(`✓ ${entries.length} entries copied — paste anywhere (Notes, Discord, chat with Claude).`);
                return;
            }
            // Then try navigator.clipboard (works in modern browsers)
            if (navigator.clipboard?.writeText) {
                navigator.clipboard.writeText(text)
                    .then(() => showSt(`✓ ${entries.length} entries copied to clipboard.`))
                    .catch(() => showFallback(text));
                return;
            }
            showFallback(text);
        });

        function showFallback(text) {
            // Full-screen selectable textarea fallback — always works in TornPDA
            const overlay = document.createElement('div');
            overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:999999;display:flex;flex-direction:column;padding:16px;gap:10px';
            overlay.innerHTML = `
<div style="background:#1a1a1a;border:1px solid #2a4a2a;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:10px;flex:1;overflow:hidden">
  <div style="color:#7abf7a;font-size:14px;font-weight:bold">📋 Gains Log — Select All &amp; Copy</div>
  <div style="font-size:11px;color:#556;line-height:1.5">Long-press → Select All → Copy. Then paste to Claude, Discord, or Notes.</div>
  <textarea id="gg-log-ta" readonly style="flex:1;min-height:0;background:#0e0e0e;color:#7a9a7a;border:1px solid #2a4a2a;border-radius:4px;padding:10px;font-size:11px;font-family:monospace;resize:none;line-height:1.4">${text.replace(/</g,'&lt;').replace(/>/g,'&gt;')}</textarea>
  <button id="gg-fb-close" style="background:#181818;border:1px solid #333;color:#888;padding:10px;border-radius:4px;font-size:14px;font-weight:bold">Close</button>
</div>`;
            document.body.appendChild(overlay);
            const ta = overlay.querySelector('#gg-log-ta');
            // Set value directly (not innerHTML) so clipboard picks it up
            ta.value = text;
            setTimeout(() => { ta.focus(); ta.select(); }, 80);
            overlay.querySelector('#gg-fb-close').addEventListener('click', () => overlay.remove());
        }

        // ── Clear ────────────────────────────────────────────────────────────
        q("#gg-log-clear")?.addEventListener("click", () => {
            if (!confirm("Clear all logged entries? This cannot be undone.")) return;
            logSave([]);
            renderLogger();
            showStatus("ok", "✓ Log cleared.");
        });

        // ── Pre-fill stat from calculator on panel open ───────────────────────
        q("#gg-logger-panel")?.querySelector(".gg-collapsible-header")?.addEventListener("click", () => {
            const cur = gf(el.statTotal);
            if (cur > 0 && !q("#gg-log-stat")?.value) {
                const statInput = q("#gg-log-stat");
                if (statInput) statInput.value = cur;
            }
        });

        renderLogger();
    })();

    // Debug buttons
    const debugLog = [];
    const _ow = console.warn.bind(console);
    console.warn = (...a) => { _ow(...a); if (String(a[0]).includes("[AIO")) debugLog.push("W:"+a.join(" ")); };
    q("#gg-copy-logs")?.addEventListener("click", () => {
        const log = [
            "Gym Planner v3.0 " + new Date().toISOString(),
            "GYMS:" + GYMS.length + " maxSPD:" + Math.max(...GYMS.map(g=>g.spd||0)),
            "Jail:" + JSON.stringify(GYMS.find(g=>g.id===32)),
            "CanPrices:" + JSON.stringify(canPrices),
            "CAN_IDs:" + CAN_TYPES.map(c=>c.label+":"+c.id).join(","),
            "Stats:" + JSON.stringify(allStats), "ActivePanel:" + (typeof activePanel !== 'undefined' ? activePanel : 'n/a'),
            "---", ...debugLog.slice(-20),
        ].join("\n");
        GM_setClipboard ? GM_setClipboard(log) : navigator.clipboard?.writeText(log);
        const ds = q("#gg-debug-status");
        if (ds) { ds.style.display = "block"; ds.textContent = "✓ Copied"; setTimeout(() => ds.style.display = "none", 2000); }
    });
    q("#gg-clear-cache")?.addEventListener("click", () => {
        ["gymDataCache_v2","gymDataCacheTs_v2","gymDataCache","gymDataCacheTs",
         PRICE_CACHE_KEY,"canItemIds","canItemIdsTs","lastAutofillTs","manualGymOwned"]
         .forEach(k => { try { GM_deleteValue(k); } catch(_){} });
        CAN_TYPES.forEach(c => c.id = null); CANDY_TYPES.forEach(c => c.id = null);
        GYMS = GYMS_FALLBACK.slice(); buildGymMaps(); rebuildGymDropdown();
        const ds = q("#gg-debug-status");
        if (ds) { ds.style.display = "block"; ds.style.color = "#bf7a7a"; ds.textContent = "✓ Cleared"; setTimeout(() => ds.style.display = "none", 3000); }
        showStatus("ok", "✓ Cache cleared. Reload or Auto-fill.");
    });


})();