Torn - AIO Planner

All-in-one: Gym Gains Calculator (gym.php), Job Planner (jobs.php), Education Planner (education). Shared API key, shared stat/perk cache across pages.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn - AIO Planner
// @namespace    http://torn.com/
// @version      2.8
// @description  All-in-one: Gym Gains Calculator (gym.php), Job Planner (jobs.php), Education Planner (education). Shared API key, shared stat/perk cache across pages.
// @author       iSatomi [3580191]
// @match        https://www.torn.com/gym.php*
// @match        https://www.torn.com/jobs.php*
// @match        https://www.torn.com/page.php?sid=education*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// @connect      weav3r.dev
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

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

    const PAGE = location.href.includes('gym.php')      ? 'gym'
               : location.href.includes('jobs.php')     ? 'job'
               : location.href.includes('sid=education')? 'edu'
               : null;
    if (!PAGE) return;

    // ── 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>`);

    // =========================================================================
    // ROUTE TO MODULE
    // =========================================================================
    if (PAGE === 'gym') gymModule();
    if (PAGE === 'job') jobModule();
    if (PAGE === 'edu') eduModule();

    function gymModule() {


    // ─────────────────────────────────────────────────────────────────────────
    // 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 }
    function checkGymAccess(gymId, currentGymId) {
        const isSpecialist = SPECIALIST_IDS.has(gymId);

        if (!isSpecialist) {
            // Standard gyms unlock sequentially — accessible if position ≤ current gym position
            const pos    = STANDARD_UNLOCK_ORDER[gymId] ?? 99;
            const curPos = STANDARD_UNLOCK_ORDER[currentGymId] ?? 0;
            return pos <= curPos
                ? { state:'unlocked', reason:'', purchased:true }
                : { state:'prereq',   reason:'Not yet unlocked', purchased:false };
        }

        // Specialist gym
        const req = SPECIALIST_REQS[gymId];
        if (!req) return { state:'unlocked', reason:'', purchased:true };

        // Check prereq gym is unlocked via sequential position
        const prereqUnlocked = (STANDARD_UNLOCK_ORDER[req.prereqId] ?? 99) <= (STANDARD_UNLOCK_ORDER[currentGymId] ?? 0);
        if (!prereqUnlocked) {
            const prereqGym = GYM_BY_ID[req.prereqId];
            return { state:'prereq', reason:`Unlock ${prereqGym?.name || 'required gym'} first`, purchased:false };
        }

        // Check stat ratio
        const hasStats = allStats.strength > 0 || allStats.speed > 0 || allStats.defense > 0 || allStats.dexterity > 0;
        if (hasStats) {
            const ratioOk = req.check(allStats);
            if (!ratioOk) return { state:'ratio', reason:req.desc, purchased:false };
        }

        // Specialist gyms need manual confirmation that user purchased the membership
        const manualOwned = JSON.parse(_load('manualGymOwned','[]'));
        const purchased = manualOwned.includes(gymId);
        if (!purchased) return { state:'ratio', reason: req.drugLimited
            ? 'Ratio met · ≤50 Xanax+Ecstasy required · Tap "I own it" to confirm'
            : 'Ratio met · Tap "I own it" to confirm membership', purchased:false };
        if (req.drugLimited) return { state:'unlocked', reason:'Only if ≤50 Xanax and ≤50 Ecstasy taken lifetime', purchased:true };
        return { state:'unlocked', reason:'', purchased:true };
    }

    // 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 };
    // 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",
    };
    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:#555;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-tabs{display:flex;gap:6px;margin-bottom:10px}
.gg-tab{flex:1;padding:8px;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:background .15s}
.gg-tab.active{background:#18182a;border-color:#303058;color:#7a7acc}
.gg-tab:hover{background:#2a2a2a}
.gg-section{display:none}
.gg-section.active{display:block}
.gg-btn-row{display:flex;gap:6px;margin-top:12px;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 10px;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:#ff7a7a}
.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:10px;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:#555;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:auto 1fr 1fr;gap:0}
.gg-cmp-hdr{font-size:11px;font-weight:bold;color:#555;padding:6px 8px;background:#1a1a1a;border-bottom:1px solid #252525;text-align:center}
.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:12px;color:#666;padding:5px 8px;background:#1c1c1c;border-bottom:1px solid #222;white-space:nowrap}
.gg-cmp-val{font-size:12px;color:#bbb;padding:5px 8px;background:#1e1e1e;border-bottom:1px solid #222;text-align:right}
.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:10px;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-locked{font-size:10px;color:#4a3a1a;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-bestgym-note{font-size:10px;color:#446;margin-top:6px;line-height:1.6;padding:6px 8px;background:#141414;border-radius:3px}
.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}`;

    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>
  <div style="display:flex;align-items:center;gap:6px">
    <button id="gg-global-settings" style="background:none;border:none;color:#555;font-size:16px;cursor:pointer;padding:2px 4px;-webkit-tap-highlight-color:transparent" title="API Key &amp; Settings">⚙</button>
    <span class="gg-toggle">▼</span>
  </div>
</div>
<div class="gg-body">

<div class="gg-section" id="gg-settings-inputs">
<button class="gg-btn gg-btn-fill" id="gg-autofill" style="width:100%;margin-bottom:10px">⟳ Auto-fill from API</button>
<div class="gg-sec">API Key</div>
<div class="gg-field">
  <label>Torn API Key <span style="font-size:10px;color:#555">— stored locally, auto-fills everything</span></label>
  <div class="gg-api-row">
    <input type="password" id="gg-apikey" placeholder="Paste your 16-character key…" autocomplete="off">
    <button class="gg-api-btn" id="gg-apikey-save">Save</button>
  </div>
  <div style="margin-top:6px;padding:8px 10px;background:#141414;border:1px solid #252525;border-radius:4px;font-size:11px;line-height:1.7;color:#556">
    <strong style="color:#668">Requires: Limited Access</strong> (for battle stats) or custom key:<br>
    <span style="color:#4a6a4a">Normal Access (includes education, gym, perks, stats)</span><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 (Normal Access) →</a><br>
    <span style="color:#444">Prices via <a href="https://weav3r.dev" target="_blank" style="color:#4a6a8a">weav3r.dev</a> (no key needed) · Data stored locally only</span>
  </div>
</div>

<div class="gg-sec" style="margin-top:10px">Debug &amp; Cache</div>
<div class="gg-sg" style="margin-bottom:6px">
  <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-bottom:4px;display:none"></div>
</div>

<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>
<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-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 with API key to check specialist gym eligibility based on your stats.</div>
  </div>
</div>
<div class="gg-field" style="margin:8px 0 6px">
  <label style="font-weight:bold;color:#9a9acc;font-size:12px">Stat to Train</label>
  <select id="gg-stat" style="font-size:15px;font-weight:bold">${statOpts}</select>
</div>

<div style="display:flex;gap:4px;align-items:center;margin-bottom:0">
  <button class="gg-tab active" id="gg-tab-daily" style="flex:1">Daily Grind</button>
  <button id="gg-tab-daily-cfg" style="padding:7px 9px;border-radius:4px;border:1px solid #383838;background:#222;color:#556;font-size:13px;cursor:pointer;-webkit-tap-highlight-color:transparent" title="Daily settings">⚙</button>
  <button class="gg-tab" id="gg-tab-jump" style="flex:1">Happy Jump</button>
  <button id="gg-tab-jump-cfg" style="padding:7px 9px;border-radius:4px;border:1px solid #383838;background:#222;color:#556;font-size:13px;cursor:pointer;-webkit-tap-highlight-color:transparent" title="Jump settings">⚙</button>
</div>

<div class="gg-section active" id="gg-daily-inputs" style="padding:8px 0 4px;font-size:12px;color:#556;text-align:center">
  Tap ⚙ to configure · then Calculate →
</div>

<div class="gg-section" id="gg-daily-cfg-inputs" style="margin-top:8px">
  <div class="gg-sec">Daily Settings</div>
  <div class="gg-sg">
    <div class="gg-field"><label>Energy to Spend</label><input type="number" id="gg-energy" min="0"></div>
    <div class="gg-field"><label>Subscriber</label>
      <select id="gg-subscriber"><option value="no">No (100E)</option><option value="yes">Yes (150E)</option></select>
    </div>
  </div>
  <div class="gg-sg">
    <div class="gg-field"><label>Points Refill</label>
      <select id="gg-dailyRefill"><option value="no">No</option><option value="yes">Yes ($1,725,000)</option></select>
    </div>
    <div class="gg-field"><label>Refill cost ($)</label><input type="number" id="gg-dailyRefillCost" min="0"></div>
  </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"><div class="gg-val-msg" id="gg-val-stat">Enter your current stat total</div></div>
  <div class="gg-field"><label>Stat Goal</label><input type="number" id="gg-statGoal" min="0"><div class="gg-val-msg" id="gg-val-goal">Goal must be higher than current stat</div></div>
</div>
<div class="gg-field"><label>Property Max Happy <span style="font-size:10px;color:#556">— your floor while training naturally</span></label><input type="number" id="gg-happy" min="0"><div class="gg-val-msg" id="gg-val-happy">Enter your property max happy</div></div>

<div class="gg-collapsible" id="gg-perks-panel">
  <div class="gg-collapsible-header">Bonuses &amp; Perks <span id="gg-perks-badge" style="font-size:10px;color:#4a7a4a;margin-left:4px"></span><span class="gg-collapsible-toggle">▼</span></div>
  <div class="gg-collapsible-body">
    <div style="font-size:11px;color:#556;margin-bottom:8px">Auto-filled from API. Adjust manually if needed.</div>
    <div class="gg-sg">
      <div class="gg-field"><label>Faction Gym Bonus</label>${perkSel('factionPerk')}</div>
      <div class="gg-field"><label>Property Gym Bonus</label>${perkSel('propertyPerk')}</div>
      <div class="gg-field"><label>Education (stat-specific)</label>${perkSel('eduStatPerk')}</div>
      <div class="gg-field"><label>Education (all stats)</label>${perkSel('eduGenPerk')}</div>
      <div class="gg-field"><label>Company / 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">0%</option><option value="20">20%</option></select>
      </div>
      <div class="gg-field"><label>Sports Sneakers</label>
        <select id="gg-sportsSneakers"><option value="0">0%</option><option value="5">5%</option></select>
      </div>
    </div>
  </div>
</div>
</div>

<div class="gg-section" id="gg-jump-inputs" style="padding:8px 0 4px;font-size:12px;color:#556;text-align:center">
  Tap ⚙ to configure · then Calculate →
</div>

<div class="gg-section" id="gg-jump-cfg-inputs" style="margin-top:8px">
  <div class="gg-sec">Happy Jump Setup</div>
  <div class="gg-field">
    <label>Happy at Jump Start <span id="gg-happy-badge" style="font-size:10px;color:#4a7a4a"></span></label>
    <input type="number" id="gg-hjBaseHappy" min="0" placeholder="0 = use property max happy">
    <div style="font-size:10px;color:#446;margin-top:3px">Your happy <em>before</em> eDVDs/FHC/ecstasy. 0 uses Property Max Happy.</div>
  </div>
  <div style="font-size:10px;font-weight:bold;color:#556;letter-spacing:.06em;text-transform:uppercase;margin:10px 0 4px">Energy Sources</div>
  <div class="gg-sg">
    <div class="gg-field"><label>Xanax to Stack</label>
      <select id="gg-hjXanaxCount">
        <option value="0">0 Xanax</option>
        <option value="1">1 Xanax (~400E)</option><option value="2">2 Xanax (~650E)</option>
        <option value="3">3 Xanax (~900E)</option><option value="4">4 Xanax (1000E)</option>
      </select>
    </div>
    <div class="gg-field"><label>FHC (Feathery Hotel)</label>
      <select id="gg-hjFHC">
        ${Array.from({length:6},(_,i)=>`<option value="${i}">${i===0?'None':i+' FHC (+'+i*FHC_HAPPY+' happy, '+(i*FHC_CD)+'h CD)'}</option>`).join('')}
      </select>
    </div>
  </div>
  <div class="gg-sg">
    <div class="gg-field"><label>LSD (50E each, no OD risk)</label>
      <select id="gg-hjLSD">
        ${Array.from({length:6},(_,i)=>`<option value="${i}">${i===0?'None':i+' LSD (+'+(i*50)+'E)'}</option>`).join('')}
      </select>
    </div>
    <div class="gg-field"><label>Points Refill after stack</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 (type)</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 E-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>Faction Voracity (extra CD hrs)</label>
      <input type="number" id="gg-hjVoracity" min="0" max="24">
    </div>
  </div>
  <div style="font-size:10px;font-weight:bold;color:#556;letter-spacing:.06em;text-transform:uppercase;margin:10px 0 4px">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"></div>
    <div class="gg-field"><label>Ecstasy at Jump</label>
      <select id="gg-hjEcstasy"><option value="yes">Yes</option><option value="no">No</option></select>
    </div>
  </div>
  <div class="gg-sg">
    <div class="gg-field"><label>10★ Adult Novelties (×2 eDVD)</label>
      <select id="gg-hjANJob"><option value="no">No</option><option value="yes">Yes</option></select>
    </div>
    <div class="gg-field"><label>FHC Happy boost</label>
      <div style="font-size:11px;color:#4a7a4a;padding:8px 0">+${FHC_HAPPY} per FHC (auto)</div>
    </div>
  </div>
  <div class="gg-sg">
    <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 class="gg-field"><label>Number of Candies</label>
      <input type="number" id="gg-hjCandies" min="0" max="48" placeholder="0">
    </div>
  </div>
  <div class="gg-sg">
    <div class="gg-field"><label>Faction 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 class="gg-field"><label>7★ Grocery Absorption (+10%)</label>
      <select id="gg-hjCandyAbsorption"><option value="no">No</option><option value="yes">Yes (+10%)</option></select>
    </div>
  </div>
  <div style="font-size:10px;font-weight:bold;color:#556;letter-spacing:.06em;text-transform:uppercase;margin:10px 0 4px">Costs <button class="gg-btn gg-btn-fill" id="gg-fetch-prices" style="font-size:10px;padding:4px 8px;margin-left:6px;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">
    </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">
    </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">
    </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">
    </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">
    </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">
    </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">
    </div>
  </div>
  <div class="gg-collapsible" id="gg-live-prices-panel" style="margin-top:8px">
    <div class="gg-collapsible-header">💰 Live Item Prices <span style="font-size:10px;color:#4a6a8a;margin-left:6px">all fetched prices</span><span class="gg-collapsible-toggle">▼</span></div>
    <div class="gg-collapsible-body">
      <div id="gg-live-prices" style="min-height:30px">
        <div style="font-size:11px;color:#445">Click ⟳ Live Prices to load.</div>
      </div>
    </div>
  </div>
  <div class="gg-collapsible" id="gg-price-panel" style="margin-top:4px">
    <div class="gg-collapsible-header">📊 Price Rankings <span style="font-size:10px;color:#4a6a8a;margin-left:6px">cheapest energy &amp; happy per $</span><span class="gg-collapsible-toggle">▼</span></div>
    <div class="gg-collapsible-body">
      <div id="gg-price-breakdown" style="min-height:40px">
        <div style="font-size:11px;color:#445">Click ⟳ Live Prices above to load live rankings.</div>
      </div>
    </div>
  </div>
  <div class="gg-collapsible" id="gg-od-panel">
    <div class="gg-collapsible-header">OD Risk Settings <span class="gg-collapsible-toggle">▼</span></div>
    <div class="gg-collapsible-body">
      <div style="padding:4px 0 8px;font-size:11px;color:#665533">⚠ Community estimates — not officially confirmed.</div>
      <div class="gg-sg">
        <div class="gg-field"><label>Xanax base OD %</label><input type="number" id="gg-hjXanaxOD" min="0" max="100" step="0.1"></div>
        <div class="gg-field"><label>Ecstasy base 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>7★ 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>

<div class="gg-btn-row">
  <button class="gg-btn gg-btn-calc" id="gg-calc">Calculate →</button>
  <button class="gg-btn gg-btn-compare" id="gg-compare">⚖ Compare</button>
  <button class="gg-btn gg-btn-copy" id="gg-copy" style="display:none">📋 Copy</button>
</div>
<div style="font-size:10px;color:#444;margin-top:4px;text-align:right">Press Enter to calculate</div>
<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>
<div class="gg-collapsible" id="gg-logger-panel" style="margin-top:10px">
  <div class="gg-collapsible-header">📓 Jump 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">
    <div style="font-size:11px;color:#556;margin-bottom:8px;line-height:1.6">
      Log each jump's actual gain. After 4–5 entries the script reverse-engineers your true gym dots using the formula — fixing accuracy permanently without needing the API gym endpoint.
    </div>
    <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:6px 8px;background:#141414;border:1px solid #252525;border-radius:4px">
      <span id="gg-autolog-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#5a9f5a;flex-shrink:0"></span>
      <span id="gg-autolog-lbl" style="font-size:11px;color:#7abf7a;cursor:pointer;flex:1">Auto-log: ON — watches Train button for jump sessions</span>
      <span style="font-size:10px;color:#445">tap to toggle</span>
    </div>
    <div class="gg-field">
      <label>Actual Gain from Last Jump</label>
      <input type="number" id="gg-log-gain" placeholder="e.g. 33161" min="0">
    </div>
    <div class="gg-sg">
      <div class="gg-field">
        <label>Pre-jump Stat</label>
        <input type="number" id="gg-log-stat" placeholder="stat before jump" min="0">
      </div>
      <div class="gg-field">
        <label>Jump Happy (after eDVDs/xtc)</label>
        <input type="number" id="gg-log-happy" placeholder="e.g. 35050" min="0">
      </div>
    </div>
    <div style="font-size:10px;color:#446;margin-bottom:8px">Gym, stat, subscriber, happy floor and perks are taken from your current inputs above.</div>
    <button class="gg-btn gg-btn-fill" id="gg-log-add" style="width:100%;margin-bottom:8px">+ Log This Jump</button>
    <div id="gg-log-entries" style="margin-bottom:8px"></div>
    <div id="gg-log-calibration" style="margin-bottom:8px"></div>
    <div class="gg-sg" style="margin-top:4px">
      <button class="gg-btn" id="gg-log-export" style="background:#1a1a2a;border-color:#303050;color:#7a7aaa;font-size:11px">⬇ Export CSV</button>
      <button class="gg-btn" id="gg-log-clear" style="background:#1a1010;border-color:#301818;color:#aa5a5a;font-size:11px">🗑 Clear Log</button>
    </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";
    wrap.innerHTML = buildHTML();
    const mt = document.querySelector('.content-wrapper') || document.body;
    mt.insertBefore(wrap, mt.firstChild);

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

    // Gym dropdown rebuild after API load
    let gymConfirmed = false; // tracks whether gym was auto-detected
    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 reDetected = detectGymFromDOM();
            if (reDetected) {
                setGymSelect(sel, String(reDetected));
                gymConfirmed = true;
                if (el.gymBadge) el.gymBadge.textContent = "✓ page";
            }
        }
        if (typeof updateDotsDisplay === 'function') updateDotsDisplay();
    }

    // Force select visual update (needed on TornPDA/webkit)
    function setGymSelect(sel, idStr) {
        sel.value = idStr;
        // Explicitly select the matching option element
        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"),
        hjHappyBadge:   q("#gg-happy-badge"),
        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"),
        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"),
        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"),
    };

    // ─────────────────────────────────────────────────────────────────────────
    // RESTORE & WIRE
    // ─────────────────────────────────────────────────────────────────────────
    const EL_MAP = {
        // NOTE: gymId intentionally excluded — always detected from page DOM/API, never restored from storage
        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",
    };
    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));
    });

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

    // API key
    const savedKey = _load("apiKey","") || "";
    el.apikey.value = savedKey;
    el.apikey.placeholder = savedKey.length === 16 ? "Key saved ✓" : "Paste your 16-character key…";
    el.apikeyBtn.addEventListener("click", () => {
        const k = el.apikey.value.trim();
        if (k.length !== 16) { showStatus("warn", `⚠ API key must be 16 characters (got ${k.length}).`); return; }
        gg_save("apiKey", k);
        el.apikey.placeholder = "Key saved ✓";
        showStatus("ok", "✓ API key saved. Click Auto-fill to load your data.");
        loadGymsFromAPI(k); // refresh gym list with new key
    });

    // Mode tabs
    let mode = gg_load("calcMode") || "daily";
    function setMode(m) {
        mode = m;
        if (m === "daily" || m === "happyjump") gg_save("calcMode", m);
        q("#gg-tab-daily")?.classList.toggle("active", m === "daily");
        q("#gg-tab-jump")?.classList.toggle("active",  m === "happyjump");
        const hlt = (id, on) => { const b = q("#"+id); if (!b) return; b.style.color = on ? "#7a7acc" : "#556"; b.style.borderColor = on ? "#303058" : "#383838"; };
        hlt("gg-tab-daily-cfg", m === "daily-cfg");
        hlt("gg-tab-jump-cfg",  m === "jump-cfg");
        const gs = q("#gg-global-settings"); if (gs) gs.style.color = m === "settings" ? "#7a7acc" : "#555";
        const MAP = { "daily":"gg-daily-inputs", "daily-cfg":"gg-daily-cfg-inputs",
                      "happyjump":"gg-jump-inputs", "jump-cfg":"gg-jump-cfg-inputs", "settings":"gg-settings-inputs" };
        Object.entries(MAP).forEach(([k,id]) => q("#"+id)?.classList.toggle("active", m===k));
    }
    q("#gg-tab-daily")?.addEventListener("click",     () => setMode("daily"));
    q("#gg-tab-jump")?.addEventListener("click",      () => setMode("happyjump"));
    q("#gg-tab-daily-cfg")?.addEventListener("click", () => setMode(mode === "daily-cfg" ? "daily" : "daily-cfg"));
    q("#gg-tab-jump-cfg")?.addEventListener("click",  () => setMode(mode === "jump-cfg"  ? "happyjump" : "jump-cfg"));
    q("#gg-global-settings")?.addEventListener("click", e => {
        e.stopPropagation();
        setMode(mode === "settings" ? (gg_load("calcMode") || "daily") : "settings");
    });
    setMode(mode);

    // Collapsibles — all use optional chaining to prevent crash if panel hidden/missing
    const wireCollapsible = id => {
        q("#"+id)?.querySelector(".gg-collapsible-header")
            ?.addEventListener("click", () => q("#"+id)?.classList.toggle("open"));
    };
    ["gg-perks-panel","gg-od-panel","gg-price-panel","gg-live-prices-panel",
     "gg-logger-panel","gg-export-panel"].forEach(wireCollapsible);

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

    // When candy type changes, auto-fill the cost input
    q("#gg-hjCandyType")?.addEventListener("change", () => {
        const idx = parseInt(q("#gg-hjCandyType").value);
        if (idx > 0) {
            const candy = CANDY_TYPES[idx - 1];
            const 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();
    });

    // When can type changes, auto-fill the cost input from fetched prices
    q("#gg-hjCanType")?.addEventListener("change", () => {
        const idx = parseInt(q("#gg-hjCanType").value);
        if (idx > 0) {
            const can = CAN_TYPES[idx - 1];
            const 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();
    });
    // Rebuild breakdown when subscriber or faction perk changes
    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(); maybeRecalc(); });
    el.autofill.addEventListener("click",     autofill);
    el.fetchPricesBtn.addEventListener("click", () => {
        const hasCache = !!_load(PRICE_CACHE_KEY, null);
        fetchPrices(hasCache); // force refresh if cache exists
    });
    el.calc.addEventListener("click",         calculate);
    el.compare.addEventListener("click", () => { try { calculateCompare(); } catch(e) { showStatus("warn","✗ "+e.message); } });
    el.copy.addEventListener("click",         copyResults);

    // Enter key → calculate
    wrap.addEventListener("keydown", e => { if (e.key === "Enter" && document.activeElement?.tagName !== "BUTTON") calculate(); });

    // Auto-recalc when key inputs change (only if results are visible)
    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();
        });
    });

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

    // ─────────────────────────────────────────────────────────────────────────
    // GYM DOTS DISPLAY
    // ─────────────────────────────────────────────────────────────────────────
    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 || (dailyVisible && mode === "happyjump")) calculateHappyJump();
                else calculateDaily();
            } 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,
            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/max happy
        if (data.energy?.maximum) {
            out.energyCap  = data.energy.maximum;
            out.subscriber = data.energy.maximum >= 150 ? "yes" : "no";
            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":""}`);

        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;
                }
            }
        }

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

    // ── Live Prices Panel ────────────────────────────────────────────────────
    // Shows can + candy prices (the items without dedicated badge displays)
    function renderLivePrices() {
        const panel = q("#gg-live-prices");
        if (!panel) return;

        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 fmtP = p => p >= 1e6 ? `$${(p/1e6).toFixed(2)}m` : `$${Math.round(p/1000)}k`;
        const row  = (name, stat, price, extra) => {
            const priceStr = price ? fmtP(price) : `<span style="color:#445">—</span>`;
            const effStr   = price && extra ? extra : "";
            return `<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid #1a1a2a">
  <span style="font-size:11px;color:#8899aa;flex:1">${name}</span>
  <span style="font-size:10px;color:#5588aa;margin:0 8px;white-space:nowrap">${stat}</span>
  <span style="font-size:12px;color:#aabbcc;text-align:right;min-width:60px">${priceStr}</span>
  ${effStr ? `<span style="font-size:10px;color:#445;text-align:right;margin-left:6px;min-width:40px">${effStr}</span>` : ""}
</div>`;
        };

        let html = "";

        // Energy Cans
        html += `<div style="font-size:9px;font-weight:bold;color:#4a6a8a;text-transform:uppercase;letter-spacing:.06em;margin:4px 0 4px;padding-bottom:3px;border-bottom:1px solid #222">
  ⚡ Energy Cans${canFacPct > 0 ? ` <span style="color:#445;font-weight:normal">(+${Math.round(canFacPct*100)}% faction)</span>` : ""}
  <span style="float:right;color:#334;font-weight:normal">stat · price · $/E</span>
</div>`;
        CAN_TYPES.forEach(c => {
            const effectiveE = canFacPct > 0 ? Math.round(c.e * (1 + canFacPct)) : c.e;
            const p     = canPrices[c.label] || 0;
            const effStr = p ? Math.round(p / effectiveE / 1000) + "k/E" : "";
            html += row(c.label, `+${effectiveE}E · 2h CD`, p, effStr);
        });

        // Candy
        html += `<div style="font-size:9px;font-weight:bold;color:#8a6a4a;text-transform:uppercase;letter-spacing:.06em;margin:10px 0 4px;padding-bottom:3px;border-bottom:1px solid #222">
  🍬 Candy${candyMult > 1 ? ` <span style="color:#445;font-weight:normal">(×${candyMult.toFixed(2)} voracity)</span>` : ""}
  <span style="float:right;color:#334;font-weight:normal">stat · price · $/happy</span>
</div>`;
        CANDY_TYPES.forEach(c => {
            const effectiveH = Math.round(c.happy * candyMult);
            const p      = candyPrices[c.label] || 0;
            const effStr = p ? Math.round(p / effectiveH / 1000) + "k/★" : "";
            html += row(c.label, `+${effectiveH} happy · 30m CD`, p, effStr);
        });

        // Cache age
        try {
            const { ts } = JSON.parse(_load(PRICE_CACHE_KEY, "{}"));
            if (ts) {
                const ageMin = Math.round((Date.now()-ts)/60000);
                const ageStr = ageMin < 1 ? "just now" : `${ageMin}m ago`;
                html += `<div style="font-size:9px;color:#334;text-align:right;margin-top:6px;padding-top:4px;border-top:1px solid #1a1a2a">updated ${ageStr} · 1h cache · click ⟳ to refresh</div>`;
            } else {
                html += `<div style="font-size:9px;color:#445;margin-top:6px">Click ⟳ Live Prices to load prices</div>`;
            }
        } catch(_) {}

        panel.innerHTML = html;

        // Auto-open the panel so the user sees the results
        q("#gg-live-prices-panel")?.classList.add("open");
    }

    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;   // energy can faction %
        const candyVorPct  = gi(el.hjCandyVoracity)    / 100;   // candy voracity %
        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;

        // Build rows: { name, e_raw, e_eff, price, per_e, cd, notes }
        const rows = [];

        // Xanax — 250E, 7h avg CD
        if (xanPrice > 0) rows.push({ name:"Xanax", e:250, price:xanPrice, cd:"6–8h", notes:"Drug. OD risk. ~250E" });

        // LSD — 50E, no OD
        if (lsdPrice > 0) rows.push({ name:"LSD", e:50, price:lsdPrice, cd:"~3h", notes:"No OD risk. 50E" });

        // FHC — refills to cap + 500 happy, 6h CD
        if (fhcPrice > 0) rows.push({ name:"FHC", e:cap, price:fhcPrice, cd:"6h", notes:`Refills to ${cap}E + 500 happy` });

        // Points refill — always 1,725,000
        rows.push({ name:"Points Refill", e:cap, price:1725000, cd:"—", notes:`Refills to ${cap}E. Daily via 25pts` });

        // Energy cans
        CAN_TYPES.forEach(can => {
            const price = canPrices[can.label];
            if (!price) return;
            const eEff = Math.round(can.e * (1 + canFacPct));
            rows.push({ name: can.label, e:eEff, price, cd:"2h/can", notes:`${can.e}E base${canFacPct>0?` + ${canFacPct*100}% faction = ${eEff}E`:""}` });
        });

        if (!rows.length) {
            const noKey = !(_load("apiKey","")||"").trim();
            panel.innerHTML = `<div style='font-size:11px;color:#445'>${noKey
                ? "Add an API key and click ⟳ Live Prices to load rankings."
                : "Click ⟳ Live Prices to load live rankings."}</div>`;
            return;
        }

        // Sort by $/E (cheapest first)
        rows.forEach(r => r.per_e = r.price / r.e);
        rows.sort((a, b) => a.per_e - b.per_e);

        const best = rows[0].per_e;
        let html = `<div style="font-size:11px;color:#4a5a6a;margin-bottom:6px">
            $/E calculated live. ${canFacPct>0?`Faction E-can +${canFacPct*100}% applied.`:""} ${cap}E cap (${cap===150?"Subscriber":"Non-sub"}).
        </div>`;

        html += `<div style="display:grid;grid-template-columns:1fr auto auto auto;gap:2px 8px;align-items:baseline">
<div style="font-size:10px;color:#445;text-transform:uppercase">Item</div>
<div style="font-size:10px;color:#445;text-align:right">Energy</div>
<div style="font-size:10px;color:#445;text-align:right">$/E</div>
<div style="font-size:10px;color:#445;text-align:right">CD</div>`;

        rows.forEach((r, i) => {
            const isBest = i === 0;
            const ratio  = r.per_e / best;
            const color  = ratio < 1.15 ? "#7abf7a" : ratio < 1.5 ? "#bf9f5a" : "#778";
            const star   = isBest ? " ★" : "";
            html += `
<div style="font-size:12px;color:${color};font-weight:${isBest?'bold':'normal'}" title="${r.notes}">${r.name}${star}</div>
<div style="font-size:11px;color:#778;text-align:right">${r.e}E</div>
<div style="font-size:12px;color:${color};text-align:right;font-weight:${isBest?'bold':'normal'}">${fmt(Math.round(r.per_e))}</div>
<div style="font-size:11px;color:#556;text-align:right">${r.cd}</div>`;
        });

        html += `</div>`;

        // ── Section 1b: Candy $/happy ranking (separate from energy) ─────────
        const candyRows = [];
        CANDY_TYPES.forEach(c => {
            const price = candyPrices[c.label];
            if (!price) return;
            const happyEff = Math.round(c.happy * candyMult);
            candyRows.push({ name: c.label, happy: happyEff, price, per_h: price / happyEff,
                             baseHappy: c.happy, maxTotal: 48 * happyEff });
        });

        if (candyRows.length > 0) {
            candyRows.sort((a, b) => a.per_h - b.per_h);
            const bestHappy = candyRows[0].per_h;
            const modNote = [
                candyVorPct > 0 ? `+${Math.round(candyVorPct*100)}% Voracity` : '',
                candyAbsPct > 0 ? '+10% Absorption' : '',
            ].filter(Boolean).join(', ');

            html += `<div style="margin-top:10px;padding-top:8px;border-top:1px solid #252535">
<div style="font-size:10px;color:#445;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">
  Candy $/Happy${modNote ? ` · ${modNote}` : ""} · 30min CD each, 48 max
</div>`;
            html += `<div style="display:grid;grid-template-columns:1fr 50px 56px 56px;gap:1px 6px;align-items:center">
<div style="font-size:9px;color:#334;text-transform:uppercase;padding-bottom:3px;border-bottom:1px solid #252535">Candy</div>
<div style="font-size:9px;color:#334;text-transform:uppercase;text-align:center;padding-bottom:3px;border-bottom:1px solid #252535">Happy</div>
<div style="font-size:9px;color:#334;text-transform:uppercase;text-align:right;padding-bottom:3px;border-bottom:1px solid #252535">$/Happy</div>
<div style="font-size:9px;color:#334;text-transform:uppercase;text-align:right;padding-bottom:3px;border-bottom:1px solid #252535">48× max</div>`;

            candyRows.forEach((r, i) => {
                const ratio  = r.per_h / bestHappy;
                const isBest = i === 0;
                const color  = isBest ? "#7abf7a" : ratio < 1.2 ? "#9abf6a" : ratio < 2 ? "#bf9f5a" : "#5a5a6a";
                const badge  = isBest ? `<span style="font-size:9px;background:#1a3a1a;border:1px solid #2a5a2a;border-radius:3px;padding:0 4px;margin-left:4px;color:#7abf7a">BEST</span>` : "";
                const maxHappy = fmt(r.maxTotal);
                html += `
<div style="font-size:12px;color:${color};font-weight:${isBest?'bold':'normal'};padding:4px 0 3px;${i>0?'border-top:1px solid #1a1a28':''}">${r.name}${badge}</div>
<div style="font-size:11px;color:#556;text-align:center;padding:4px 0 3px;${i>0?'border-top:1px solid #1a1a28':''}">${r.happy}</div>
<div style="font-size:${isBest?'13':'12'}px;color:${color};font-weight:${isBest?'bold':'normal'};text-align:right;padding:4px 0 3px;${i>0?'border-top:1px solid #1a1a28':''}">${fmt(Math.round(r.per_h))}</div>
<div style="font-size:11px;color:#3a4a5a;text-align:right;padding:4px 0 3px;${i>0?'border-top:1px solid #1a1a28':''}">${maxHappy}</div>`;
            });
            html += `</div>`;

            // Cost to get X happy from candy
            html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid #252535">
<div style="font-size:10px;color:#445;text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Cost for 49 candies (common jump amount)</div>`;
            candyRows.forEach((r, i) => {
                const totalCost = 49 * r.price;
                const totalHappy = 49 * r.happy;
                const isBest = i === 0;
                const color = isBest ? "#7abf7a" : "#778";
                const costStr = totalCost >= 1e6 ? `$${(totalCost/1e6).toFixed(2)}m` : `$${(totalCost/1000).toFixed(0)}k`;
                html += `<div style="display:flex;justify-content:space-between;padding:2px 0;${i>0?'border-top:1px solid #1a1a28':''}">
  <span style="font-size:11px;color:${color};font-weight:${isBest?'bold':'normal'}">${r.name} ×49</span>
  <span style="font-size:11px;color:${color};font-weight:${isBest?'bold':'normal'}">${costStr} <span style="font-size:10px;color:#445">+${fmt(totalHappy)} happy</span></span>
</div>`;
            });
            html += `</div></div>`;
        }

        // FHC note about happy bonus
        if (fhcPrice > 0) {
            const fhcRow = rows.find(r => r.name === "FHC");
            if (fhcRow) {
                const effPriceWithHappy = fhcPrice; // happy value is hard to quantify in $
                html += `<div style="font-size:10px;color:#4a6a8a;margin-top:6px;padding-top:6px;border-top:1px solid #252535">
                    ★ FHC also gives 500 happy — this can significantly boost gains if your happy is below max.
                </div>`;
            }
        }

        // "To get 150E" breakdown for subscriber perspective
        const targetE = cap;
        html += `<div style="font-size:10px;font-weight:bold;color:#445;text-transform:uppercase;letter-spacing:.06em;margin-top:10px;padding-top:6px;border-top:1px solid #252535">
            Cost to get ${targetE}E
        </div>`;
        rows.forEach(r => {
            if (!r.price) return;
            const canCount = Math.ceil(targetE / r.e);
            const totalCost = canCount * r.price;
            const totalCD   = r.name === "Points Refill" ? "—" :
                              r.name === "FHC" ? `${Math.ceil(targetE / r.e) * (r.name==="FHC"?6:2)}h CD` :
                              r.cd !== "—" ? `${canCount * 2}h CD` : "—";
            html += `<div style="display:flex;justify-content:space-between;font-size:11px;color:#778;padding:2px 0">
                <span>${r.name}${canCount>1?` ×${canCount}`:""}</span>
                <span style="color:#aab">$${fmt(totalCost)} <span style="color:#445;font-size:10px">${totalCD}</span></span>
            </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();
        showStatus("ok", "⟳ Fetching data…");

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

        Promise.all([gymLoadPromise, apiPromise]).then(([_, api]) => {
            if (api?.error) { showStatus("warn", `⚠ ${api.error}`); 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
                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.currentHappy != null) {
                    // Set hjBaseHappy to current happy for this session only — don't persist
                    el.hjBaseHappy.value = api.currentHappy;
                    el.hjHappyBadge.textContent = `✓ ${api.currentHappy.toLocaleString()} current`;
                }

                // 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"; q("#gg-perks-panel").classList.add("open"); }
                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);

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

    // ─────────────────────────────────────────────────────────────────────────
    // STARTUP — detect gym from page DOM immediately (sync), stats after DOM settles
    // ─────────────────────────────────────────────────────────────────────────

    // Detect gym from page right away
    (function detectGymOnLoad() {
        el.gym.value = "";
        const domGymId = detectGymFromDOM();
        if (domGymId) {
            setGymSelect(el.gym, String(domGymId));
            el.gymBadge.textContent = "✓ page";
            gymConfirmed = true;
        }
    })();

    // After 800ms for React/SPA to settle, re-detect and fill stats
    setTimeout(() => {
        const domGymId = detectGymFromDOM();
        if (domGymId) {
            setGymSelect(el.gym, String(domGymId));
            el.gymBadge.textContent = "✓ page";
            gymConfirmed = true;
        }
        tryAutoFillStat();
        updateDotsDisplay();
        validateInputs();
        updateBestGymPanel();
    }, 800);

    // Third attempt at 2.5s for slow-loading SPAs (TornPDA)
    setTimeout(() => {
        if (!gymConfirmed) {
            const domGymId = detectGymFromDOM();
            if (domGymId) {
                setGymSelect(el.gym, String(domGymId));
                el.gymBadge.textContent = "\u2713 page";
                gymConfirmed = true;
                updateDotsDisplay();
                updateBestGymPanel();
            }
        }
    }, 2500);

    // Third attempt at 2.5s for slow-loading SPAs (TornPDA)
    setTimeout(() => {
        if (!gymConfirmed) {
            const domGymId = detectGymFromDOM();
            if (domGymId) {
                setGymSelect(el.gym, String(domGymId));
                el.gymBadge.textContent = "✓ page";
                gymConfirmed = true;
                updateDotsDisplay();
                updateBestGymPanel();
            }
        }
    }, 2500);

    // Load cached gym dots from API (if saved), refresh dots display
    setTimeout(() => {
        const k = (_load("apiKey","")||"").trim();
        if (k.length === 16) loadGymsFromAPI(k).then(() => {
            updateDotsDisplay();
            updateBestGymPanel();
        });
    }, 100);

    // Restore last-updated label
    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`;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // AUTO GYM SWITCHER
    // Intercepts Torn's Train buttons. If not in the best gym for the stat
    // being trained, it clicks Torn's own Switch button instead, shows a banner,
    // and lets the user click Train again.
    // ToS compliant: one user interaction → one action (gym switch), no background requests.
    // ─────────────────────────────────────────────────────────────────────────

    // Switcher toggle — saved in storage
    let switcherEnabled = _load('autoSwitchEnabled', true);

    // Add toggle to our widget header area
    const switcherToggle = document.createElement('div');
    switcherToggle.style.cssText = 'font-size:10px;color:#556;padding:4px 12px 0;display:flex;align-items:center;gap:6px;cursor:pointer;-webkit-tap-highlight-color:transparent;user-select:none';
    switcherToggle.innerHTML = `
        <span id="gg-switcher-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${switcherEnabled?'#5a9f5a':'#555'};flex-shrink:0"></span>
        <span id="gg-switcher-lbl" style="color:${switcherEnabled?'#7abf7a':'#555'}">Auto gym switcher: ${switcherEnabled?'ON':'OFF'}</span>`;
    wrap.querySelector('.gg-body').prepend(switcherToggle);

    switcherToggle.addEventListener('click', () => {
        switcherEnabled = !switcherEnabled;
        _save('autoSwitchEnabled', switcherEnabled);
        switcherToggle.querySelector('#gg-switcher-dot').style.background = switcherEnabled ? '#5a9f5a' : '#555';
        const lbl = switcherToggle.querySelector('#gg-switcher-lbl');
        lbl.style.color = switcherEnabled ? '#7abf7a' : '#555';
        lbl.textContent = `Auto gym switcher: ${switcherEnabled ? 'ON' : 'OFF'}`;
        if (!switcherEnabled) hideSwitchBanner();
    });

    // Banner shown when gym is switched
    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) {
        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;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // AUTO JUMP LOGGER
    // Watches Train button clicks and the post-train result DOM mutation to
    // capture: pre-jump stat, jump happy, total gain, trains, and gym context.
    // Groups consecutive trains into a session; auto-logs when idle >5 min.
    // ─────────────────────────────────────────────────────────────────────────

    // Parse stat value from the gym card — Torn shows e.g. "352,245" next to stat icon
    function readStatFromPage(statKey) {
        // Map our stat key to label text shown on page
        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);
        // Scan all text for "STR 33,401" pattern
        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 t = node.textContent.trim();
            if (t === abbrev) {
                // Value is usually in the next sibling text node or nearby element
                const sib = node.parentElement?.nextElementSibling;
                if (sib) {
                    const v = parseFloat(sib.textContent.replace(/,/g,''));
                    if (v > 0) return v;
                }
                // Or in the same parent as a number following the label
                const parent = node.parentElement?.parentElement;
                if (parent) {
                    const nums = parent.textContent.match(/[\d,]{3,}/g);
                    if (nums) {
                        const v = parseFloat(nums[0].replace(/,/g,''));
                        if (v > 0) return v;
                    }
                }
            }
            // Also handle "STR\n33,401" style (label + value in same element)
            if (new RegExp(`^${abbrev}\\s+([\\d,]+)$`).test(t)) {
                const m = t.match(/[\d,]+$/);
                if (m) return parseFloat(m[0].replace(/,/g,''));
            }
        }
        return null;
    }

    // Read current energy from page ("You have X/Y energy")

    // Read current happy from page
    function readHappyFromPage() {
        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 t = node.textContent.trim();
            // Torn shows happy as a number in the sidebar or top bar
            if (/^\d{2,6}$/.test(t.replace(/,/g,''))) {
                const parent = node.parentElement;
                const ctx = (parent?.closest('[class*="happy"], [id*="happy"]') ||
                             parent?.previousElementSibling?.textContent?.toLowerCase().includes('happy') ||
                             parent?.parentElement?.textContent?.toLowerCase().includes('happiness'));
                if (ctx) {
                    const v = parseInt(t.replace(/,/g,''));
                    if (v > 0 && v < 100000) return v;
                }
            }
        }
        return null;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // ─────────────────────────────────────────────────────────────────────────
    // JUMP SESSION TRACKING
    // Each jump is ONE train result (e.g. 999 energy = one click).
    // Possibly followed by ONE refill train (~150E) after returning from Points.
    // We track a pending session: log immediately on the jump train, then
    // watch for a refill train within a short window to add to the total.
    // Page unload between the two is fine — the stack entry is already saved.
    // ─────────────────────────────────────────────────────────────────────────

    // ─────────────────────────────────────────────────────────────────────────
    // AUTO JUMP LOGGER — per-event logging
    // Each train result is logged independently using the actual energy detected
    // from Torn's result text ("You used 1000 energy...").
    // No merge logic, no session state — simple and reliable.
    // The calibration formula uses actualEnergy directly for each entry.
    // ─────────────────────────────────────────────────────────────────────────

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

        // Filter out daily grind: only log if energy used is well above natural max (150E)
        // 1000E jump: gain ~31k-36k  |  150E refill: gain ~2k-5k
        // Both are valid jump-cycle events — log both, just mark them differently
        const isMainJump   = energyUsed >= 500;   // 1000E Xanax stack
        const isRefillJump = energyUsed >= 100 && energyUsed <= 499; // 150E refill
        if (!isMainJump && !isRefillJump) return; // daily grind — skip

        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;

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

        const entry = {
            ts:          Date.now(),
            stat,
            gymId:       gym?.id,
            gymName:     gym?.name,
            gymDots,
            preStat,
            jumpHappy:   readHappyFromPage() || gf(el.happy) || 5025,
            actualGain:  Math.round(gain),
            energyUsed,                          // actual energy from result text
            stackTrains: isMainJump ? 1 : 0,     // for CSV compat
            refillTrains: isRefillJump ? 1 : 0,  // for CSV compat
            propHappy:   gf(el.happy) || 5025,
            ePerTrain:   gym?.energy || 10,
            bonus:       calcBonus(stat),
            autoLogged:  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) < 30000 &&
            Math.abs(e.actualGain - entry.actualGain) < 100
        );
        if (isDupe) return;

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

        const typeLabel = isMainJump ? '1000E jump' : '150E refill';
        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(';');
        banner.textContent = `📓 Logged ${typeLabel}: +${fmt(entry.actualGain)} ${STAT_LABELS[stat]}`;
        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) {
            // For characterData mutations the target IS the text node
            const nodes = mut.type === 'characterData'
                ? [mut.target]
                : [...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;

                // Only accept result text from the gym training area.
                // Torn's event log, notifications, and other panels also contain
                // "You used X energy..." text — we must ignore those.
                // The gym result appears inside #gymroot or the main content wrapper,
                // NOT inside a log/event/notification overlay panel.
                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 < 5) continue;

                // Extra guard: the result text must contain our key trigger phrase.
                // This rules out partial matches from log snippets.
                const hasResult = /increasing it by|you (?:have )?gained\s+[\d,]+\s+(?:strength|speed|defense|dexterity)/i.test(text);
                if (!hasResult) continue;

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

                // Pattern 1 (full): "You used 1000 energy...increasing it by 31887 to 453315"
                // Captures energy used AND gain AND new stat
                const mFull = text.match(/you used\s+([\d,]+)\s+energy[^.]*?increasing it by\s+([\d,]+(?:\.\d+)?)\s+to\s+([\d,]+(?:\.\d+)?)/i);
                if (mFull) {
                    energyUsed = parseInt(mFull[1].replace(/,/g,''));
                    gain       = parseFloat(mFull[2].replace(/,/g,''));
                    newStat    = Math.round(parseFloat(mFull[3].replace(/,/g,'')));
                }

                // Pattern 2 (short): "increasing it by 31887.00 to 453315.00"
                if (!gain) {
                    const mShort = text.match(/increasing it by\s+([\d,]+(?:\.\d+)?)\s+to\s+([\d,]+(?:\.\d+)?)/i);
                    if (mShort) {
                        gain    = parseFloat(mShort[1].replace(/,/g,''));
                        newStat = Math.round(parseFloat(mShort[2].replace(/,/g,'')));
                        // Try to also find energy in the same text block
                        const mE = text.match(/you used\s+([\d,]+)\s+energy/i);
                        if (mE) energyUsed = parseInt(mE[1].replace(/,/g,''));
                    }
                }

                // Pattern 3: "You have gained 35158.00 dexterity" / "You gained 35158 strength"
                // This is a per-train result from Torn (not a session total).
                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 = Math.round(readStatFromPage(statKey) || 0);
                        // Infer energy from gain magnitude
                        energyUsed = gain > 10000 ? 1000 : 150;
                    }
                }

                if (!gain) continue;

                // Try to get stat from "training your dexterity" in the text
                if (!statKey) {
                    const mStat = text.match(/training (?:your\s+)?(strength|speed|defense|dexterity)/i);
                    if (mStat) statKey = STAT_KEYS[mStat[1].toLowerCase()];
                }
                // Fallback to context or last clicked
                if (!statKey) {
                    const container = node.parentElement;
                    if (container) {
                        const ctxt = container.textContent.toLowerCase();
                        for (const [k, sk] of Object.entries(STAT_KEYS)) {
                            if (ctxt.includes(k) || ctxt.includes(STAT_LABELS[k].toLowerCase())) {
                                statKey = sk; break;
                            }
                        }
                    }
                    if (!statKey && lastTrainedStat) statKey = lastTrainedStat;
                }

                // If energyUsed still unknown, infer from gain magnitude
                if (!energyUsed && gain) {
                    energyUsed = gain > 10000 ? 1000 : 150;
                }

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


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

    function handleTrainClick(e, btn) {
        // Snapshot stat AND happy at the moment of click — before the train processes
        // and before happy regen ticks. This is the only reliable moment to read happy.
        const statKey = getStatForTrainButton(btn);
        if (statKey) lastTrainedStat = statKey;
        lastTrainedHappy = readHappyFromPage(); // snapshot now, before result fires

        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() {
        const buttons = document.querySelectorAll('button');
        for (const btn of buttons) {
            if (wrap.contains(btn)) continue;
            if (hookedButtons.has(btn)) continue;
            const t = btn.textContent.trim().toUpperCase();
            if (t !== '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;
        return (stat*lnPart + 8*Math.pow(H,1.05) + (1-Math.pow(H/99999,2))*consts.A + consts.B)
               * (1/200000) * dots * energy * bonus;
    }

    // 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;

        // Initial energy block
        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;

        // Natural regen
        const natEPerHr  = NATURAL_E[el.subscriber.value] || NATURAL_E.no;
        const natEPerDay = natEPerHr * 24;
        const natTrains  = Math.floor(natEPerDay / ePerTrain);
        const wastedE    = natEPerDay - natTrains * ePerTrain;
        const dayBlock   = simulateBlock(dots, initBlock.finalStat, 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 total</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)} — this is your property max happy floor`);
        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)} (random variance ±${fmtD(errSingle,2)})`);

        // ── Current energy block ──────────────────────────────────────────────
        if (numTrains > 0) {
            html += rsec(`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 spending this energy`);
        }

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

        // ── Daily refill ──────────────────────────────────────────────────────
        if (useRefill) {
            html += rsec("Points Refill (Extra $1,725,000/day)");
            html += row("Energy bought", `${refillE}E → ${refillTrains} more trains`);
            html += row("Extra gain",    `+${fmt(refillBlock.totalGain)} ${STAT_LABELS[stat]} from refill`, "hi");
            html += row("Cost",          `$${fmt(refillCost)}/day from Points building`, "b");
        }

        // ── Total ─────────────────────────────────────────────────────────────
        html += rsec("Total Per Day");
        html += row("Gain/day",   `+${fmt(totalDailyGain)} ${STAT_LABELS[stat]}${useRefill?" (regen + refill)":""}`, "hi");
        if (useRefill) html += row("Cost/day", `$${fmt(dailyCostPerDay)} for the Points refill`, "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 of training like this`, "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, propHappy + fhcHappyBoost * 0.5, 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, propHappy + fhcHappyBoost * 0.5, 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, lsdCount, canCount, canType,
                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 gains ─────────────────────────────────────────────────
        const eDaily      = gi(el.energy) || energyCap;
        const dailyTrains = Math.floor(eDaily / ePerTrain);
        const gainDaily   = simulateBlock(dots, statNow, propHappy, bonus, ePerTrain, consts, dailyTrains).totalGain;
        const refillCost  = el.dailyRefill.value === "yes" ? gf(el.dailyRefillCost) : 0;
        const cycleDaily  = energyCap / natEPerHr;
        const weeklyDaily = gainDaily * (24 * 7 / cycleDaily);

        // ── Happy Jump gains ──────────────────────────────────────────────────
        const gainStack   = simulateBlock(dots, statNow, jumpHappy, bonus, ePerTrain, consts, stackTrains).totalGain;
        const gainFHC     = fhcCount > 0 ? simulateBlock(dots, statNow+gainStack, propHappy+fhcHappyBoost*0.5, 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);

        // ── Goal projections ──────────────────────────────────────────────────
        let dJumps=0, dCur=statNow;
        while (dCur<statGoal && dJumps<MAX_ITER) {
            dCur += simulateBlock(dots,dCur,propHappy,bonus,ePerTrain,consts,dailyTrains).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,propHappy+fhcHappyBoost*0.5,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++;
        }
        const daysDaily = dJumps * (cycleDaily / 24);
        const daysJump  = jJumps * (cycleAvg / 24);

        // ── Build comparison table ────────────────────────────────────────────
        const dailyWins = (metric) => metric === "gain" ? gainDaily >= gainJump
                        : metric === "weekly" ? weeklyDaily >= weeklyJump
                        : daysDaily <= daysJump;
        const val = (v, win) => `<div class="gg-cmp-val${win?" win":" lose"}">${v}</div>`;

        let html = `<div class="gg-cmp">
  <div class="gg-cmp-title">⚖ Daily Grind vs Happy Jump — ${statLabel} at ${gym.name}</div>
  <div class="gg-cmp-grid">
    <div class="gg-cmp-hdr"></div>
    <div class="gg-cmp-hdr daily">Daily Grind</div>
    <div class="gg-cmp-hdr jump">Happy Jump</div>`;

        const rows_ = [
            ["Gain / session",  `+${fmtD(gainDaily,1)}`,     `+${fmtD(gainJump,1)}`,    "gain"],
            ["Gain / week",     `+${fmt(weeklyDaily)}`,       `+${fmt(weeklyJump)}`,     "weekly"],
            ["Cycle time",      `${fmtD(cycleDaily,1)}h`,     `${fmtD(cycleAvg,1)}h`,   null],
            ["Cost / session",  `$${fmt(refillCost||0)}`,     `$${fmt(costTotal)}`,      null],
            ["Sessions to goal",`${dJumps>=MAX_ITER?"∞":dJumps}`, `${jJumps>=MAX_ITER?"∞":jJumps}`, null],
            ["Days to goal",    `${fmtD(daysDaily,1)}d`,      `${fmtD(daysJump,1)}d`,   "days"],
        ];
        for (const [label, dv, jv, metric] of rows_) {
            const dWins = metric ? dailyWins(metric) : null;
            html += `<div class="gg-cmp-label">${label}</div>
    ${val(dv, dWins === true)} ${val(jv, dWins === false)}`;
        }
        html += `</div>`;

        // Verdict
        const jumpBetter = weeklyJump > weeklyDaily;
        html += `<div class="gg-cmp-verdict">${jumpBetter
            ? `🏆 <strong>Happy Jump wins</strong> — ${fmtD((weeklyJump/weeklyDaily-1)*100,0)}% more ${statLabel}/week. Worth ~$${fmt(costTotal)}/jump if you can afford it.`
            : `📈 <strong>Daily Grind wins</strong> — simpler, cheaper, and more gains/week at your current stats/happy. Happy Jump becomes better with higher happy or more energy.`
        }</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;
    }


    function calculate() {
        try {
            el.compareResults.style.display = "none";
            if (mode === "happyjump") calculateHappyJump();
            else calculateDaily();
        } 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."));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // JUMP LOGGER
    // Stores actual jump results and reverse-engineers true gym dots via
    // least-squares fitting: minimise Σ(predicted(dots) - actual)²
    // Entries expire after 7 days. Export as CSV for external analysis.
    // ─────────────────────────────────────────────────────────────────────────
    const LOG_KEY   = "jumpLog_v1";
    const LOG_TTL   = 7 * 24 * 60 * 60 * 1000; // 7 days in ms

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

    // Predict total gain for a given dots value using current formula
    function predictJump(entry, dots) {
        const consts    = STAT_CONSTS[entry.stat];
        const ePerTrain = entry.ePerTrain || 10;

        // Infer energy used — handle both new format (energyUsed field) and
        // old format (stackTrains=100 means 1000E, stackTrains=15 means 150E refill)
        let energy = entry.energyUsed;
        if (!energy) {
            if (entry.stackTrains >= 50)       energy = entry.stackTrains * ePerTrain; // old multi-train format
            else if (entry.stackTrains === 1)  energy = 1000; // old single-entry = 1000E jump
            else                               energy = ePerTrain; // fallback
        }

        const numTrains = Math.round(energy / ePerTrain);
        return simulateBlock(
            dots, Math.round(entry.preStat), entry.jumpHappy,
            entry.bonus, ePerTrain, consts,
            numTrains, entry.propHappy
        ).totalGain;
    }

    // Least-squares golden-section search for best dots value
    // Searches [1.0, 9.5] with sub-0.01 precision
    function calibrateDots(entries) {
        if (entries.length < 2) return null;
        const loss = d => entries.reduce((sum, e) => {
            const diff = predictJump(e, d) - e.actualGain;
            return sum + diff * diff;
        }, 0);

        // Golden section search
        const phi = (Math.sqrt(5) - 1) / 2;
        let a = 1.0, b = 9.5;
        let c = b - phi * (b - a);
        let d = a + phi * (b - a);
        for (let i = 0; i < 100; i++) {
            if (loss(c) < loss(d)) { b = d; } else { a = c; }
            c = b - phi * (b - a);
            d = a + phi * (b - a);
            if (Math.abs(b - a) < 0.001) break;
        }
        const bestDots = (a + b) / 2;
        // Calculate R² across entries
        const meanActual = entries.reduce((s,e) => s + e.actualGain, 0) / entries.length;
        const ssTot = entries.reduce((s,e) => s + Math.pow(e.actualGain - meanActual, 2), 0);
        const ssRes = entries.reduce((s,e) => s + Math.pow(predictJump(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 allEntries = logPrune(logLoad());
        logSave(allEntries); // prune stale on render
        const badge = q("#gg-logger-badge");
        if (badge) badge.textContent = allEntries.length > 0 ? `${allEntries.length} entries` : "";

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

        if (allEntries.length === 0) {
            entriesEl.innerHTML = `<div style="font-size:11px;color:#445;padding:4px 0">No jumps logged yet.</div>`;
            calibEl.innerHTML = "";
            return;
        }

        // Render entry list
        const rows = allEntries.map((e, i) => {
            const age  = Math.round((Date.now() - e.ts) / 3600000);
            const ageStr = age < 1 ? "just now" : age < 24 ? `${age}h ago` : `${Math.round(age/24)}d ago`;
            const predicted = predictJump(e, e.gymDots);
            const error     = e.actualGain - predicted;
            const errPct    = (error / predicted * 100).toFixed(1);
            const errColor  = Math.abs(parseFloat(errPct)) > 5 ? "#bf7a3a" : "#4a7a4a";
            return `<div style="display:flex;justify-content:space-between;align-items:center;padding:5px 0;border-bottom:1px solid #1e1e1e;font-size:11px">
  <div>
    <span style="color:#7a7aaa">#${i+1}</span>
    <span style="color:#667;margin:0 4px">·</span>
    <span style="color:#8a8">${STAT_LABELS[e.stat] || e.stat}</span>
    <span style="color:#667;margin:0 4px">·</span>
    <span style="color:#aaa">+${fmt(e.actualGain)}</span>
    <span style="color:${errColor};margin-left:4px">(err ${errPct}%)</span>
  </div>
  <div style="color:#445">${ageStr} <button data-idx="${i}" class="gg-log-del" style="margin-left:6px;background:none;border:none;color:#663;cursor:pointer;font-size:12px">✕</button></div>
</div>`;
        }).join('');
        entriesEl.innerHTML = `<div style="margin-bottom:4px;font-size:10px;color:#445;text-transform:uppercase;letter-spacing:.05em">Logged Jumps (last 7 days)</div>${rows}`;

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

        // Calibration panel — needs ≥2 entries
        if (allEntries.length < 2) {
            calibEl.innerHTML = `<div style="font-size:11px;color:#445;padding:4px 0">Log at least 2 jumps to calibrate dots.</div>`;
            return;
        }

        const cal = calibrateDots(allEntries);
        if (!cal) return;

        const currentDots = getGymData()?.[STAT_KEYS[allEntries[0]?.stat]] || "?";
        const dotsMatch   = Math.abs(cal.dots - parseFloat(currentDots)) < 0.15;
        const confidence  = allEntries.length >= 5 ? "High" : allEntries.length >= 3 ? "Medium" : "Low";
        const confColor   = allEntries.length >= 5 ? "#4a9a4a" : allEntries.length >= 3 ? "#9a9a4a" : "#9a7a3a";
        const r2Pct       = (cal.r2 * 100).toFixed(1);

        calibEl.innerHTML = `
<div style="margin-top:8px;padding:8px 10px;background:#141a14;border:1px solid #253025;border-radius:4px">
  <div style="font-size:10px;color:#4a7a4a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">Calibrated Gym Dots</div>
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
    <div>
      <span style="font-size:20px;font-weight:bold;color:#7abf7a">${cal.dots}</span>
      <span style="font-size:11px;color:#556;margin-left:6px">vs current ${currentDots} ${dotsMatch ? "✓ match" : "⚠ differs"}</span>
    </div>
    <div style="text-align:right;font-size:11px;color:#556">
      <div>R² ${r2Pct}%</div>
      <div>RMSE ±${fmt(Math.round(cal.rmse))}</div>
    </div>
  </div>
  <div style="font-size:11px;color:${confColor}">Confidence: ${confidence} (${allEntries.length} jumps)</div>
  ${!dotsMatch && allEntries.length >= 3 ? `
  <div style="margin-top:8px;padding:6px 8px;background:#1a1a10;border:1px solid #2a2818;border-radius:3px;font-size:11px;color:#9a9a4a">
    ⚠ Calibrated dots (${cal.dots}) differ from current (${currentDots}) by more than 0.1.
    Your fallback table may be inaccurate for this gym — consider running Auto-fill to fetch API dots.
  </div>` : ""}
  ${allEntries.length >= 5 ? `
  <button id="gg-log-apply" class="gg-btn" style="margin-top:8px;width:100%;background:#182018;border-color:#2a4a2a;color:#7abf7a;font-size:11px">
    Apply ${cal.dots} dots to calculator
  </button>` : `<div style="font-size:10px;color:#445;margin-top:4px">Log ${5 - allEntries.length} more jump(s) to enable Apply.</div>`}
</div>`;

        const applyBtn = q("#gg-log-apply");
        if (applyBtn) {
            applyBtn.addEventListener("click", () => {
                // Find and update the current gym in GYMS with calibrated dots
                const gymId  = parseInt(el.gym.value);
                const stat   = el.stat.value;
                const statKey = STAT_KEYS[stat];
                const gymIdx = GYMS.findIndex(g => g.id === gymId);
                if (gymIdx >= 0 && statKey) {
                    GYMS[gymIdx] = { ...GYMS[gymIdx], [statKey]: cal.dots };
                    buildGymMaps();
                    updateDotsDisplay();
                    updateBestGymPanel();
                    showStatus("ok", `✓ Applied calibrated dots (${cal.dots}) to ${GYMS[gymIdx].name} ${STAT_LABELS[stat]}.`);
                }
            });
        }
    }

    // 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' : '#555';
            lbl.style.color = autoLogEnabled ? '#7abf7a' : '#556';
            lbl.textContent = autoLogEnabled
                ? 'Auto-log: ON — watches Train button for jump sessions'
                : 'Auto-log: OFF — manual logging only';
        };
        q("#gg-autolog-lbl")?.closest('div')?.addEventListener('click', () => {
            autoLogEnabled = !autoLogEnabled;
            _save('autoLogEnabled', autoLogEnabled);
            updateAutoLogUI();
            // Expose to the result observer
            window._ggAutoLogEnabled = autoLogEnabled;
        });
        window._ggAutoLogEnabled = autoLogEnabled;
        updateAutoLogUI();

        const addBtn    = q("#gg-log-add");
        const exportBtn = q("#gg-log-export");
        const clearBtn  = q("#gg-log-clear");
        const panel     = q("#gg-logger-panel");

        if (!addBtn) return;

        // Toggle panel
        panel.querySelector(".gg-collapsible-header").addEventListener("click", () => {
            panel.classList.toggle("open");
            renderLogger();
        });

        addBtn.addEventListener("click", () => {
            const gain        = parseFloat(q("#gg-log-gain")?.value) || 0;
            const preStat     = parseFloat(q("#gg-log-stat")?.value) || 0;
            const jumpHappy   = parseFloat(q("#gg-log-happy")?.value) || 0;
            const stackTrains  = 1;   // single-train jump (999E in one click)
            const refillTrains = 0;   // unknown for manual entries

            if (!gain || !preStat || !jumpHappy) {
                showStatus("warn", "⚠ Enter actual gain, pre-jump stat, and jump happy to log.");
                return;
            }

            const gym      = getGymData();
            const stat     = el.stat.value;
            const statKey  = STAT_KEYS[stat];
            const gymDots  = gym?.[statKey] || 0;
            const propHappy= gf(el.happy) || 5025;
            const ePerTrain= gym?.energy || 10;
            const bonus    = calcBonus(stat);

            if (!gymDots) {
                showStatus("warn", "⚠ Gym doesn't train this stat — select a valid gym/stat combo.");
                return;
            }

            const entry = {
                ts: Date.now(),
                stat, gymId: gym?.id, gymName: gym?.name, gymDots,
                preStat, jumpHappy, actualGain: gain,
                stackTrains, refillTrains, propHappy, ePerTrain, bonus,
            };

            const entries = logPrune(logLoad());
            entries.push(entry);
            logSave(entries);

            // Clear inputs
            q("#gg-log-gain").value   = "";
            q("#gg-log-stat").value   = "";
            q("#gg-log-happy").value  = "";

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

        exportBtn.addEventListener("click", () => {
            const entries = logPrune(logLoad());
            if (!entries.length) { showStatus("warn", "⚠ No entries to export."); return; }

            const header = "date,stat,gym,gymDots,preStat,jumpHappy,actualGain,energyUsed,propHappy,ePerTrain,bonus,predictedGain,errorPct";
            const rows = entries.map(e => {
                const predicted = predictJump(e, e.gymDots);
                const errPct    = ((e.actualGain - predicted) / predicted * 100).toFixed(2);
                const date      = new Date(e.ts).toISOString().split("T")[0];
                const ePerTrain = e.ePerTrain || 10;
                let energy = e.energyUsed;
                if (!energy) {
                    if (e.stackTrains >= 50)      energy = e.stackTrains * ePerTrain;
                    else if (e.stackTrains === 1) energy = 1000;
                    else                          energy = ePerTrain;
                }
                return [date, e.stat, e.gymName, e.gymDots, e.preStat, e.jumpHappy,
                        e.actualGain, energy, e.propHappy,
                        e.ePerTrain, e.bonus.toFixed(4), predicted.toFixed(1), errPct].join(",");
            });
            const csv = [header, ...rows].join("\n");

            // Try clipboard first (works on some mobile browsers)
            const doCopy = () => navigator.clipboard?.writeText(csv)
                .then(() => showStatus("ok", `✓ ${entries.length} entries copied to clipboard — paste into Notes or a message.`))
                .catch(() => showModal());

            // Fallback: show text in a selectable modal — always works in TornPDA
            const showModal = () => {
                const overlay = document.createElement('div');
                overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:999999;display:flex;align-items:center;justify-content:center;padding:16px';
                overlay.innerHTML = `
                    <div style="background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:16px;width:100%;max-width:500px;max-height:80vh;display:flex;flex-direction:column;gap:10px">
                        <div style="color:#7abf7a;font-size:13px;font-weight:bold">📋 Jump Log CSV — Select All &amp; Copy</div>
                        <div style="font-size:11px;color:#556">Long-press the text below → Select All → Copy</div>
                        <textarea readonly style="flex:1;min-height:200px;background:#111;color:#8a8;border:1px solid #2a4a2a;border-radius:4px;padding:8px;font-size:10px;font-family:monospace;resize:none">${csv}</textarea>
                        <button id="gg-export-close" style="background:#181818;border:1px solid #333;color:#888;padding:8px;border-radius:4px;font-size:13px">Close</button>
                    </div>`;
                document.body.appendChild(overlay);
                // Auto-select textarea
                const ta = overlay.querySelector('textarea');
                setTimeout(() => { ta.focus(); ta.select(); }, 100);
                overlay.querySelector('#gg-export-close').addEventListener('click', () => overlay.remove());
                overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
            };

            // Try clipboard, fall back to modal
            if (navigator.clipboard) {
                doCopy();
            } else {
                showModal();
            }
        });

        clearBtn.addEventListener("click", () => {
            if (!confirm("Clear all logged jumps?")) return;
            logSave([]);
            renderLogger();
            showStatus("ok", "✓ Log cleared.");
        });

        // Pre-fill stat from current inputs when panel opens
        panel.querySelector(".gg-collapsible-header").addEventListener("click", () => {
            const currentStat = gf(el.statTotal);
            if (currentStat > 0 && !q("#gg-log-stat").value) {
                q("#gg-log-stat").value = currentStat;
            }
            const gym     = getGymData();
            const stat    = el.stat.value;
            const statKey = STAT_KEYS[stat];
            if (gym && statKey) {
                const edvd   = parseInt(el.hjEDVDs?.value) || 0;
                const anJob  = el.hjANJob?.value === "yes";
                const base   = gf(el.hjBaseHappy) || gf(el.happy) || 5025;
                const perDvd = anJob ? 5000 : 2500;
                const xtc    = el.hjEcstasy?.value === "yes";
                const jumpH  = Math.min((base + edvd * perDvd) * (xtc ? 2 : 1), 99999);
                if (!q("#gg-log-happy").value) q("#gg-log-happy").value = jumpH;
            }
        }, { once:false });

        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 = [
            "AIO v2.8 " + 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), "Mode:" + mode,
            "---", ...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.");
    });

    } // end gymModule
    function jobModule() {

    // ─────────────────────────────────────────────────────────────────────────
    // JOB DATA
    // ─────────────────────────────────────────────────────────────────────────
    const JOBS = {
        army: {
            label:"Army", topRankPerk:"Spy battle stats once/day (10 pts + $5,000)",
            benefits:[],
            ranks:[
                {name:"Private",         req:{man:2,    int:2,    end:2    },gain:{man:3, int:1, end:2 },jpPerDay:1, jpNeeded:5,   perks:[]},
                {name:"Corporal",        req:{man:50,   int:15,   end:20   },gain:{man:5, int:2, end:3 },jpPerDay:2, jpNeeded:10,  perks:[]},
                {name:"Sergeant",        req:{man:120,  int:35,   end:50   },gain:{man:8, int:3, end:5 },jpPerDay:3, jpNeeded:15,  perks:[]},
                {name:"Master Sergeant", req:{man:325,  int:60,   end:115  },gain:{man:12,int:4, end:7 },jpPerDay:4, jpNeeded:20,  perks:[]},
                {name:"Warrant Officer", req:{man:700,  int:160,  end:300  },gain:{man:17,int:7, end:10},jpPerDay:5, jpNeeded:25,  perks:[]},
                {name:"Lieutenant",      req:{man:1300, int:360,  end:595  },gain:{man:20,int:9, end:11},jpPerDay:6, jpNeeded:30,  perks:[]},
                {name:"Major",           req:{man:2550, int:490,  end:900  },gain:{man:24,int:10,end:13},jpPerDay:7, jpNeeded:35,  perks:[]},
                {name:"Colonel",         req:{man:4150, int:600,  end:1100 },gain:{man:28,int:12,end:15},jpPerDay:8, jpNeeded:40,  perks:[]},
                {name:"Brigadier",       req:{man:7500, int:1350, end:2530 },gain:{man:33,int:18,end:15},jpPerDay:9, jpNeeded:45,  perks:[]},
                {name:"General",         req:{man:10000,int:2000, end:4000 },gain:{man:40,int:25,end:20},jpPerDay:10,jpNeeded:null,perks:["⭐ Spy battle stats once/day — active while in Army"]},
            ]
        },
        grocer: {
            label:"Grocer", topRankPerk:"Steal Energy Drink (25 pts — worth ~$3–5M each)",
            benefits:[],
            ranks:[
                {name:"Bag Boy",       req:{man:2,  int:2,  end:2  },gain:{man:2, int:1, end:3 },jpPerDay:1,jpNeeded:5,  perks:[]},
                {name:"Price Labeller",req:{man:30, int:15, end:50 },gain:{man:3, int:2, end:5 },jpPerDay:2,jpNeeded:10, perks:[]},
                {name:"Cashier",       req:{man:50, int:35, end:120},gain:{man:5, int:3, end:8 },jpPerDay:3,jpNeeded:15, perks:[]},
                {name:"Food Delivery", req:{man:120,int:60, end:225},gain:{man:10,int:5, end:15},jpPerDay:4,jpNeeded:20, perks:[]},
                {name:"Manager",       req:{man:250,int:200,end:500},gain:{man:15,int:10,end:20},jpPerDay:5,jpNeeded:null,perks:["⭐ Steal Energy Drink (~$3–5M each, 25 pts) — active while in Grocer"]},
            ]
        },
        casino: {
            label:"Casino", topRankPerk:"Money payout ~$120–160k per use (10 pts + $100k base)",
            benefits:[],
            ranks:[
                {name:"Dealer",           req:{man:2,  int:2,   end:2   },gain:{man:1, int:2, end:3 },jpPerDay:1,jpNeeded:5,  perks:[]},
                {name:"Gaming Consultant",req:{man:35, int:50,  end:120 },gain:{man:2, int:3, end:5 },jpPerDay:2,jpNeeded:10, perks:[]},
                {name:"Marketing Manager",req:{man:60, int:115, end:325 },gain:{man:4, int:7, end:12},jpPerDay:3,jpNeeded:15, perks:[]},
                {name:"Revenue Manager",  req:{man:360,int:595, end:1300},gain:{man:9, int:11,end:20},jpPerDay:4,jpNeeded:20, perks:[]},
                {name:"Casino Manager",   req:{man:490,int:900, end:2550},gain:{man:10,int:13,end:24},jpPerDay:5,jpNeeded:25, perks:[]},
                {name:"Casino President", req:{man:755,int:1100,end:4150},gain:{man:12,int:15,end:28},jpPerDay:6,jpNeeded:null,perks:["⭐ Count Cards ~$120–160k payout — active while in Casino"]},
            ]
        },
        medical: {
            label:"Medical", topRankPerk:"Revive players for 75 energy — permanent passive, earn $500k–$1M+ per revive",
            benefits:[],
            ranks:[
                {name:"Medical Student",req:{man:0,   int:300,  end:0   },gain:{man:4, int:12,end:7 },jpPerDay:1,jpNeeded:5,  perks:[]},
                {name:"Houseman",       req:{man:100, int:600,  end:150 },gain:{man:7, int:17,end:10},jpPerDay:2,jpNeeded:10, perks:[]},
                {name:"Senior Houseman",req:{man:175, int:1000, end:275 },gain:{man:9, int:20,end:11},jpPerDay:3,jpNeeded:15, perks:[]},
                {name:"GP",             req:{man:300, int:1500, end:500 },gain:{man:10,int:24,end:13},jpPerDay:4,jpNeeded:20, perks:[]},
                {name:"Consultant",     req:{man:600, int:2500, end:1000},gain:{man:12,int:28,end:15},jpPerDay:5,jpNeeded:25, perks:[]},
                {name:"Surgeon",        req:{man:1300,int:5000, end:2000},gain:{man:18,int:33,end:15},jpPerDay:6,jpNeeded:30, perks:[]},
                {name:"Brain Surgeon",  req:{man:2600,int:10000,end:4000},gain:{man:20,int:40,end:25},jpPerDay:7,jpNeeded:null,perks:["⭐ Revive players for 75 energy — permanent passive, earns $500k–$1M+ per revive"]},
            ]
        },
        education: {
            label:"Education", topRankPerk:"10% reduction in all education course completion times — permanent passive",
            benefits:[
                {statKey:"man",unlockedAtRank:0},
                {statKey:"end",unlockedAtRank:2},
                {statKey:"int",unlockedAtRank:4},
            ],
            ranks:[
                {name:"Recess Supervisor", req:{man:0,   int:500, end:0   },gain:{man:8, int:10,end:9 },jpPerDay:1,jpNeeded:5,  perks:[]},
                {name:"Substitute Teacher",req:{man:300, int:750, end:500 },gain:{man:13,int:15,end:14},jpPerDay:2,jpNeeded:10, perks:[]},
                {name:"Elementary Teacher",req:{man:600, int:1000,end:700 },gain:{man:15,int:20,end:17},jpPerDay:3,jpNeeded:15, perks:[]},
                {name:"Secondary Teacher", req:{man:1000,int:1300,end:1000},gain:{man:20,int:25,end:20},jpPerDay:4,jpNeeded:20, perks:[]},
                {name:"Professor",         req:{man:1500,int:2000,end:1500},gain:{man:25,int:30,end:25},jpPerDay:5,jpNeeded:25, perks:[]},
                {name:"Vice Principal",    req:{man:1500,int:3000,end:1500},gain:{man:30,int:35,end:30},jpPerDay:6,jpNeeded:30, perks:[]},
                {name:"Principal",         req:{man:1500,int:5000,end:1500},gain:{man:30,int:40,end:30},jpPerDay:7,jpNeeded:null,perks:["⭐ 10% reduction in all education course times — permanent passive"]},
            ]
        },
        law: {
            label:"Law", topRankPerk:"+5% crime experience & skill progression — permanent passive",
            benefits:[],
            ranks:[
                {name:"Law Student",        req:{man:0,   int:0,   end:1500 },gain:{man:15,int:15,end:20},jpPerDay:1,jpNeeded:5,  perks:[]},
                {name:"Paralegal",          req:{man:1750,int:2500,end:5000 },gain:{man:17,int:20,end:23},jpPerDay:2,jpNeeded:10, perks:[]},
                {name:"Probate Lawyer",     req:{man:2500,int:5000,end:7500 },gain:{man:19,int:23,end:30},jpPerDay:3,jpNeeded:15, perks:[]},
                {name:"Trial Lawyer",       req:{man:3500,int:6500,end:7750 },gain:{man:25,int:27,end:35},jpPerDay:4,jpNeeded:20, perks:[]},
                {name:"Circuit Court Judge",req:{man:4000,int:7250,end:10000},gain:{man:27,int:30,end:38},jpPerDay:5,jpNeeded:25, perks:[]},
                {name:"Federal Judge",      req:{man:6000,int:9000,end:15000},gain:{man:30,int:33,end:45},jpPerDay:6,jpNeeded:null,perks:["⭐ +5% crime experience & skill progression — permanent passive"]},
            ]
        }
    };

    // ─────────────────────────────────────────────────────────────────────────
    // JOB DETECTION
    // ─────────────────────────────────────────────────────────────────────────
    const JOB_KEYWORDS = {
        education:"education",educational:"education","education system":"education",
        army:"army",grocer:"grocer",grocery:"grocer","grocery store":"grocer",
        casino:"casino",medical:"medical","medical system":"medical",law:"law",
    };

    function detectJob() {
        const text = sel => document.querySelector(sel)?.textContent.toLowerCase() ?? "";
        const match = t => {
            for (const [k,v] of Object.entries(JOB_KEYWORDS))
                if (k.length >= 3 && t.includes(k)) return v;
            return null;
        };
        const ptWord = text('.points-text').trim().split(/\s+/)[0];
        if (JOB_KEYWORDS[ptWord]) return JOB_KEYWORDS[ptWord];
        const msgT = text('.info-msg-cont:not(.red) .msg');
        const m1 = msgT.match(/\b(\w+)\s+points\b/);
        if (m1 && JOB_KEYWORDS[m1[1]]) return JOB_KEYWORDS[m1[1]];
        const m2 = msgT.match(/work in the\s+([\w ]+?)(?:\s+system|[.,\n]|$)/);
        if (m2) { const k=m2[1].trim(); if (JOB_KEYWORDS[k]) return JOB_KEYWORDS[k]; if (JOB_KEYWORDS[k.split(' ')[0]]) return JOB_KEYWORDS[k.split(' ')[0]]; }
        for (const e of document.querySelectorAll('.msg')) { const r=match(e.textContent.toLowerCase()); if (r) return r; }
        const bm = document.body.innerText.toLowerCase().match(/work in the\s+([\w ]+?)(?:\s+system|\n|\.)/);
        if (bm) { const k=bm[1].trim(); return JOB_KEYWORDS[k]||JOB_KEYWORDS[k.split(' ')[0]]||null; }
        return null;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // PERSISTENCE
    // ─────────────────────────────────────────────────────────────────────────
    const JT_DEFAULTS = {selectedJob:"education",currentRank:"0",currentJP:"5",manStat:"0",intStat:"0",endStat:"0",collapsed:"no"};
    const jt_load = k => _load(k, JT_DEFAULTS[k]);
    const jt_save = (k, v) => _save(k, v);

    // ─────────────────────────────────────────────────────────────────────────
    // CSS
    // ─────────────────────────────────────────────────────────────────────────
    const STYLES = `
.jt-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}
.jt-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}
.jt-header:hover{background:linear-gradient(135deg,#2c2c2c,#222)}
.jt-title{font-size:15px;font-weight:bold;color:#e0e0e0}
.jt-toggle{font-size:16px;color:#555;transition:transform .2s}
.jt-wrap.open .jt-toggle{transform:rotate(180deg)}
.jt-body{display:none;padding:12px}
.jt-wrap.open .jt-body{display:block}
.jt-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}
.jt-sec:first-child{margin-top:0}
.jt-field{margin-bottom:8px}
.jt-field label{display:block;font-size:12px;color:#888;margin-bottom:3px}
.jt-field select,.jt-field input[type=number]{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}
.jt-field select:focus,.jt-field input:focus{outline:none;border-color:#555;background:#282828}
.jt-sg{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.jt-sg .jt-field{margin-bottom:0}
.jt-sg .jt-field label{font-size:11px}
.jt-btn-row{display:flex;gap:8px;margin-top:12px}
.jt-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}
.jt-btn:hover,.jt-btn:active{background:#2a2a2a}
.jt-btn-fill{background:#1a2518;border-color:#3a5030;color:#7abf7a}
.jt-btn-fill:hover,.jt-btn-fill:active{background:#20301e}
.jt-btn-calc{background:#18182a;border-color:#303058;color:#7a7acc}
.jt-btn-calc:hover,.jt-btn-calc:active{background:#20203a}
.jt-status{display:none;margin-top:8px;padding:8px 10px;border-radius:4px;font-size:12px;line-height:1.5;word-break:break-word}
.jt-status.ok{display:block;background:#182018;border:1px solid #2a4a2a;color:#7abf7a}
.jt-status.err{display:block;background:#201818;border:1px solid #4a2828;color:#bf7a7a}
.jt-status.warn{display:block;background:#201e10;border:1px solid #4a3a18;color:#bf9f5a}
.jt-row{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 10px;margin-top:3px;border-radius:4px;background:#1e1e1e}
.jt-row .jt-rl{color:#888;font-size:13px;flex-shrink:0}
.jt-row .jt-rv{color:#ddd;font-size:13px;text-align:right;word-break:break-word}
.jt-row.g{background:#18201a}.jt-row.g .jt-rv{color:#7abf7a}
.jt-row.r{background:#201818}.jt-row.r .jt-rv{color:#bf7a7a}
.jt-row.a{background:#201e10}.jt-row.a .jt-rv{color:#bf9f5a}
.jt-row.b{background:#181828}.jt-row.b .jt-rv{color:#7a7acc}
.jt-perk{margin-top:8px;padding:10px 12px;background:#182018;border:1px solid #2a4a2a;border-radius:4px;font-size:13px;color:#7abf7a;line-height:1.5}
.jt-perk.locked{background:#201818;border-color:#4a2828;color:#bf7a7a}
.jt-rec{display:flex;gap:8px;padding:8px 10px;margin-top:4px;background:#1c1a14;border:1px solid #302810;border-radius:4px;font-size:12px;color:#c8b060;line-height:1.5}
.jt-rec-num{flex-shrink:0;font-weight:bold;color:#a08040;font-size:13px;min-width:16px}
.jt-rec.urgent{background:#201818;border-color:#503020;color:#d08050}
.jt-rec.urgent .jt-rec-num{color:#b06030}
.pk{display:inline-block;border-radius:3px;padding:1px 5px;font-size:10px;margin-left:3px}
.pk.ok{background:#1a3a1a;color:#7abf7a}
.pk.no{background:#3a1a1a;color:#bf6060}
.jt-summary{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:4px;margin-bottom:8px}
.jt-summary-text{font-size:12px;color:#aaa;line-height:1.5;flex:1}
.jt-summary-text strong{color:#ddd}
.jt-edit-btn{flex-shrink:0;padding:4px 10px;border-radius:3px;border:1px solid #383838;background:#252525;color:#888;font-size:11px;cursor:pointer;-webkit-tap-highlight-color:transparent}
.jt-edit-btn:hover,.jt-edit-btn:active{background:#2e2e2e;color:#bbb}
.jt-inputs{display:none}
.jt-inputs.expanded{display:block}
.jt-planner{margin-top:14px;padding:10px 12px;background:#14181e;border:1px solid #2a3040;border-radius:4px}
.jt-planner-header{display:flex;align-items:center;justify-content:space-between;cursor:pointer;-webkit-tap-highlight-color:transparent}
.jt-planner-title{font-size:12px;font-weight:bold;color:#6a8aaa;letter-spacing:.04em;text-transform:uppercase}
.jt-planner-toggle{font-size:13px;color:#445;transition:transform .2s}
.jt-planner.open .jt-planner-toggle{transform:rotate(180deg)}
.jt-planner-body{display:none;margin-top:10px}
.jt-planner.open .jt-planner-body{display:block}
.jt-prow{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:5px 8px;margin-top:3px;border-radius:3px;background:#181c24}
.jt-prow .jt-rl{color:#667;font-size:12px;flex-shrink:0}
.jt-prow .jt-rv{color:#aac;font-size:12px;text-align:right}
.jt-prow.pg .jt-rv{color:#7abf7a}
.jt-prow.pr .jt-rv{color:#bf7a7a}
.jt-prow.pa .jt-rv{color:#bf9f5a}
.jt-prow.pb .jt-rv{color:#7a7acc}
.jt-pstat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:6px}
.jt-pstat{background:#181c24;border:1px solid #222a38;border-radius:3px;padding:6px 8px;font-size:12px}
.jt-pstat-label{color:#556;font-size:10px;text-transform:uppercase;letter-spacing:.05em}
.jt-pstat-val{color:#aac;font-size:13px;font-weight:bold;margin-top:1px}
.jt-pstat-val.ok{color:#7abf7a}
.jt-pstat-days{font-size:10px;color:#445;margin-top:1px}
.jt-plan-summary{background:#0e1a28;border:1px solid #2a4a6a;border-radius:5px;padding:12px 14px;margin-top:10px;margin-bottom:4px}
.jt-plan-summary-title{font-size:10px;font-weight:bold;color:#4a7aaa;letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px}
.jt-plan-step{display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #1a2a3a;align-items:flex-start}
.jt-plan-step:last-child{border-bottom:none;padding-bottom:0}
.jt-plan-num{flex-shrink:0;width:20px;height:20px;background:#1a3a5a;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:bold;color:#5a9acc;margin-top:1px}
.jt-plan-step-text{font-size:13px;color:#bbd;line-height:1.5}
.jt-plan-step-text strong{color:#ddeeff}
.jt-plan-step-text .stat-pill{display:inline-block;background:#1a2a3a;border:1px solid #2a4a6a;border-radius:3px;padding:1px 6px;font-size:11px;font-weight:bold;color:#88bbdd;margin:0 2px}
.jt-perk-line{padding:2px 8px 5px;font-size:11px;color:#5a7a3a}`;

    // ─────────────────────────────────────────────────────────────────────────
    // HTML
    // ─────────────────────────────────────────────────────────────────────────
    function buildHTML() {
        const opts = (filter=null) => Object.entries(JOBS)
            .filter(([k]) => !filter || k !== filter)
            .map(([k,v]) => `<option value="${k}">${v.label}</option>`).join('');
        return `
<div class="jt-header"><span class="jt-title">📋 Job Planner</span><span class="jt-toggle">▼</span></div>
<div class="jt-body">
<div class="jt-summary" id="jt-summary" style="display:none">
  <span class="jt-summary-text" id="jt-summary-text">—</span>
  <button class="jt-edit-btn" id="jt-edit-btn">✏ Edit</button>
</div>
<div class="jt-inputs expanded" id="jt-inputs">
  <div class="jt-sec">Job &amp; Rank</div>
  <div class="jt-field"><label>Job</label><select id="jt-job">${opts()}</select></div>
  <div class="jt-field"><label>Current Rank</label><select id="jt-rank"></select></div>
  <div class="jt-field"><label>Job Points (accumulated, not spent)</label><input type="number" id="jt-jp" min="0"></div>
  <div class="jt-sec">Working Stats</div>
  <div class="jt-sg">
    <div class="jt-field"><label>Manual</label><input type="number" id="jt-man" min="0"></div>
    <div class="jt-field"><label>Intelligence</label><input type="number" id="jt-int" min="0"></div>
    <div class="jt-field"><label>Endurance</label><input type="number" id="jt-end" min="0"></div>
  </div>
</div>
<div class="jt-btn-row">
  <button class="jt-btn jt-btn-fill" id="jt-autofill">⟳ Auto-fill</button>
  <button class="jt-btn jt-btn-calc" id="jt-calc">Calculate →</button>
</div>
<div class="jt-status" id="jt-status"></div>
<div id="jt-results"></div>
<div class="jt-planner" id="jt-planner">
  <div class="jt-planner-header" id="jt-planner-header">
    <span class="jt-planner-title">🎯 Switch Job Planner</span>
    <span class="jt-planner-toggle">▼</span>
  </div>
  <div class="jt-planner-body">
    <div class="jt-field"><label>Target Job</label><select id="jt-plan-job">${opts("education")}</select></div>
    <div class="jt-field"><label>Target Rank</label><select id="jt-plan-rank"></select></div>
    <button class="jt-btn jt-btn-calc" id="jt-plan-calc" style="margin-top:8px">Calculate Plan →</button>
    <div id="jt-plan-results"></div>
  </div>
</div>
</div>`;
    }

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

    const wrap = document.createElement("div");
    wrap.className = "jt-wrap";
    wrap.innerHTML = buildHTML();
    const mountTarget = document.querySelector('.content-wrapper') || document.body;
    mountTarget.insertBefore(wrap, mountTarget.firstChild);

    const q = sel => wrap.querySelector(sel);
    const el = {
        header:     q(".jt-header"),   summary:    q("#jt-summary"),
        sumText:    q("#jt-summary-text"), editBtn: q("#jt-edit-btn"),
        inputs:     q("#jt-inputs"),   job:        q("#jt-job"),
        rank:       q("#jt-rank"),     jp:         q("#jt-jp"),
        man:        q("#jt-man"),      int:        q("#jt-int"),
        end:        q("#jt-end"),      calcBtn:    q("#jt-calc"),
        fillBtn:    q("#jt-autofill"), status:     q("#jt-status"),
        results:    q("#jt-results"),  planner:    q("#jt-planner"),
        planHdr:    q("#jt-planner-header"), planJob: q("#jt-plan-job"),
        planRank:   q("#jt-plan-rank"), planCalc:  q("#jt-plan-calc"),
        planOut:    q("#jt-plan-results"),
    };

    // ─────────────────────────────────────────────────────────────────────────
    // HELPERS
    // ─────────────────────────────────────────────────────────────────────────
    const iV      = e => parseInt(e.value) || 0;
    const fmt     = n => Math.round(n).toLocaleString();
    const met     = (req,m,i,e) => m>=req.man && i>=req.int && e>=req.end;
    const dateIn  = n => { const d=new Date(); d.setDate(d.getDate()+n); return d.toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'}); };
    const row     = (l,v,c="") => `<div class="jt-row ${c}"><span class="jt-rl">${l}</span><span class="jt-rv">${v}</span></div>`;
    const sec     = l => `<div class="jt-sec" style="margin-top:14px">${l}</div>`;
    const recEl   = (n,t,u=false) => `<div class="jt-rec${u?" urgent":""}"><span class="jt-rec-num">${n}</span><span>${t}</span></div>`;
    const pill    = (h,n) => h>=n ? `<span class="pk ok">${fmt(h)}</span>` : `${fmt(h)}<span class="pk no">-${fmt(n-h)}</span>`;
    const rq      = v => v > 0 ? fmt(v) : "—";
    const prow    = (l,v,c="") => `<div class="jt-prow ${c}"><span class="jt-rl">${l}</span><span class="jt-rv">${v}</span></div>`;
    const psec    = l => `<div class="jt-sec" style="margin-top:10px;font-size:10px">${l}</div>`;
    const spill   = s => `<span class="stat-pill">${s}</span>`;
    const snum    = n => `<div class="jt-plan-num">${n}</div>`;

    // ─────────────────────────────────────────────────────────────────────────
    // RANK DROPDOWNS
    // ─────────────────────────────────────────────────────────────────────────
    function populateRanks(jobKey, idx) {
        el.rank.innerHTML = '';
        JOBS[jobKey].ranks.forEach((r,i) => {
            const o = document.createElement("option");
            o.value = i; o.textContent = `${i+1}. ${r.name}`;
            el.rank.appendChild(o);
        });
        el.rank.value = String(idx ?? 0);
    }

    function populatePlanRanks(jobKey) {
        el.planRank.innerHTML = '';
        JOBS[jobKey].ranks.forEach((r,i) => {
            const o = document.createElement("option");
            o.value = i; o.textContent = `${i+1}. ${r.name}`;
            el.planRank.appendChild(o);
        });
        el.planRank.value = String(JOBS[jobKey].ranks.length - 1);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // SUMMARY BAR & PLANNER VISIBILITY
    // ─────────────────────────────────────────────────────────────────────────
    function showSummary(jobKey, ri, jp, man, int_, end) {
        const rank = JOBS[jobKey].ranks[ri];
        el.sumText.innerHTML = `<strong>${JOBS[jobKey].label}</strong> · ${rank.name} · <strong>${jp} JP</strong><br>MAN <strong>${fmt(man)}</strong> · INT <strong>${fmt(int_)}</strong> · END <strong>${fmt(end)}</strong>`;
        el.summary.style.display = "";
        el.inputs.classList.remove("expanded");
    }
    const hideSummary = () => { el.summary.style.display="none"; el.inputs.classList.add("expanded"); };
    const updatePlanner = () => { el.planner.style.display = el.job.value==="education" ? "" : "none"; };

    // ─────────────────────────────────────────────────────────────────────────
    // RESTORE & WIRE
    // ─────────────────────────────────────────────────────────────────────────
    el.job.value = jt_load("selectedJob");
    populateRanks(el.job.value, jt_load("currentRank"));
    el.jp.value  = jt_load("currentJP");
    el.man.value = jt_load("manStat");
    el.int.value = jt_load("intStat");
    el.end.value = jt_load("endStat");
    populatePlanRanks(el.planJob.value || "army");
    updatePlanner();
    if (jt_load("collapsed") !== "yes") wrap.classList.add("open");

    el.editBtn.addEventListener("click", hideSummary);
    el.header.addEventListener("click", () => { const o=wrap.classList.toggle("open"); jt_save("collapsed", o?"no":"yes"); });
    el.job.addEventListener("change", () => {
        jt_save("selectedJob", el.job.value);
        populateRanks(el.job.value, 0); jt_save("currentRank","0");
        updatePlanner(); hideSummary();
    });
    el.rank.addEventListener("change", () => jt_save("currentRank", el.rank.value));
    el.jp.addEventListener("input",  () => jt_save("currentJP",  el.jp.value));
    el.man.addEventListener("input", () => jt_save("manStat",    el.man.value));
    el.int.addEventListener("input", () => jt_save("intStat",    el.int.value));
    el.end.addEventListener("input", () => jt_save("endStat",    el.end.value));
    el.planJob.addEventListener("change", () => populatePlanRanks(el.planJob.value));
    el.planHdr.addEventListener("click", () => el.planner.classList.toggle("open"));
    el.fillBtn.addEventListener("click", autofill);
    el.calcBtn.addEventListener("click", calculate);
    el.planCalc.addEventListener("click", calculatePlan);

    // ─────────────────────────────────────────────────────────────────────────
    // AUTO-FILL
    // ─────────────────────────────────────────────────────────────────────────
    function autofill() {
        const num  = s => s ? parseInt(s.replace(/,/g,''),10)||0 : null;
        const text = sel => { const e=document.querySelector(sel); return e ? e.textContent.trim() : null; };
        const errs=[], filled=[];

        const job = detectJob();
        if (job) { el.job.value=job; jt_save("selectedJob",job); filled.push(JOBS[job].label); }
        else errs.push("Job not detected");

        let ri = 0;
        const rankName = text('.jrank');
        if (rankName && job) {
            // Normalize: collapse hyphens/dashes to spaces for fuzzy matching
            const norm = s => s.toLowerCase().replace(/[-–—]/g,' ').replace(/\s+/g,' ').trim();
            ri = JOBS[job].ranks.findIndex(r => norm(r.name) === norm(rankName));
            if (ri >= 0) { populateRanks(job,ri); jt_save("currentRank",String(ri)); filled.push(rankName); }
            else { errs.push(`Rank "${rankName}" not recognised`); ri=0; populateRanks(job,0); }
        } else if (!rankName) { errs.push("Rank not found"); if (job) populateRanks(job,0); }

        const jpRaw = text('.jpoints');
        if (jpRaw !== null) { const v=num(jpRaw); el.jp.value=v; jt_save("currentJP",String(v)); filled.push(`${v} JP`); }
        else errs.push("JP not found");

        [['.jmanLabor',el.man,"manStat","MAN"],['.jintelligence',el.int,"intStat","INT"],['.jendurance',el.end,"endStat","END"]]
        .forEach(([sel,inp,key,lbl]) => {
            const raw=text(sel);
            if (raw!==null) { const v=num(raw); inp.value=v; jt_save(key,String(v)); filled.push(`${lbl} ${fmt(v)}`); }
            else errs.push(`${lbl} not found`);
        });

        updatePlanner();
        if (!errs.length) {
            el.status.className="jt-status ok"
        cache.set('workstats', {
            man: parseInt(jt_load('manStat')) || 0,
            int: parseInt(jt_load('intStat')) || 0,
            end: parseInt(jt_load('endStat')) || 0,
        });; el.status.textContent="✓ "+filled.join(" · ");
            showSummary(el.job.value, parseInt(el.rank.value)||0, iV(el.jp), iV(el.man), iV(el.int), iV(el.end));
        } else if (filled.length) {
            el.status.className="jt-status warn"; el.status.textContent="⚠ Partial: "+filled.join(", ")+" — "+errs.join("; ");
        } else {
            el.status.className="jt-status err"; el.status.textContent="✗ "+errs.join(" | ");
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // SIMULATION  — Education auto-uses all unlocked JP specials each rank,
    // spending surplus on the biggest bottleneck for the next rank.
    // ─────────────────────────────────────────────────────────────────────────
    function simulate(jobKey, startRi, startJP, startMan, startInt, startEnd) {
        const ranks=JOBS[jobKey].ranks, isEdu=jobKey==="education";
        let man=startMan, int_=startInt, end=startEnd, jp=startJP, ri=startRi;
        const results=[{rankIdx:ri, rankName:ranks[ri].name, dayReached:0}];

        for (let day=1; day<=3650; day++) {
            const r=ranks[ri];
            jp+=r.jpPerDay; man+=r.gain.man; int_+=r.gain.int; end+=r.gain.end;

            if (isEdu) {
                const boostable=new Set(JOBS.education.benefits.filter(b=>ri>=b.unlockedAtRank).map(b=>b.statKey));
                if (boostable.size) {
                    const nReq=r.jpNeeded!==null?ranks[ri+1].req:{man:0,int:0,end:0};
                    let surplus=Math.max(0,jp-(r.jpNeeded??0));
                    while (surplus>=10) {
                        let best=null, bs=-1;
                        for (const sk of boostable) {
                            const have=sk==="man"?man:sk==="int"?int_:end, need=sk==="man"?nReq.man:sk==="int"?nReq.int:nReq.end;
                            const gain=sk==="man"?r.gain.man:sk==="int"?r.gain.int:r.gain.end;
                            const score=gain>0?Math.max(0,need-have)/gain:(need>have?9999:0);
                            if (score>bs){bs=score;best=sk;}
                        }
                        if (!best||bs===0) break;
                        jp-=10; surplus-=10;
                        if (best==="man") man+=100; else if (best==="int") int_+=100; else end+=100;
                    }
                }
            }

            if (r.jpNeeded!==null && jp>=r.jpNeeded) {
                const next=ranks[ri+1];
                if (met(next.req,man,int_,end)) {
                    jp-=r.jpNeeded; ri++;
                    results.push({rankIdx:ri, rankName:ranks[ri].name, dayReached:day});
                    if (ri===ranks.length-1) break;
                }
            }
        }
        return results;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // CALCULATE
    // ─────────────────────────────────────────────────────────────────────────
    function calculate() {
        const jobKey=el.job.value, ri=parseInt(el.rank.value)||0;
        const jp=iV(el.jp), man=iV(el.man), int_=iV(el.int), end=iV(el.end);
        const job=JOBS[jobKey], ranks=job.ranks, cur=ranks[ri], total=ranks.length;
        const sim=simulate(jobKey,ri,jp,man,int_,end);
        let html="";

        html+=sec("Current Rank");
        html+=row("Job",job.label)+row("Rank",`${ri+1}. ${cur.name}`,ri===total-1?"g":"");

        if (cur.jpNeeded===null) {
            html+=row("JP accumulated",`${fmt(jp)} — top rank`);
        } else if (jobKey==="education" && ri<total-1) {
            const nx=ranks[ri+1];
            // Only count boost JP for stats that passive gains WON'T close
            // before you'd have 10 JP available to spend.
            // Days to earn 10 JP from current surplus = ceil(max(0,10-(jp-cur.jpNeeded))/jpPerDay)
            const surplusNow=Math.max(0,jp-cur.jpNeeded);
            const daysTo10JP=surplusNow>=10?0:Math.ceil((10-surplusNow)/cur.jpPerDay);
            const immediateGapBoosts=JOBS.education.benefits
                .filter(b=>ri>=b.unlockedAtRank)
                .reduce((sum,b)=>{
                    const gap=b.statKey==="man"?Math.max(0,nx.req.man-man):b.statKey==="int"?Math.max(0,nx.req.int-int_):Math.max(0,nx.req.end-end);
                    if (gap<=0) return sum;
                    // Will passive gains close this gap before you can spend 10 JP?
                    const passiveGain=b.statKey==="man"?cur.gain.man:b.statKey==="int"?cur.gain.int:cur.gain.end;
                    const daysToClosePassively=Math.ceil(gap/passiveGain);
                    if (daysToClosePassively<=daysTo10JP) return sum; // passive closes it first
                    return sum+Math.ceil(gap/100)*10;
                },0);
            const effectiveNeeded=cur.jpNeeded+immediateGapBoosts;
            if (immediateGapBoosts>0) {
                html+=row("JP accumulated",`${fmt(jp)} / ${fmt(effectiveNeeded)} needed <span style="font-size:11px;color:#667">(${cur.jpNeeded} to promote + ${immediateGapBoosts} for stat boosts)</span>`);
            } else {
                html+=row("JP accumulated",`${fmt(jp)} / ${cur.jpNeeded} needed`);
            }
        } else {
            html+=row("JP accumulated",`${fmt(jp)} / ${cur.jpNeeded} needed`);
        }

        if (ri<total-1) {
            const nx=ranks[ri+1];
            html+=sec(`Stats vs ${nx.name}`);
            html+=row("Manual",      pill(man,nx.req.man)+` / ${rq(nx.req.man)}`);
            html+=row("Intelligence",pill(int_,nx.req.int)+` / ${rq(nx.req.int)}`);
            html+=row("Endurance",   pill(end,nx.req.end)+` / ${rq(nx.req.end)}`);
        }

        if (sim.length>1) {
            html+=sec("Projected Rank Timeline");
            sim.forEach((r,i)=>{ if(!i)return; html+=row(`→ ${r.rankName}`,r.dayReached===0?"✓ Now":`Day ${r.dayReached} (${dateIn(r.dayReached)})`,r.rankIdx===total-1?"g":""); });
        }

        html+=sec("Top Rank Perk");
        html+=sim.find(r=>r.rankIdx===total-1)
            ?`<div class="jt-perk">${job.topRankPerk}</div>`
            :`<div class="jt-perk locked">⚠ Cannot project top rank — passive gains alone cannot meet all requirements.</div>`;

        html+=sec("Recommendations");
        html+=buildRecs(jobKey,ri,jp,man,int_,end,sim).map((r,i)=>recEl(i+1,r.text,r.urgent)).join('');
        el.results.innerHTML=html;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // RECOMMENDATIONS
    // ─────────────────────────────────────────────────────────────────────────
    function buildRecs(jobKey,ri,jp,man,int_,end,sim) {
        const job=JOBS[jobKey], ranks=job.ranks, cur=ranks[ri], total=ranks.length, recs=[];

        if (ri===total-1) {
            recs.push({text:`You're at the top rank. Perk: ${job.topRankPerk}`});
            if (jobKey==="medical") recs.push({text:"You can now revive players for 75 energy — earns $500k–$1M+ each. This passive stays forever; you can leave Medical now."});
            if (jobKey==="law")     recs.push({text:"The +5% crime exp & skill passive is yours permanently. Consider switching jobs for better working stat gains."});
            return recs;
        }

        const nx=ranks[ri+1];
        const jpLeft=Math.max(0,cur.jpNeeded-jp), dJP=jpLeft>0?Math.ceil(jpLeft/cur.jpPerDay):0;
        const gaps={man:Math.max(0,nx.req.man-man),int:Math.max(0,nx.req.int-int_),end:Math.max(0,nx.req.end-end)};
        const dStat={man:gaps.man>0?Math.ceil(gaps.man/cur.gain.man):0,int:gaps.int>0?Math.ceil(gaps.int/cur.gain.int):0,end:gaps.end>0?Math.ceil(gaps.end/cur.gain.end):0};
        const statBlock=Math.max(dStat.man,dStat.int,dStat.end);

        if (statBlock>dJP) {
            const gList=["man","int","end"].filter(k=>gaps[k]>0).map(k=>`${k.toUpperCase()}: need ${fmt(gaps[k])} more (${dStat[k]}d at +${cur.gain[k]}/day)`);
            recs.push({text:`Stat-blocked for ${nx.name}. ${gList.join(" · ")}`,urgent:true});
            if (jobKey==="education") {
                const anyUnlocked=JOBS.education.benefits.some(b=>ri>=b.unlockedAtRank);
                if (anyUnlocked) {
                    const surp=Math.max(0,jp+statBlock*cur.jpPerDay-cur.jpNeeded);
                    // Find which stat is the biggest bottleneck to boost
                    const boostable=JOBS.education.benefits.filter(b=>ri>=b.unlockedAtRank).map(b=>b.statKey);
                    let bestStat=null, bestScore=-1;
                    for (const sk of boostable) {
                        const have=sk==="man"?man:sk==="int"?int_:end;
                        const need=sk==="man"?nx.req.man:sk==="int"?nx.req.int:nx.req.end;
                        const gain=sk==="man"?cur.gain.man:sk==="int"?cur.gain.int:cur.gain.end;
                        const score=gain>0?Math.max(0,need-have)/gain:0;
                        if(score>bestScore){bestScore=score;bestStat=sk;}
                    }
                    const statLabel={man:"Manual",int:"Intelligence",end:"Endurance"};
                    const boostNow=Math.floor(Math.max(0,jp-cur.jpNeeded)/10);
                    let boostText=`Spend surplus JP on ${bestStat?statLabel[bestStat]:"your bottleneck stat"} (+100 per 10 pts).`;
                    if(boostNow>0) boostText+=` You have ${boostNow} boost${boostNow!==1?"s":""} available right now.`;
                    boostText+=` You'll accumulate ~${Math.floor(surp/10)} total boosts while waiting for stats.`;
                    recs.push({text:boostText});
                } else recs.push({text:"No JP stat specials unlocked yet. Save all JP for promotions."});
            }
        } else {
            recs.push(jpLeft>0
                ?{text:`${dJP} day${dJP!==1?"s":""} until you can promote to ${nx.name}. Stats already meet requirements.`}
                :{text:`✓ You can promote to ${nx.name} right now!`,urgent:true});
            if (jobKey==="education"&&dJP>0) {
                const boostable=JOBS.education.benefits.filter(b=>ri>=b.unlockedAtRank).map(b=>b.statKey);
                if (boostable.length>0) {
                    const statLabel={man:"Manual",int:"Intelligence",end:"Endurance"};
                    const surplusNow=Math.max(0,jp-cur.jpNeeded);
                    const boostTotal=Math.floor((surplusNow+dJP*cur.jpPerDay)/10);
                    const daysTo10JP=surplusNow>=10?0:Math.ceil((10-surplusNow)/cur.jpPerDay);
                    const immediateGaps=boostable.filter(sk=>{
                        if(!gaps[sk]) return false;
                        const passiveGain=sk==="man"?cur.gain.man:sk==="int"?cur.gain.int:cur.gain.end;
                        const daysToClosePassively=Math.ceil(gaps[sk]/passiveGain);
                        return daysToClosePassively>daysTo10JP; // only flag if passive won't close it first
                    });
                    if (immediateGaps.length>0) {
                        // There are stat gaps that JP specials can close faster than passive gains
                        const closeable=immediateGaps.map(sk=>{
                            const gap=gaps[sk], boostsNeeded=Math.ceil(gap/100);
                            return {sk, gap, boostsNeeded, label:statLabel[sk]};
                        }).sort((a,b)=>a.boostsNeeded-b.boostsNeeded);
                        const parts=closeable.map(x=>`${x.label} (${fmt(x.gap)} gap — ${x.boostsNeeded} boost${x.boostsNeeded!==1?"s":""} of 10 JP each)`);
                        const totalBoostsNeeded=closeable.reduce((s,x)=>s+x.boostsNeeded,0);
                        let boostText=`Use JP specials to close stat gaps for ${nx.name}: ${parts.join(", ")}.`;
                        if (surplusNow>=10) boostText+=` You have ${Math.floor(surplusNow/10)} boost${Math.floor(surplusNow/10)!==1?"s":""} available right now.`;
                        if (totalBoostsNeeded<=boostTotal) boostText+=` You'll have enough boosts to cover all gaps before promotion.`;
                        recs.push({text:boostText,urgent:closeable.some(x=>x.boostsNeeded<=boostTotal)});
                    } else {
                        // No gaps to next rank — look ahead to rank after next
                        const targetRank=ranks[Math.min(ri+2,total-1)];
                        let bestStat=null, bestScore=-1;
                        for (const sk of boostable) {
                            const have=sk==="man"?man:sk==="int"?int_:end;
                            const need=sk==="man"?targetRank.req.man:sk==="int"?targetRank.req.int:targetRank.req.end;
                            const gain=sk==="man"?cur.gain.man:sk==="int"?cur.gain.int:cur.gain.end;
                            const score=gain>0?Math.max(0,need-have)/gain:0;
                            if(score>bestScore){bestScore=score;bestStat=sk;}
                        }
                        if (bestStat&&bestScore>0) {
                            let boostText=`While waiting, spend surplus JP above ${cur.jpNeeded} on ${statLabel[bestStat]}.`;
                            if(Math.floor(surplusNow/10)>0) boostText+=` ${Math.floor(surplusNow/10)} boost${Math.floor(surplusNow/10)!==1?"s":""} available now.`;
                            if(boostTotal>Math.floor(surplusNow/10)) boostText+=` ~${boostTotal} total by promotion time.`;
                            recs.push({text:boostText});
                        }
                    }
                }
            }
        }

        // Total JP to reach top rank
        const jpToTop=ranks.slice(ri,total-1).reduce((sum,r)=>sum+(r.jpNeeded??0),0);
        if (jpToTop>0) {
            const jpRemaining=Math.max(0,jpToTop-jp);
            recs.push({text:`Total JP needed to reach ${ranks[total-1].name}: ${fmt(jpToTop)} pts (you have ${fmt(jp)}, need ${fmt(jpRemaining)} more at ~${cur.jpPerDay}/day).`});
        }

        const topR=sim.find(r=>r.rankIdx===total-1);
        if (topR?.dayReached>0) recs.push({text:`At this rate you'll reach ${ranks[total-1].name} in ~${topR.dayReached} days (${dateIn(topR.dayReached)}). Perk: ${job.topRankPerk}`});

        const tips={army:"Save all JP for promotions. Don't spend on Str/Def boosts until General — a lump spend at high stats is far more efficient.",grocer:"Save all JP for promotions. Wait until Manager, then spend on Energy Drink steals (25 pts, ~$3–5M each).",casino:"Save all JP for promotions. The Casino President payout is the only worthwhile spend.",medical:"Save all JP for promotions. Don't spend on med steals before Brain Surgeon — the revive passive is the goal.",law:"Save all JP for promotions. Endurance is almost certainly your bottleneck for Federal Judge (needs END 15,000)."};
        if (tips[jobKey]) recs.push({text:tips[jobKey]});
        return recs;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // CALCULATE PLAN
    // Tests every subset of unlocked Education JP specials and picks the
    // combination that minimises total days (Education + target job).
    // ─────────────────────────────────────────────────────────────────────────

    // ─────────────────────────────────────────────────────────────────────────
    // AUTO-INIT
    // ─────────────────────────────────────────────────────────────────────────
    function tryInit() {
        if(document.querySelector('.jrank')&&document.querySelector('.jmanLabor')){
            autofill(); calculate();
            wrap.classList.add("open"); jt_save("collapsed","no");
            updatePlanner(); return true;
        }
        return false;
    }
    // Read battle stats from cross-page cache (written by gym module)
    (function() {
        const ws = cache.get('workstats');
        if (ws) {
            if (ws.man > 0 && !parseInt(jt_load('manStat'))) { jt_save('manStat', String(ws.man)); }
            if (ws.int > 0 && !parseInt(jt_load('intStat'))) { jt_save('intStat', String(ws.int)); }
            if (ws.end > 0 && !parseInt(jt_load('endStat'))) { jt_save('endStat', String(ws.end)); }
        }
    })();

    if(!tryInit()){
        const obs=new MutationObserver(()=>{if(tryInit())obs.disconnect();});
        obs.observe(document.body,{childList:true,subtree:true});
        setTimeout(()=>obs.disconnect(),10000);
    }

    } // end jobModule
    function eduModule() {

    const SUBJECTS = {
        BIO: { label: "Biology",           color: "#4a8a5a" },
        BUS: { label: "Business",          color: "#6a6aaa" },
        CBT: { label: "Combat Training",   color: "#aa5a3a" },
        CMT: { label: "Computer Science",  color: "#3a7aaa" },
        DEF: { label: "Self Defense",      color: "#8a5aaa" },
        GEN: { label: "General Studies",   color: "#7a7a5a" },
        HAF: { label: "Health & Fitness",  color: "#5a9a5a" },
        HIS: { label: "History",           color: "#9a7a3a" },
        LAW: { label: "Law",               color: "#7a5a3a" },
        MTH: { label: "Mathematics",       color: "#3a8a9a" },
        PSY: { label: "Psychology",        color: "#9a5a7a" },
        SPT: { label: "Sports Science",    color: "#5a9a7a" },
    };

    // Tier: 1=intro (always 7d), 2=mid, 3=bachelor
    // prereqs: array of course codes that must be completed first
    const COURSES = [
        // ── BIOLOGY (9 courses) ───────────────────────────────────────────────
        { id:"BIO1340", subj:"BIO", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction to Biochemistry",           perk:"" },
        { id:"BIO2127", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"],                                                                                  name:"Intravenous Therapy",                    perk:"Use blood bags to heal yourself and others" },
        { id:"BIO2350", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"],                                                                                  name:"Evolution",                              perk:"+3% damage to chest shots" },
        { id:"BIO2360", subj:"BIO", tier:2, days:28, prereqs:["BIO1340"],                                                                                  name:"Intermediate Biochemistry",              perk:"+10% medical item effectiveness" },
        { id:"BIO2370", subj:"BIO", tier:2, days:35, prereqs:["BIO2360"],                                                                                  name:"Advanced Biochemistry",                  perk:"+10% further medical item effectiveness" },
        { id:"BIO2380", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"],                                                                                  name:"Fundamentals Of Neurobiology",           perk:"+3% damage to throat shots" },
        { id:"BIO2390", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"],                                                                                  name:"Chromosomes And Gene Functions",         perk:"+3% damage to stomach shots" },
        { id:"BIO2400", subj:"BIO", tier:2, days:21, prereqs:["BIO1340"],                                                                                  name:"Forensic Science",                       perk:"Decrease opponent stealth by 25%" },
        { id:"BIO2410", subj:"BIO", tier:2, days:28, prereqs:["BIO1340"],                                                                                  name:"Anatomy",                                perk:"+3% critical hit chance" },
        { id:"BIO3420", subj:"BIO", tier:3, days:42, prereqs:["BIO2127","BIO2350","BIO2360","BIO2370","BIO2380","BIO2390","BIO2400","BIO2410"],             name:"Bachelor Of Biology",                    perk:"Equip life/stat booster temps + unlock Pharmacy" },
        // ── BUSINESS (13 courses) ────────────────────────────────────────────
        { id:"BUS1100", subj:"BUS", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Business",               perk:"" },
        { id:"BUS2100", subj:"BUS", tier:2, days:14, prereqs:["BUS1100"],                                                                                  name:"Business Ethics",                        perk:"Small increase in company popularity" },
        { id:"BUS2110", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"Human Resource Management",              perk:"Passive bonus to employee working stats" },
        { id:"BUS2120", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"E-Commerce",                             perk:"+2% company productivity" },
        { id:"BUS2200", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"Statistics",                             perk:"+2% company productivity" },
        { id:"BUS2300", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"Communication",                          perk:"+5% employee effectiveness" },
        { id:"BUS2400", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"Marketing",                              perk:"Increase advertising effectiveness" },
        { id:"BUS2500", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"Corporate Finance",                      perk:"+2% company productivity" },
        { id:"BUS2600", subj:"BUS", tier:2, days:28, prereqs:["BUS1100"],                                                                                  name:"Corporate Strategy",                     perk:"+7% employee effectiveness" },
        { id:"BUS2700", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"Pricing Strategy",                       perk:"+10% product price ceiling" },
        { id:"BUS2800", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"Logistics",                              perk:"+2% company productivity" },
        { id:"BUS2900", subj:"BUS", tier:2, days:21, prereqs:["BUS1100"],                                                                                  name:"Product Management",                     perk:"+5% product price ceiling" },
        { id:"BUS3130", subj:"BUS", tier:3, days:42, prereqs:["BUS2100","BUS2110","BUS2120","BUS2200","BUS2300","BUS2400","BUS2500","BUS2600","BUS2700","BUS2800","BUS2900"], name:"Bachelor Of Commerce",          perk:"Unlock new company size/storage/staff upgrades" },
        // ── COMBAT TRAINING (10 courses) ─────────────────────────────────────
        { id:"CBT1780", subj:"CBT", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Combat",                 perk:"" },
        { id:"CBT2125", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Study Of Shotguns",                      perk:"+5% accuracy with shotguns" },
        { id:"CBT2790", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Military Psychology",                    perk:"+3% damage in all attacks" },
        { id:"CBT2800", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Study Of War And Technology",            perk:"+5% accuracy with temporary weapons" },
        { id:"CBT2810", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Study Of Society And Warfare",           perk:"+5% accuracy with melee weapons" },
        { id:"CBT2820", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Study Of Machine Guns",                  perk:"+5% accuracy with machine guns" },
        { id:"CBT2830", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Study Of Submachine Guns",               perk:"+5% accuracy with sub-machine guns" },
        { id:"CBT2840", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Study Of Pistols",                       perk:"+5% accuracy with pistols" },
        { id:"CBT2850", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Study Of Rifles",                        perk:"+5% accuracy with rifles" },
        { id:"CBT2860", subj:"CBT", tier:2, days:14, prereqs:["CBT1780"],                                                                                  name:"Study Of Heavy Artillery",               perk:"+5% accuracy with heavy artillery" },
        { id:"CBT3870", subj:"CBT", tier:3, days:42, prereqs:["CBT2125","CBT2790","CBT2800","CBT2810","CBT2820","CBT2830","CBT2840","CBT2850","CBT2860"],  name:"Bachelor Of Military Arts And Science",  perk:"Start gaining weapon experience" },
        // ── COMPUTER SCIENCE (16 courses) ────────────────────────────────────
        { id:"CMT1520", subj:"CMT", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Computing",              perk:"Code simple viruses" },
        { id:"CMT2128", subj:"CMT", tier:2, days:21, prereqs:["CMT2570"],                                                                                  name:"Overclocking",                           perk:"+10% overclocking cracking bonus" },
        { id:"CMT2129", subj:"CMT", tier:2, days:28, prereqs:["CMT2128"],                                                                                  name:"Advanced Overclocking",                  perk:"+15% overclocking cracking bonus" },
        { id:"CMT2130", subj:"CMT", tier:2, days:21, prereqs:["CMT2530"],                                                                                  name:"Web Security And Penetration Testing",   perk:"+10% hacking success rate" },
        { id:"CMT2131", subj:"CMT", tier:2, days:21, prereqs:["CMT2530"],                                                                                  name:"Automated Data Mining & Processing",     perk:"+10% data mining success rate" },
        { id:"CMT2230", subj:"CMT", tier:2, days:14, prereqs:["CMT1520"],                                                                                  name:"Web Design And Development",             perk:"+5% company advertising effectiveness" },
        { id:"CMT2530", subj:"CMT", tier:2, days:21, prereqs:["CMT1520"],                                                                                  name:"Intermediate Programming",               perk:"Code Polymorphic and Tunneling viruses" },
        { id:"CMT2540", subj:"CMT", tier:2, days:14, prereqs:["CMT1520"],                                                                                  name:"Networking",                             perk:"+5% hacking success rate" },
        { id:"CMT2550", subj:"CMT", tier:2, days:14, prereqs:["CMT1520"],                                                                                  name:"Computer Repair",                        perk:"+5% company productivity" },
        { id:"CMT2560", subj:"CMT", tier:2, days:28, prereqs:["CMT2530"],                                                                                  name:"Algorithms And Advanced Programming",    perk:"Code Armored and Stealth viruses" },
        { id:"CMT2570", subj:"CMT", tier:2, days:21, prereqs:["CMT1520"],                                                                                  name:"Fundamentals Of Computer Architecture", perk:"+5% computer speed" },
        { id:"CMT2580", subj:"CMT", tier:2, days:21, prereqs:["CMT1520"],                                                                                  name:"Software Engineering",                   perk:"+5% virus effectiveness" },
        { id:"CMT2590", subj:"CMT", tier:2, days:28, prereqs:["CMT1520"],                                                                                  name:"Quantum Computing",                      perk:"+10% virus effectiveness" },
        { id:"CMT2600", subj:"CMT", tier:2, days:28, prereqs:["CMT1520"],                                                                                  name:"Natural Language Engineering",           perk:"+5% virus detection avoidance" },
        { id:"CMT2610", subj:"CMT", tier:2, days:28, prereqs:["CMT2540"],                                                                                  name:"Computer Security And Defense",          perk:"+10% hacking crime success rate" },
        { id:"CMT3620", subj:"CMT", tier:3, days:42, prereqs:["CMT2128","CMT2129","CMT2130","CMT2131","CMT2230","CMT2530","CMT2540","CMT2550","CMT2560","CMT2570","CMT2580","CMT2590","CMT2600","CMT2610"], name:"Bachelor Of Computer Science", perk:"Send mails anonymously" },
        // ── SELF DEFENSE (7 courses) ──────────────────────────────────────────
        { id:"DEF1700", subj:"DEF", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Self Defense",           perk:"" },
        { id:"DEF2710", subj:"DEF", tier:2, days:14, prereqs:["DEF1700"],                                                                                  name:"Judo",                                   perk:"+1% passive defense bonus" },
        { id:"DEF2720", subj:"DEF", tier:2, days:14, prereqs:["DEF1700"],                                                                                  name:"Kick Boxing",                            perk:"Unlock kick attack" },
        { id:"DEF2730", subj:"DEF", tier:2, days:21, prereqs:["DEF1700"],                                                                                  name:"Krav Maga",                              perk:"+1% passive speed bonus" },
        { id:"DEF2740", subj:"DEF", tier:2, days:21, prereqs:["DEF1700"],                                                                                  name:"Jujitsu",                                perk:"+1% passive defense bonus" },
        { id:"DEF2750", subj:"DEF", tier:2, days:21, prereqs:["DEF1700"],                                                                                  name:"Tae Kwon Do",                            perk:"+1% passive speed bonus" },
        { id:"DEF2760", subj:"DEF", tier:2, days:21, prereqs:["DEF1700"],                                                                                  name:"Muay Thai",                              perk:"+1% passive strength bonus" },
        { id:"DEF3770", subj:"DEF", tier:3, days:35, prereqs:["DEF2710","DEF2720","DEF2730","DEF2740","DEF2750","DEF2760"],                                name:"Bachelor Of Self Defense",               perk:"+100% fist/kick damage" },
        // ── GENERAL STUDIES (12 courses) ─────────────────────────────────────
        { id:"GEN1112", subj:"GEN", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To General Studies",        perk:"" },
        { id:"GEN2113", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"],                                                                                  name:"Driving License",                        perk:"Drive cars in the city" },
        { id:"GEN2114", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"],                                                                                  name:"Astronomy",                              perk:"+3% city find chance" },
        { id:"GEN2115", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"],                                                                                  name:"Mechanical Arts",                        perk:"+5% city find chance" },
        { id:"GEN2116", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"],                                                                                  name:"General Mechanics",                      perk:"+5% hit increase with temporary weapons" },
        { id:"GEN2117", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"],                                                                                  name:"Basic English",                          perk:"+5% effectiveness negotiating bail" },
        { id:"GEN2118", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"],                                                                                  name:"Creative Writing",                       perk:"+5% company advertising effectiveness" },
        { id:"GEN2119", subj:"GEN", tier:2, days:21, prereqs:["GEN1112"],                                                                                  name:"General Science",                        perk:"+5% damage with temporary weapons" },
        { id:"GEN2120", subj:"GEN", tier:2, days:14, prereqs:["GEN1112"],                                                                                  name:"Survival Skills",                        perk:"+15% hunting bonus" },
        { id:"GEN2122", subj:"GEN", tier:2, days:21, prereqs:["GEN1112"],                                                                                  name:"Newtonian Physics",                      perk:"+5% damage with thrown weapons" },
        { id:"GEN2123", subj:"GEN", tier:2, days:21, prereqs:["GEN1112"],                                                                                  name:"Ivory Crafting",                         perk:"+5% city find chance" },
        { id:"GEN3121", subj:"GEN", tier:3, days:42, prereqs:["GEN2113","GEN2114","GEN2115","GEN2116","GEN2117","GEN2118","GEN2119","GEN2120","GEN2122","GEN2123"], name:"Bachelor Of General Studies",  perk:"+10% working stat gains from all education" },
        // ── HEALTH & FITNESS (8 courses) ─────────────────────────────────────
        { id:"HAF1103", subj:"HAF", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Health And Fitness",     perk:"" },
        { id:"HAF2104", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"],                                                                                  name:"Aerobics",                               perk:"+1% passive dexterity bonus" },
        { id:"HAF2105", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"],                                                                                  name:"Acrobatics",                             perk:"+1% passive speed bonus" },
        { id:"HAF2106", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"],                                                                                  name:"Power Lifting",                          perk:"+1% passive strength bonus" },
        { id:"HAF2107", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"],                                                                                  name:"Yoga",                                   perk:"+2% passive strength bonus" },
        { id:"HAF2108", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"],                                                                                  name:"Swimming",                               perk:"+1% passive dexterity bonus" },
        { id:"HAF2109", subj:"HAF", tier:2, days:28, prereqs:["HAF1103"],                                                                                  name:"Marathon Training",                      perk:"+3% passive speed bonus" },
        { id:"HAF2110", subj:"HAF", tier:2, days:14, prereqs:["HAF1103"],                                                                                  name:"Sailing",                                perk:"+5% travel speed" },
        { id:"HAF3111", subj:"HAF", tier:3, days:35, prereqs:["HAF2104","HAF2105","HAF2106","HAF2107","HAF2108","HAF2109","HAF2110"],                       name:"Bachelor Of Health Sciences",            perk:"+25% speed during escape + 50% reduce chance opponent flees" },
        // ── HISTORY (7 courses) ───────────────────────────────────────────────
        { id:"HIS1140", subj:"HIS", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To History",                perk:"" },
        { id:"HIS2150", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"],                                                                                  name:"Aims And Methods In Archaeology",        perk:"+10% city find chance" },
        { id:"HIS2160", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"],                                                                                  name:"Ancient Japanese History",               perk:"+10% damage with Japanese blade weapons" },
        { id:"HIS2170", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"],                                                                                  name:"Medieval History",                       perk:"+10% damage with clubbing weapons" },
        { id:"HIS2180", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"],                                                                                  name:"Medieval Archaeology",                   perk:"+10% damage with piercing weapons" },
        { id:"HIS2190", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"],                                                                                  name:"South Asian Archaeology",                perk:"+10% city find chance" },
        { id:"HIS2200", subj:"HIS", tier:2, days:21, prereqs:["HIS1140"],                                                                                  name:"Egyptian Archaeology",                   perk:"+10% damage with slashing weapons" },
        { id:"HIS3210", subj:"HIS", tier:3, days:42, prereqs:["HIS2150","HIS2160","HIS2170","HIS2180","HIS2190","HIS2200"],                                name:"Bachelor Of History",                    perk:"Unlock museum" },
        // ── LAW (14 courses) ──────────────────────────────────────────────────
        { id:"LAW1880", subj:"LAW", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Law",                    perk:"" },
        { id:"LAW2100", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"],                                                                                  name:"Media Law",                              perk:"Increase advertising effectiveness" },
        { id:"LAW2101", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"],                                                                                  name:"Revenue Law",                            perk:"-5% bail cost" },
        { id:"LAW2890", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"],                                                                                  name:"Public Law",                             perk:"-25% nerve to escape jail" },
        { id:"LAW2900", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"],                                                                                  name:"Common Law",                             perk:"Buy yourself/others out of jail while in jail" },
        { id:"LAW2910", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"],                                                                                  name:"Property Law",                           perk:"-5% property upgrade cost" },
        { id:"LAW2920", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"],                                                                                  name:"Criminal Law",                           perk:"+5% crime success rate" },
        { id:"LAW2930", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"],                                                                                  name:"Administrative Law",                     perk:"+5% busting skill" },
        { id:"LAW2940", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"],                                                                                  name:"Commercial And Consumer Law",            perk:"+5% company profit" },
        { id:"LAW2950", subj:"LAW", tier:2, days:21, prereqs:["LAW1880"],                                                                                  name:"Family Law",                             perk:"-5% bail cost" },
        { id:"LAW2960", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"],                                                                                  name:"Labor Law",                              perk:"+5% employee effectiveness" },
        { id:"LAW2970", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"],                                                                                  name:"Social And Economic Law",                perk:"+5% busting skill" },
        { id:"LAW2980", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"],                                                                                  name:"Use Of Force In International Law",      perk:"+5% crime success rate" },
        { id:"LAW2990", subj:"LAW", tier:2, days:28, prereqs:["LAW1880"],                                                                                  name:"International Human Rights",             perk:"-10% bail cost" },
        { id:"LAW3102", subj:"LAW", tier:3, days:42, prereqs:["LAW2100","LAW2101","LAW2890","LAW2900","LAW2910","LAW2920","LAW2930","LAW2940","LAW2950","LAW2960","LAW2970","LAW2980","LAW2990"], name:"Bachelor Of Law", perk:"Greatly increased busting skill" },
        // ── MATHEMATICS (10 courses) ─────────────────────────────────────────
        { id:"MTH1220", subj:"MTH", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Mathematics",            perk:"" },
        { id:"MTH2240", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"],                                                                                  name:"Essential Foundation Mathematics",       perk:"+1% passive speed bonus" },
        { id:"MTH2250", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"],                                                                                  name:"Intermediate Mathematics",               perk:"+1% passive speed bonus" },
        { id:"MTH2260", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"],                                                                                  name:"Geometry",                               perk:"+1% passive defense bonus" },
        { id:"MTH2270", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"],                                                                                  name:"Algebra",                                perk:"+5% ammo conservation" },
        { id:"MTH2280", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"],                                                                                  name:"Probability",                            perk:"+1% company productivity" },
        { id:"MTH2290", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"],                                                                                  name:"Trigonometry",                           perk:"+5% ammo conservation" },
        { id:"MTH2300", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"],                                                                                  name:"Calculus",                               perk:"+5% ammo conservation" },
        { id:"MTH2310", subj:"MTH", tier:2, days:28, prereqs:["MTH1220"],                                                                                  name:"Discrete Mathematics",                   perk:"+5% ammo conservation" },
        { id:"MTH2320", subj:"MTH", tier:2, days:21, prereqs:["MTH1220"],                                                                                  name:"Geometry 2",                             perk:"+2% passive defense bonus" },
        { id:"MTH3330", subj:"MTH", tier:3, days:42, prereqs:["MTH2240","MTH2250","MTH2260","MTH2270","MTH2280","MTH2290","MTH2300","MTH2310","MTH2320"],  name:"Bachelor Of Mathematics",                perk:"+20% ammo conservation" },
        // ── PSYCHOLOGY (7 courses) ────────────────────────────────────────────
        { id:"PSY1630", subj:"PSY", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Psychology",             perk:"" },
        { id:"PSY2132", subj:"PSY", tier:2, days:21, prereqs:["PSY1630"],                                                                                  name:"Intrapersonal Dynamics",                 perk:"+5% crime success rate" },
        { id:"PSY2640", subj:"PSY", tier:2, days:14, prereqs:["PSY1630"],                                                                                  name:"Memory And Decision",                    perk:"+1% passive dexterity bonus" },
        { id:"PSY2650", subj:"PSY", tier:2, days:14, prereqs:["PSY1630"],                                                                                  name:"Brain And Behaviour",                    perk:"+2% passive dexterity bonus" },
        { id:"PSY2660", subj:"PSY", tier:2, days:21, prereqs:["PSY1630"],                                                                                  name:"Quantitative Methods In Psychology",     perk:"+4% passive dexterity bonus" },
        { id:"PSY2670", subj:"PSY", tier:2, days:28, prereqs:["PSY1630"],                                                                                  name:"Applied Decision Methods",               perk:"+8% passive dexterity bonus" },
        { id:"PSY2680", subj:"PSY", tier:2, days:21, prereqs:["PSY1630"],                                                                                  name:"Attention And Awareness",                perk:"+5% city find chance" },
        { id:"PSY3690", subj:"PSY", tier:3, days:35, prereqs:["PSY2132","PSY2640","PSY2650","PSY2660","PSY2670","PSY2680"],                                name:"Bachelor Of Psychological Sciences",     perk:"+10% crime success rate" },
        // ── SPORTS SCIENCE (10 courses) — order matches Torn DOM slot positions ─
        { id:"SPT1430", subj:"SPT", tier:1, days:7,  prereqs:[],                                                                                          name:"Introduction To Sports Science",         perk:"" },
        { id:"SPT2440", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"],                                                                                  name:"Strength And Conditioning",              perk:"+1% strength gym gains" },
        { id:"SPT2450", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"],                                                                                  name:"Physiological Testing",                  perk:"+1% speed gym gains" },
        { id:"SPT2460", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"],                                                                                  name:"Human Movement Analysis",                perk:"+1% defense gym gains" },
        { id:"SPT2470", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"],                                                                                  name:"Bio Mechanical Determinants Of Skill",   perk:"+1% dexterity gym gains" },
        { id:"SPT2480", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"],                                                                                  name:"Sports Medicine",                        perk:"+10% temporary booster stat increases" },
        { id:"SPT2490", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"],                                                                                  name:"Nutritional Science",                    perk:"+2% passive speed and strength bonus" },
        { id:"SPT2500", subj:"SPT", tier:2, days:21, prereqs:["SPT1430"],                                                                                  name:"Analysis And Performance",               perk:"+2% passive defense and dexterity bonus" },
        { id:"SPT2126", subj:"SPT", tier:2, days:14, prereqs:["SPT1430"],                                                                                  name:"Sports Administration",                  perk:"Unlock the Sports Shop" },
        { id:"SPT3510", subj:"SPT", tier:3, days:35, prereqs:["SPT2440","SPT2450","SPT2460","SPT2470","SPT2480","SPT2490","SPT2500","SPT2126"],             name:"Bachelor Of Sports Science",             perk:"+1% all gym gains + 1% all passive stats" },
    ];

    // ── Lookup maps ──────────────────────────────────────────────────────────
    const COURSE_BY_ID = Object.fromEntries(COURSES.map(c => [c.id, c]));
    const BY_SUBJECT   = COURSES.reduce((m, c) => { (m[c.subj] ??= []).push(c); return m; }, {});
    const TOTAL_BASE_DAYS = COURSES.reduce((s, c) => s + c.days, 0);

    // ── Persistence ──────────────────────────────────────────────────────────
    const loadCompleted = () => { try { return new Set(JSON.parse(_load("ep_completed","[]"))); } catch(_){ return new Set(); }};
    const saveCompleted = s  => _save("ep_completed", JSON.stringify([...s]));
    const loadQueue     = () => { try { return JSON.parse(_load("ep_queue","[]")); } catch(_){ return []; }};
    const saveQueue     = a  => _save("ep_queue", JSON.stringify(a));

    // ── State ─────────────────────────────────────────────────────────────────
    let completed     = loadCompleted();
    let queue         = loadQueue();
    let currentCourse = null;    // { id, timeLeft (seconds) }
    let reduction     = parseFloat(_load("ep_reduction","0"));
    // pickerOpen: set of subject keys whose picker section is expanded
    const pickerOpen  = new Set();

    // ── Helpers ───────────────────────────────────────────────────────────────
    const applyRed  = d => d * (1 - reduction / 100);
    const canEnroll = id => (COURSE_BY_ID[id]?.prereqs ?? []).every(p => completed.has(p));
    const subjDone  = s => BY_SUBJECT[s].every(c => completed.has(c.id));
    const fmtD      = (d,r=1) => parseFloat(d.toFixed(r));

    function daysToStr(d) {
        d = Math.ceil(d);
        if (d <= 0) return "Done";
        if (d < 7)  return `${d}d`;
        const w = Math.floor(d/7), r = d%7;
        return r ? `${w}w ${r}d` : `${w}w`;
    }

    function dateIn(days) {
        const d = new Date();
        d.setDate(d.getDate() + Math.ceil(days));
        return d.toLocaleDateString('en-GB', {day:'numeric', month:'short', year:'numeric'});
    }

    function remSubjDays(subj) {
        return BY_SUBJECT[subj].filter(c => !completed.has(c.id)).reduce((s,c) => s + applyRed(c.days), 0);
    }

    // ── CSS ───────────────────────────────────────────────────────────────────
    document.head.insertAdjacentHTML('beforeend', `<style>
.ep-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}
.ep-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:linear-gradient(135deg,#1e1e28,#181820);border-bottom:1px solid #2a2a38;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}
.ep-hdr:active{background:#24243a}
.ep-title{font-size:15px;font-weight:bold;color:#aab8dd}
.ep-tog{font-size:16px;color:#555;transition:transform .2s}
.ep-wrap.open .ep-tog{transform:rotate(180deg)}
.ep-body{display:none;padding:12px}
.ep-wrap.open .ep-body{display:block}
.ep-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 #252535}
.ep-sec:first-child{margin-top:0}
.ep-row{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 10px;margin-top:3px;border-radius:4px;background:#1e1e28}
.ep-rl{color:#778;font-size:12px;flex-shrink:0}
.ep-rv{color:#dde;font-size:12px;text-align:right}
.ep-row.g .ep-rv{color:#7abf7a}.ep-row.b .ep-rv{color:#7a9acc}.ep-row.r .ep-rv{color:#bf7a7a}.ep-row.a .ep-rv{color:#bf9f5a}
/* status */
.ep-st{display:none;margin-top:8px;padding:8px 10px;border-radius:4px;font-size:12px;line-height:1.5;word-break:break-word}
.ep-st.ok{display:block;background:#182018;border:1px solid #2a4a2a;color:#7abf7a}
.ep-st.err{display:block;background:#201818;border:1px solid #4a2828;color:#bf7a7a}
.ep-st.warn{display:block;background:#1e1a10;border:1px solid #4a3a18;color:#bf9f5a}
/* buttons */
.ep-btns{display:flex;gap:8px;margin-top:10px}
.ep-btn{flex:1;padding:10px 8px;border-radius:4px;border:1px solid #383848;background:#222;color:#ddd;font-size:13px;font-weight:bold;cursor:pointer;text-align:center;-webkit-tap-highlight-color:transparent}
.ep-btn:active{background:#2a2a3a}
.ep-btn-api{background:#18182a;border-color:#3a3a60;color:#8a8aee}
.ep-btn-dom{background:#182028;border-color:#304060;color:#6aaade}
/* reduction */
.ep-red-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.ep-red-row label{font-size:12px;color:#778;white-space:nowrap}
.ep-red-row input{flex:1;padding:6px 10px;background:#222;border:1px solid #383848;border-radius:4px;color:#e0e0e0;font-size:14px;max-width:80px}
.ep-red-chips{display:flex;gap:4px;flex-wrap:wrap;flex:1}
.ep-chip{font-size:10px;font-weight:bold;padding:2px 7px;border-radius:10px;border:1px solid}
.ep-chip.merit{color:#8a8aee;border-color:#3a3a60;background:#14142a}
.ep-chip.princ{color:#7abf7a;border-color:#2a4a2a;background:#141e14}
.ep-chip.wsu{color:#7a9acc;border-color:#2a3a50;background:#0e1420}
/* current course */
.ep-cur{padding:10px 12px;background:#14182a;border:1px solid #2a3060;border-radius:5px;margin-bottom:8px}
.ep-cur-name{font-size:14px;color:#aabfee;font-weight:bold;margin-bottom:2px}
.ep-cur-sub{font-size:11px;margin-bottom:4px}
.ep-cur-perk{font-size:11px;color:#556;margin-bottom:6px}
.ep-cur-time{font-size:13px;color:#7a9acc}
.ep-bar{height:6px;background:#1e1e38;border-radius:3px;margin-top:8px;overflow:hidden}
.ep-bar-fill{height:100%;background:linear-gradient(90deg,#3a5aa0,#6a8acc);border-radius:3px}
.ep-bar-note{font-size:10px;color:#4a5a6a;margin-top:4px}
/* queue */
.ep-q-item{display:flex;align-items:center;gap:8px;padding:8px 10px;margin-top:3px;background:#1a1a28;border:1px solid #252535;border-radius:5px}
.ep-q-num{flex-shrink:0;width:20px;height:20px;background:#1a2040;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;color:#6a8acc}
.ep-q-body{flex:1;min-width:0}
.ep-q-name{font-size:13px;color:#ccd;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep-q-info{font-size:10px;color:#4a5a6a;margin-top:1px}
.ep-q-right{display:flex;align-items:center;gap:6px;flex-shrink:0}
.ep-q-days{font-size:12px;font-weight:bold;color:#7a9acc}
.ep-q-del{background:none;border:none;color:#3a3a5a;cursor:pointer;font-size:18px;padding:0 2px;line-height:1;-webkit-tap-highlight-color:transparent}
.ep-q-del:active{color:#9a5a5a}
.ep-q-date{font-size:10px;color:#2a4a2a;text-align:right;padding:0 10px 4px}
.ep-q-empty{font-size:12px;color:#3a3a5a;padding:12px;text-align:center;border:1px dashed #252535;border-radius:5px;margin-top:3px}
.ep-q-total{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;margin-top:6px;background:#0e1420;border:1px solid #2a3a50;border-radius:5px}
.ep-q-total-l{font-size:12px;color:#5a7a9a}
.ep-q-total-r{font-size:13px;font-weight:bold;color:#7abfee}
/* picker */
.ep-pick{margin-top:10px;border:1px solid #252535;border-radius:6px;overflow:hidden}
.ep-pick-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#141424;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent}
.ep-pick-hdr:active{background:#1c1c32}
.ep-pick-title{font-size:12px;font-weight:bold;color:#6a8acc;text-transform:uppercase;letter-spacing:.05em}
.ep-pick-tog{font-size:12px;color:#3a4a6a;transition:transform .15s}
.ep-pick.open .ep-pick-tog{transform:rotate(180deg)}
.ep-pick-body{display:none;background:#0e0e1e}
.ep-pick.open .ep-pick-body{display:block}
.ep-ps{border-bottom:1px solid #1a1a2e}
.ep-ps:last-child{border-bottom:none}
.ep-ps-hdr{display:flex;align-items:center;padding:9px 12px;gap:8px;-webkit-tap-highlight-color:transparent}
.ep-ps-hdr:active{background:#141424}
.ep-ps-name{flex:1;font-size:13px;font-weight:bold;cursor:pointer}
.ep-ps-info{font-size:11px;color:#3a4a5a;flex-shrink:0}
.ep-ps-addall{flex-shrink:0;padding:5px 11px;border-radius:4px;border:1px solid #2a3a50;background:#0e1828;color:#5a8acc;font-size:11px;cursor:pointer;-webkit-tap-highlight-color:transparent}
.ep-ps-addall:active{background:#142030}
.ep-pc-list{display:none;padding:0 4px 6px}
.ep-ps.open .ep-pc-list{display:block}
.ep-pc{display:flex;align-items:center;padding:7px 8px;border-radius:4px;cursor:pointer;-webkit-tap-highlight-color:transparent;gap:8px;margin-top:2px}
.ep-pc:active{background:#141424}
.ep-pc-icon{flex-shrink:0;font-size:13px;width:18px;text-align:center}
.ep-pc-body{flex:1;min-width:0}
.ep-pc-name{font-size:12px;color:#aab}
.ep-pc-perk{font-size:10px;color:#3a4a5a;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep-pc-days{flex-shrink:0;font-size:11px;color:#4a6a8a;min-width:30px;text-align:right}
.ep-pc.done{opacity:.35;pointer-events:none}
.ep-pc.queued .ep-pc-name{color:#4a8a4a}
.ep-pc.locked .ep-pc-name{color:#4a4a6a}
/* subjects section */
.ep-subj{margin-top:8px;border:1px solid #252535;border-radius:5px;overflow:hidden}
.ep-subj-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;cursor:pointer;-webkit-tap-highlight-color:transparent;user-select:none}
.ep-subj-hdr:active{filter:brightness(1.15)}
.ep-subj-r{display:flex;align-items:center;gap:8px}
.ep-subj-days{font-size:12px;font-weight:bold;color:#aabfee}
.ep-subj-tog{font-size:11px;color:#445;transition:transform .15s}
.ep-subj.open .ep-subj-tog{transform:rotate(180deg)}
.ep-subj-bar{height:4px;background:#222;border-radius:0;overflow:hidden}
.ep-subj-fill{height:100%;transition:width .3s}
.ep-subj-body{display:none;border-top:1px solid #1e1e28}
.ep-subj.open .ep-subj-body{display:block}
.ep-cr{display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-bottom:1px solid #1a1a28;font-size:12px}
.ep-cr:last-child{border-bottom:none}
.ep-cr-icon{flex-shrink:0;width:16px;text-align:center;font-size:13px;margin-top:1px}
.ep-cr-body{flex:1}
.ep-cr-name{line-height:1.3}
.ep-cr-perk{font-size:10px;color:#446;margin-top:1px}
.ep-cr-days{flex-shrink:0;font-size:11px;color:#445;text-align:right;white-space:nowrap}
/* summary */
.ep-sg{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px}
.ep-sb{background:#1a1a28;border:1px solid #252540;border-radius:4px;padding:7px 10px}
.ep-sb-l{font-size:10px;color:#4a5a6a;text-transform:uppercase;letter-spacing:.04em}
.ep-sb-v{font-size:13px;font-weight:bold;color:#aabfee;margin-top:1px}
</style>`);

    // ── Mount ─────────────────────────────────────────────────────────────────
    const wrap = document.createElement("div");
    wrap.className = "ep-wrap";
    const q = sel => wrap.querySelector(sel);

    function mount() {
        if (wrap.parentElement) return;
        const eduRoot = document.querySelector('#education-root');
        const cw      = document.querySelector('.content-wrapper');
        if      (eduRoot?.parentElement) eduRoot.parentElement.insertBefore(wrap, eduRoot);
        else if (cw)                     cw.insertBefore(wrap, cw.firstChild);
        else                             document.body.insertBefore(wrap, document.body.firstChild);
    }

    mount();
    if (!wrap.parentElement || wrap.parentElement === document.body) {
        const obs = new MutationObserver(() => { if (document.querySelector('#education-root')) { obs.disconnect(); mount(); }});
        obs.observe(document.body, {childList:true, subtree:true});
        setTimeout(() => obs.disconnect(), 8000);
    }

    // ── Render scaffold ───────────────────────────────────────────────────────
    function buildHTML() {
        wrap.innerHTML = `
<div class="ep-hdr" id="ep-hdr"><span class="ep-title">🎓 Education Planner</span><span class="ep-tog">▼</span></div>
<div class="ep-body">

<div class="ep-red-row">
  <label>Reduction %</label>
  <input type="number" id="ep-red" min="0" max="40" step="1" value="${reduction}">
  <div class="ep-red-chips" id="ep-chips"></div>
</div>
<div class="ep-btns">
  <button class="ep-btn ep-btn-api" id="ep-api-btn">⟳ Auto-fill API</button>
  <button class="ep-btn ep-btn-dom" id="ep-dom-btn">↺ Recalculate</button>
</div>
<div class="ep-st" id="ep-st"></div>
<div style="margin:6px 0 0;padding:8px 10px;background:#141414;border:1px solid #252525;border-radius:4px;font-size:11px;color:#556;line-height:1.7">
  <strong style="color:#668">API key needs "Education" access.</strong><br>
  Torn → Preferences → API Keys → <em>edit your key</em> → tick <strong>Education</strong> → Save.<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 with Education access →</a>
</div>

<div id="ep-cur-sec" style="display:none">
  <div class="ep-sec">Current Course</div>
  <div class="ep-cur" id="ep-cur"></div>
</div>

<div class="ep-sec">Queue Planner</div>
<div id="ep-q-list"></div>
<div id="ep-q-sum"></div>
<div class="ep-pick" id="ep-pick">
  <div class="ep-pick-hdr" id="ep-pick-hdr">
    <span class="ep-pick-title">+ Add Courses</span>
    <span class="ep-pick-tog">▼</span>
  </div>
  <div class="ep-pick-body" id="ep-pick-body"></div>
</div>

<div class="ep-sec" style="margin-top:14px">All Subjects</div>
<div style="font-size:11px;color:#4a5a6a;margin-bottom:8px">✓ done · ▶ active · ○ available · 🔒 locked</div>
<div id="ep-subjs"></div>

<div class="ep-sec" style="margin-top:14px">Summary</div>
<div id="ep-sum"></div>

</div>`;
    }

    buildHTML();
    if (_load("ep_collapsed","no") !== "yes") wrap.classList.add("open");

    // ── Wire persistent events ────────────────────────────────────────────────
    q("#ep-hdr").addEventListener("click", () => {
        const o = wrap.classList.toggle("open");
        _save("ep_collapsed", o ? "no" : "yes");
    });
    q("#ep-red").addEventListener("input", e => {
        reduction = parseFloat(e.target.value) || 0;
        _save("ep_reduction", String(reduction));
        renderAll();
    });
    q("#ep-api-btn").addEventListener("click", doAutofill);
    q("#ep-dom-btn").addEventListener("click", () => { domDetect(); renderAll(); });
    q("#ep-pick-hdr").addEventListener("click", () => q("#ep-pick").classList.toggle("open"));

    // ── Render functions ──────────────────────────────────────────────────────

    function renderAll() {
        renderCurrent();
        renderQueue();
        renderPickerUpdate();  // in-place update, preserves open state
        renderSubjects();
        renderSummary();
        renderChips();
    }

    function renderChips() {
        const el = q("#ep-chips");
        if (!el) return;
        const meritPct = _load("ep_merit_pct", 0);
        const hasPrinc = _load("ep_has_princ", "0") === "1";
        const hasWSU   = _load("ep_has_wsu",   "0") === "1";
        let html = "";
        if (meritPct > 0) html += `<span class="ep-chip merit">Merits ${meritPct}%</span>`;
        if (hasPrinc)     html += `<span class="ep-chip princ">Principal 10%</span>`;
        if (hasWSU)       html += `<span class="ep-chip wsu">WSU 10%</span>`;
        el.innerHTML = html;
    }

    function showStatus(type, msg) {
        const el = q("#ep-st");
        if (!el) return;
        el.className = `ep-st ${type}`;
        el.textContent = msg;
    }

    // ── Current course ────────────────────────────────────────────────────────
    function renderCurrent() {
        const sec = q("#ep-cur-sec");
        const box = q("#ep-cur");
        if (!sec || !box) return;
        if (!currentCourse) { sec.style.display = "none"; return; }
        sec.style.display = "";
        const c = COURSE_BY_ID[currentCourse.id];
        if (!c) { box.innerHTML = `<div class="ep-cur-name">${currentCourse.id}</div>`; return; }
        const base   = c.days;
        const red    = applyRed(base);
        const remD   = currentCourse.timeLeft > 0 ? currentCourse.timeLeft / 86400 : 0;
        const pct    = red > 0 ? Math.min(100, (1 - remD / red) * 100) : 100;
        const subj   = SUBJECTS[c.subj];
        box.innerHTML = `
<div class="ep-cur-name">${c.name}</div>
<div class="ep-cur-sub" style="color:${subj.color}">${subj.label}</div>
${c.perk ? `<div class="ep-cur-perk">🎁 ${c.perk}</div>` : ""}
<div class="ep-cur-time">${remD > 0 ? `⏱ ${daysToStr(remD)} remaining — done ${dateIn(remD)}` : "✓ Complete"}</div>
<div class="ep-bar"><div class="ep-bar-fill" style="width:${pct.toFixed(1)}%"></div></div>
<div class="ep-bar-note">Base ${base}d · ${reduction}% reduction → ${fmtD(red)}d total</div>`;
    }

    // ── Queue ─────────────────────────────────────────────────────────────────
    function renderQueue() {
        const listEl = q("#ep-q-list");
        const sumEl  = q("#ep-q-sum");
        if (!listEl) return;

        if (!queue.length) {
            listEl.innerHTML = `<div class="ep-q-empty">No courses queued — use Add Courses below</div>`;
            sumEl.innerHTML  = "";
            return;
        }

        const curOff = currentCourse?.timeLeft > 0 ? currentCourse.timeLeft / 86400 : 0;
        let cumul = curOff, html = "";

        queue.forEach((id, i) => {
            const c = COURSE_BY_ID[id];
            if (!c) return;
            const d    = applyRed(c.days);
            cumul     += d;
            const subj = SUBJECTS[c.subj];
            const flag = completed.has(id) ? "✓ " : !canEnroll(id) ? "🔒 " : "";

            if (i > 0 && COURSE_BY_ID[queue[i-1]]?.subj !== c.subj)
                html += `<div style="font-size:9px;color:#2a2a4a;text-align:center;padding:2px 0">· · ·</div>`;

            html += `
<div class="ep-q-item">
  <div class="ep-q-num">${i+1}</div>
  <div class="ep-q-body">
    <div class="ep-q-name">${flag}${c.name}</div>
    <div class="ep-q-info" style="color:${subj.color}55">${subj.label}${c.perk ? " · " + c.perk : ""}</div>
  </div>
  <div class="ep-q-right">
    <div class="ep-q-days">${daysToStr(d)}</div>
    <button class="ep-q-del" data-id="${id}">✕</button>
  </div>
</div>
<div class="ep-q-date">→ ${dateIn(cumul)}</div>`;
        });

        listEl.innerHTML = html;
        listEl.querySelectorAll(".ep-q-del").forEach(btn =>
            btn.addEventListener("click", () => {
                queue = queue.filter(id => id !== btn.dataset.id);
                saveQueue(queue);
                renderQueue();
                renderPickerUpdate();
                renderSummary();
            })
        );

        const totalD = queue.reduce((s, id) => s + (COURSE_BY_ID[id] ? applyRed(COURSE_BY_ID[id].days) : 0), 0);
        sumEl.innerHTML = `
<div class="ep-q-total">
  <span class="ep-q-total-l">${queue.length} course${queue.length!==1?"s":""} · ${daysToStr(totalD)}</span>
  <span class="ep-q-total-r">done ${dateIn(curOff + totalD)}</span>
</div>`;
    }

    // ── Picker — build once, update in-place ──────────────────────────────────
    let pickerBuilt = false;

    function renderPickerBuild() {
        const body = q("#ep-pick-body");
        if (!body) return;
        let html = "";
        for (const [subj, meta] of Object.entries(SUBJECTS)) {
            const courses = BY_SUBJECT[subj] || [];
            html += `<div class="ep-ps" id="ep-ps-${subj}${pickerOpen.has(subj) ? " open" : ""}">
<div class="ep-ps-hdr">
  <span class="ep-ps-name" data-subj="${subj}" style="color:${meta.color}">${meta.label}</span>
  <span class="ep-ps-info" id="ep-ps-info-${subj}"></span>
  <button class="ep-ps-addall" data-subj="${subj}">+ All</button>
</div>
<div class="ep-pc-list" id="ep-pcl-${subj}">`;
            courses.forEach(c => {
                html += `<div class="ep-pc" id="ep-pc-${c.id}" data-id="${c.id}">
  <div class="ep-pc-icon" id="ep-pc-icon-${c.id}"></div>
  <div class="ep-pc-body">
    <div class="ep-pc-name">${c.name}</div>
    ${c.perk ? `<div class="ep-pc-perk">${c.perk}</div>` : ""}
  </div>
  <div class="ep-pc-days" id="ep-pc-days-${c.id}"></div>
</div>`;
            });
            html += `</div></div>`;
        }
        body.innerHTML = html;
        pickerBuilt = true;

        // Wire subject header clicks (name only — not the button)
        body.querySelectorAll(".ep-ps-name").forEach(el =>
            el.addEventListener("click", () => {
                const subj = el.dataset.subj;
                const card = q(`#ep-ps-${subj}`);
                if (!card) return;
                const isOpen = card.classList.toggle("open");
                if (isOpen) pickerOpen.add(subj); else pickerOpen.delete(subj);
            })
        );

        // Wire + All buttons
        body.querySelectorAll(".ep-ps-addall").forEach(btn =>
            btn.addEventListener("click", e => { e.stopPropagation(); queueSubject(btn.dataset.subj); })
        );

        // Wire course row clicks
        body.querySelectorAll(".ep-pc").forEach(row =>
            row.addEventListener("click", () => {
                const id = row.dataset.id;
                if (completed.has(id)) return;
                if (queue.includes(id)) {
                    queue = queue.filter(q => q !== id);
                } else {
                    queue.push(id);
                }
                saveQueue(queue);
                renderQueue();
                renderPickerUpdate(); // only updates classes/text, no DOM rebuild
                renderSummary();
            })
        );

        renderPickerUpdate();
    }

    function renderPickerUpdate() {
        if (!pickerBuilt) { renderPickerBuild(); return; }
        // Update each course row's classes and icon without rebuilding DOM
        for (const [subj] of Object.entries(SUBJECTS)) {
            const courses  = BY_SUBJECT[subj] || [];
            const notDone  = courses.filter(c => !completed.has(c.id));
            const allQ     = notDone.length > 0 && notDone.every(c => queue.includes(c.id));
            const doneCount= courses.filter(c => completed.has(c.id)).length;
            const infoEl   = q(`#ep-ps-info-${subj}`);
            if (infoEl) infoEl.textContent = `${doneCount}/${courses.length}`;

            courses.forEach(c => {
                const row  = q(`#ep-pc-${c.id}`);
                const icon = q(`#ep-pc-icon-${c.id}`);
                const days = q(`#ep-pc-days-${c.id}`);
                if (!row) return;
                const isDone   = completed.has(c.id);
                const isQueued = queue.includes(c.id);
                const isLocked = !canEnroll(c.id) && !isDone;

                row.className  = `ep-pc${isDone?" done":isQueued?" queued":isLocked?" locked":""}`;
                if (icon) icon.textContent = isDone ? "✓" : isQueued ? "⊕" : isLocked ? "🔒" : "○";
                if (days) days.textContent = isDone ? "done" : daysToStr(applyRed(c.days));
            });
        }
    }

    function queueSubject(subj) {
        let added = 0;
        for (const c of BY_SUBJECT[subj] || []) {
            if (!completed.has(c.id) && !queue.includes(c.id)) { queue.push(c.id); added++; }
        }
        if (added) { saveQueue(queue); renderQueue(); renderPickerUpdate(); renderSummary(); }
    }

    // ── All Subjects section ──────────────────────────────────────────────────
    function renderSubjects() {
        const el = q("#ep-subjs");
        if (!el) return;
        let html = "";
        for (const [subj, meta] of Object.entries(SUBJECTS)) {
            const courses = BY_SUBJECT[subj] || [];
            const done    = courses.filter(c => completed.has(c.id)).length;
            const total   = courses.length;
            const remD    = remSubjDays(subj);
            const pct     = total > 0 ? done/total*100 : 0;
            const bach    = courses.find(c => c.tier === 3);

            html += `<div class="ep-subj" id="ep-subj-${subj}">
<div class="ep-subj-hdr" data-subj="${subj}" style="background:${meta.color}14;border-bottom:1px solid ${meta.color}20">
  <div>
    <div style="font-size:13px;font-weight:bold;color:${meta.color}">${done===total?"✓ ":""}${meta.label}</div>
    <div style="font-size:11px;color:#557">${done}/${total} courses${bach&&completed.has(bach.id)?" · 🎓 done":""}</div>
  </div>
  <div class="ep-subj-r">
    <div class="ep-subj-days">${done===total?"✓":daysToStr(remD)}</div>
    <div class="ep-subj-tog">▼</div>
  </div>
</div>
<div class="ep-subj-bar"><div class="ep-subj-fill" style="width:${pct.toFixed(1)}%;background:${meta.color}80"></div></div>
<div class="ep-subj-body">`;

            courses.forEach(c => {
                const isDone   = completed.has(c.id);
                const isActive = currentCourse?.id === c.id;
                const isLocked = !canEnroll(c.id) && !isDone;
                const isQueued = queue.includes(c.id);
                const icon  = isDone?"✓":isActive?"▶":isLocked?"🔒":"○";
                const color = isDone?"#3a6a3a":isActive?"#5a5a9a":isLocked?"#3a3a4a":"#668";
                const qBadge= isQueued?` <span style="font-size:9px;color:#4a6a8a;background:#1a2030;border:1px solid #2a3a50;border-radius:3px;padding:0 3px">queued</span>`:"";
                html += `<div class="ep-cr">
  <div class="ep-cr-icon" style="color:${color}">${icon}</div>
  <div class="ep-cr-body">
    <div class="ep-cr-name" style="color:${color}">${c.name}${qBadge}</div>
    ${c.perk?`<div class="ep-cr-perk">${c.perk}</div>`:""}
  </div>
  <div class="ep-cr-days">${isDone?"✓":`${daysToStr(applyRed(c.days))}<div style="font-size:9px;color:#334">${c.days}d base</div>`}</div>
</div>`;
            });

            if (bach) html += `<div style="padding:7px 10px;font-size:11px;background:#0e1418;color:${meta.color}99;border-top:1px solid #1e2028">🎓 ${bach.perk}</div>`;
            html += `</div></div>`;
        }
        el.innerHTML = html;
        el.querySelectorAll(".ep-subj-hdr").forEach(hdr =>
            hdr.addEventListener("click", () => q(`#ep-subj-${hdr.dataset.subj}`)?.classList.toggle("open"))
        );
    }

    // ── Summary ───────────────────────────────────────────────────────────────
    function renderSummary() {
        const el = q("#ep-sum");
        if (!el) return;
        const done    = completed.size;
        const total   = COURSES.length;
        const degrees = Object.keys(SUBJECTS).filter(s => subjDone(s)).length;
        const remD    = COURSES.filter(c => !completed.has(c.id)).reduce((s,c) => s + applyRed(c.days), 0);
        const totalD  = applyRed(TOTAL_BASE_DAYS);
        const perks   = COURSES.filter(c => completed.has(c.id) && c.perk).map(c => c.perk);
        const avail   = COURSES.filter(c => !completed.has(c.id) && canEnroll(c.id) && c.id !== currentCourse?.id);

        let html = `<div class="ep-sg">
  <div class="ep-sb"><div class="ep-sb-l">Courses</div><div class="ep-sb-v">${done}/${total} <span style="font-size:11px;color:#445">(${(done/total*100).toFixed(1)}%)</span></div></div>
  <div class="ep-sb"><div class="ep-sb-l">Degrees</div><div class="ep-sb-v">${degrees}/${Object.keys(SUBJECTS).length}</div></div>
  <div class="ep-sb"><div class="ep-sb-l">Remaining</div><div class="ep-sb-v" style="font-size:12px">${daysToStr(remD)}</div></div>
  <div class="ep-sb"><div class="ep-sb-l">Finish</div><div class="ep-sb-v" style="font-size:11px">${dateIn(remD)}</div></div>
</div>
<div class="ep-row b" style="margin-top:8px"><span class="ep-rl">Base total</span><span class="ep-rv">${daysToStr(TOTAL_BASE_DAYS)}</span></div>
<div class="ep-row g"><span class="ep-rl">With ${reduction}% reduction</span><span class="ep-rv">${daysToStr(totalD)}</span></div>`;

        if (perks.length) {
            html += `<div class="ep-sec" style="margin-top:12px">Earned Bonuses (${perks.length})</div>`;
            perks.forEach(p => html += `<div style="padding:4px 10px;font-size:11px;color:#7a9a7a;background:#141e14;border-left:2px solid #2a5a2a;margin-top:3px;border-radius:0 3px 3px 0">🔓 ${p}</div>`);
        }

        if (avail.length) {
            html += `<div class="ep-sec" style="margin-top:12px">Available Now (${avail.length})</div>`;
            avail.slice(0,8).forEach(c => html += `<div class="ep-row"><span class="ep-rl">${c.name}</span><span class="ep-rv">${daysToStr(applyRed(c.days))}</span></div>`);
            if (avail.length > 8) html += `<div style="font-size:11px;color:#445;padding:4px 10px">…+${avail.length-8} more</div>`;
        }
        el.innerHTML = html;
    }

    // ── DOM auto-detect ───────────────────────────────────────────────────────
    function domDetect() {
        // Current course
        const btn = document.querySelector('[class*="goToCourseBtn"]');
        const cdEl = document.querySelector('.hasCountdown,[class*="hasCountdown"]');
        if (btn) {
            const name  = btn.textContent.trim();
            const match = COURSES.find(c => c.name.toLowerCase() === name.toLowerCase());
            if (match) {
                let t = 0;
                if (cdEl) {
                    const tx = cdEl.textContent;
                    const n = s => parseInt(tx.match(new RegExp(`(\\d+)\\s*${s}`))?.[1] || 0);
                    t = n("day")*86400 + n("hour")*3600 + n("minute")*60 + n("second");
                }
                currentCourse = { id: match.id, timeLeft: t };
            }
        }

        // Completed via slot positions
        let found = 0;
        document.querySelectorAll('[class*="categoryItem"]').forEach(item => {
            const titleEl = item.querySelector('[class*="categoryTitle"]');
            if (!titleEl) return;
            const label   = titleEl.textContent.trim();
            const subjKey = Object.entries(SUBJECTS).find(([,v]) => v.label === label)?.[0];
            if (!subjKey) return;
            const courses = BY_SUBJECT[subjKey] || [];
            item.querySelectorAll('[class*="courseWrapper"]').forEach((slot, idx) => {
                if (idx >= courses.length) return;
                const ind = slot.querySelector('[class*="courseIndicator"]');
                if (!ind) return;
                const cls  = ind.className;
                const done = cls.includes('Ghv3G') || cls.includes('completed___');
                const prog = cls.includes('a9M6f') || cls.includes('inProgress');
                if (done && !prog) { completed.add(courses[idx].id); found++; }
            });
        });

        if (found || currentCourse) saveCompleted(completed);
        return found > 0 || !!currentCourse;
    }

    // ── API auto-fill ─────────────────────────────────────────────────────────
    const WSU_ID = 25, WSU_MIN = 1000000;

    function doAutofill() {
        let key = (_load("apiKey","") || "").trim();
        if (key.length !== 16) {
            const k = prompt("Enter your Torn API key (16 chars, Limited Access or higher):");
            if (!k || k.trim().length !== 16) { showStatus("err","✗ Invalid API key."); return; }
            key = k.trim(); _save("apiKey", key);
        }
        showStatus("ok","⟳ Fetching from API…");

        // Single Torn API call — education+merits+perks+stocks merged
        GM_xmlhttpRequest({
            method:"GET",
            url:`https://api.torn.com/user/?selections=education,merits,perks,stocks&key=${key}&comment=EduPlan`,
            onload: r => {
                try {
                    const d = JSON.parse(r.responseText);
                    if (d.error) {
                        const code = d.error.code;
                        const msg  = d.error.error;
                        if (code === 16) {
                            showStatus("err",
                                `✗ Error 16: Key access level too low for Education data.\n\n` +
                                `Your key needs "education" permission.\n` +
                                `Go to: Torn → Preferences → API Keys → Edit your key → check "Education" → Save.\n` +
                                `Or delete your key and use the Auto-create link in the Gym widget ⚙ settings.`
                            );
                        } else {
                            showStatus("err", `✗ Error ${code}: ${msg}`);
                        }
                        return;
                    }
                    applyAPIData(d, d); // stocks are in same response
                } catch(e) { showStatus("err",`✗ Parse error: ${e.message}`); }
            },
            onerror: () => showStatus("err","✗ Network error.")
        });
    }

    function applyAPIData(d, stockData) {
        const filled = [], notes = [];

        // Current course
        if (d.education_current != null) {
            const raw = d.education_current;
            const match = COURSES.find(c => c.name.toLowerCase() === String(raw).toLowerCase().trim());
            if (match && d.education_timeleft != null) {
                currentCourse = { id: match.id, timeLeft: Number(d.education_timeleft) };
                filled.push(`current: ${match.name}`);
            } else if (typeof raw === "number") {
                notes.push(`course ID ${raw} resolved from DOM`);
            }
        }

        // Completed courses
        if (d.education_completed) {
            const raw = Array.isArray(d.education_completed) ? d.education_completed : Object.values(d.education_completed);
            const matched = raw.map(item => COURSES.find(c => c.name.toLowerCase() === String(item).toLowerCase().trim())?.id).filter(Boolean);
            if (matched.length) { completed = new Set(matched); saveCompleted(completed); filled.push(`${matched.length} courses`); }
        }

        // Merits — Education Length: each point = 2%, max 10 pts = 20%
        let meritPct = 0;
        if (d.merits) {
            const key = Object.keys(d.merits).find(k => k.toLowerCase().includes("education") && k.toLowerCase().includes("length"));
            if (key) {
                const val = d.merits[key];
                const n = typeof val === "object" ? (val.current ?? val.level ?? val.value ?? 0) : Number(val);
                meritPct = Math.min(20, n * 2);
            }
        }

        // Perks — Principal job perk
        let hasPrinc = false;
        if (d.perks) {
            const flat = Object.values(d.perks).flat().map(p => String(p).toLowerCase());
            hasPrinc = flat.some(p => (p.includes("education") && p.includes("10") && p.includes("reduc")) || p.includes("principal"));
            // Debug: save raw perk strings
            _save("ep_debug_perks", JSON.stringify(flat.slice(0,20)));
        }

        // WSU stock — check portfolio directly
        let hasWSU = false;
        if (stockData?.stocks) {
            const wsu = stockData.stocks[String(WSU_ID)] ?? stockData.stocks[WSU_ID];
            if (wsu) {
                const shares = Array.isArray(wsu) ? wsu.reduce((s,b) => s + (b.shares ?? b.quantity ?? 0), 0) : (wsu.shares ?? wsu.total_shares ?? 0);
                const activeBenefit = wsu.benefit?.active === true || wsu.benefit?.active === 1;
                hasWSU = activeBenefit || shares >= WSU_MIN;
            }
        }

        // Persist reduction sources for chips display
        _save("ep_merit_pct", meritPct);
        _save("ep_has_princ", hasPrinc ? "1" : "0");
        _save("ep_has_wsu",   hasWSU   ? "1" : "0");

        // Apply total reduction
        const total = meritPct + (hasPrinc ? 10 : 0) + (hasWSU ? 10 : 0);
        if (total > 0) {
            reduction = total;
            _save("ep_reduction", String(reduction));
            const inp = q("#ep-red");
            if (inp) inp.value = reduction;

        cache.set('edu_reduction', { reduction: total, meritPct, hasPrinc, hasWSU });
            const parts = [];
            if (meritPct) parts.push(`merits ${meritPct}%`);
            if (hasPrinc) parts.push("Principal 10%");
            if (hasWSU)   parts.push("WSU 10%");
            filled.push(`${parts.join(" + ")} = ${reduction}%`);
        } else {
            notes.push("no reductions found");
        }

        // Also run DOM detect to catch anything API missed
        domDetect();

        const msg = (filled.length ? "✓ " + filled.join(" · ") : "") + (notes.length ? "  " + notes.join(" — ") : "");
        showStatus(filled.length ? "ok" : "warn", msg.trim() || "Done");
        renderAll();
    }

    // ── Init ──────────────────────────────────────────────────────────────────

    // Check cross-page cache for reduction data from gym session
    (function() {
        const cached = cache.get('edu_reduction');
        if (cached && typeof cached.reduction === 'number') {
            if (!_load('ep_merit_pct', 0) && cached.meritPct > 0) {
                _save('ep_merit_pct', cached.meritPct);
                _save('ep_has_princ', cached.hasPrinc ? '1' : '0');
                _save('ep_has_wsu',   cached.hasWSU   ? '1' : '0');
                reduction = cached.reduction;
                _save('ep_reduction', String(reduction));
            }
        }
    })();
    renderAll();

    // Auto-detect from DOM on load, then auto-fill from API if key saved
    setTimeout(() => {
        const changed = domDetect();
        if (changed) renderAll();

        const savedKey = (_load("apiKey","") || "").trim();
        const lastTs   = parseInt(_load("lastAutofillTs", "0")) || 0;
        if (savedKey.length === 16 && (Date.now() - lastTs) > 30 * 60000) {
            // Silent auto-fill only if >30min since last autofill (saves Torn API calls)
            doAutofill();
        }
    }, 1200);

    } // end eduModule

})();