Choco Jump Helper

Times your Torn Choco Jump cycle (Xanax / BBC / Ecstasy / Points refill) and tells you what to do now vs wait for.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Choco Jump Helper
// @namespace    https://github.com/marcin/choco-jump-helper
// @version      0.2.0
// @description  Times your Torn Choco Jump cycle (Xanax / BBC / Ecstasy / Points refill) and tells you what to do now vs wait for.
// @author       Nicram
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // -------------------------------------------------------------------------
    // Constants — Choco Jump timing (from HOU5E's schedule + the guide)
    // -------------------------------------------------------------------------
    // Source of truth: HOU5E's Single & Double Choco Jump schedule charts.
    //
    //   Single jump (1×/day):  Xanax → 3.5h Ecstasy CD → 48 BBC + E → train →
    //                          refill → train.  Booster CD 24h. Optional 2nd
    //                          Xanax inside an ~6.5h window starting at the
    //                          Ecstasy-CD end (practical: from when 1st Xanax
    //                          CD ends, ~3h long).
    //   Double jump (2×/day):  Same shape but only 24 BBC → 12h booster CD,
    //                          so the cycle repeats once per ~12h. No optional
    //                          Xanax (no time for it in the half cycle).
    //
    // HOU5E's chart assumes a 7h Xanax CD (e.g. with the Insomnia perk).
    // Base Torn is 8h; we default to 8h and let the user adjust if they have
    // the perk. The actual wait time comes from the API in any case — these
    // constants only drive the optional-Xanax window planning.
    const T = {
        XANAX_CD_S: 8 * 3600,           // default Xanax cooldown (base Torn)
        ECSTASY_CD_S: 3.5 * 3600,       // 3.5h drug CD after Xanax → BBC/Ecstasy
        BOOSTER_CD_FULL_S: 24 * 3600,   // Full (Single): 48 BBC → 24h booster CD
        BOOSTER_CD_HALF_S: 12 * 3600,   // Half (Double): 24 BBC → 12h booster CD
        OPTIONAL_XAN_END_AFTER_XAN_S: 10 * 3600, // Per HOU5E's chart, the Optional
                                                 //   Daytime Xanax Window closes at
                                                 //   hour 10 of the cycle (i.e. 10h
                                                 //   after the 1st Xanax was popped).
        TRAIN_MIN_ENERGY: 10,           // Each gym train costs ≥10 energy.
    };

    const DEFAULTS = {
        apiKey: '',
        mode: 'full',          // 'full' | 'half'
        optionalXanax: false,
        lastSnapshot: null,    // { fetchedAt, energy, drugCD, boosterCD, medicalCD, refills }
        firstXanaxAt: 0,       // unix s — tracked locally for optional Xanax window
        availableFrom: 8,      // local hour, inclusive (0–23). Player is "online-able" from this hour…
        availableTo: 23,       // …until this hour (exclusive). Wraps past midnight if To <= From.
    };

    // -------------------------------------------------------------------------
    // Storage helpers
    // -------------------------------------------------------------------------
    function load(key) {
        const v = GM_getValue(key, undefined);
        return v === undefined ? DEFAULTS[key] : v;
    }
    function save(key, value) {
        GM_setValue(key, value);
    }

    const settings = {
        apiKey: load('apiKey'),
        mode: load('mode'),
        optionalXanax: load('optionalXanax'),
        firstXanaxAt: load('firstXanaxAt'),
        availableFrom: load('availableFrom'),
        availableTo: load('availableTo'),
    };
    let snapshot = load('lastSnapshot');

    // -------------------------------------------------------------------------
    // Torn API client
    // -------------------------------------------------------------------------
    function fetchState() {
        if (!settings.apiKey) return Promise.resolve(null);
        // `bars` includes both energy and happy in one call. `cooldowns` +
        // `refills` round out everything we need — all available on a Limited
        // (or even Public) key.
        const url = `https://api.torn.com/user/?selections=bars,cooldowns,refills&key=${encodeURIComponent(settings.apiKey)}`;
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                timeout: 10000,
                onload: (res) => {
                    try {
                        const data = JSON.parse(res.responseText);
                        if (data.error) {
                            console.warn('[CJH] API error:', data.error);
                            resolve(null);
                            return;
                        }
                        const now = Math.floor(Date.now() / 1000);
                        const snap = {
                            fetchedAt: now,
                            energy: data.energy || { current: 0, maximum: 150, fulltime: 0, increment: 5, interval: 600 },
                            happy: data.happy || { current: 0, maximum: 5025, fulltime: 0, increment: 5, interval: 900 },
                            drugCD: data.cooldowns ? data.cooldowns.drug : 0,
                            boosterCD: data.cooldowns ? data.cooldowns.booster : 0,
                            medicalCD: data.cooldowns ? data.cooldowns.medical : 0,
                            refills: data.refills || {},
                        };
                        save('lastSnapshot', snap);
                        resolve(snap);
                    } catch (e) {
                        console.warn('[CJH] parse error', e);
                        resolve(null);
                    }
                },
                onerror: () => resolve(null),
                ontimeout: () => resolve(null),
            });
        });
    }

    // Project the cached snapshot forward by `elapsed` seconds so the banner
    // ticks smoothly between API polls.
    function projectSnapshot(snap, nowS) {
        if (!snap) return null;
        const elapsed = Math.max(0, nowS - snap.fetchedAt);
        const energyRegenPer600s = (snap.energy.increment || 5);
        const interval = snap.energy.interval || 600;
        const projectedCurrent = Math.min(
            snap.energy.maximum,
            snap.energy.current + Math.floor((elapsed / interval) * energyRegenPer600s)
        );
        const projectedFulltime = Math.max(0, snap.energy.fulltime - elapsed);

        // Happy projection. After eating BBC + popping Ecstasy, happy is
        // *over* its maximum (e.g. 30k vs 5k cap) — that overcap is the
        // signal that the gym-gain "happy jump" boost is live. When over
        // max, happy drains toward max at the same regen rate it would
        // otherwise climb at.
        const happy = snap.happy || { current: 0, maximum: 5025, increment: 5, interval: 900 };
        const happySecsPerUnit = happy.increment > 0 ? (happy.interval / happy.increment) : 180;
        const happyDelta = Math.floor(elapsed / happySecsPerUnit);
        const projectedHappy = happy.current > happy.maximum
            ? Math.max(happy.maximum, happy.current - happyDelta)
            : Math.min(happy.maximum, happy.current + happyDelta);

        return {
            ...snap,
            energy: {
                ...snap.energy,
                current: projectedCurrent,
                fulltime: projectedFulltime,
            },
            happy: {
                ...happy,
                current: projectedHappy,
                fulltime: Math.max(0, (happy.fulltime || 0) - elapsed),
            },
            drugCD: Math.max(0, snap.drugCD - elapsed),
            boosterCD: Math.max(0, snap.boosterCD - elapsed),
            medicalCD: Math.max(0, snap.medicalCD - elapsed),
        };
    }

    // -------------------------------------------------------------------------
    // Availability helpers
    // -------------------------------------------------------------------------
    // The player declares an "online window" (local hours). The engine uses it
    // to decide when to recommend popping Xanax, because the cycle requires the
    // player back online ~3.5h later to eat BBC + pop Ecstasy + train.
    //
    // Window is [from, to). If to <= from we treat it as wrapping past midnight
    // (e.g. 22 → 06 means 22:00–06:00 next day).
    function isInWindow(date, from, to) {
        const h = date.getHours() + date.getMinutes() / 60;
        if (from === to) return true; // 24h availability
        if (from < to) return h >= from && h < to;
        return h >= from || h < to;   // wrap-around
    }

    // Returns Date of the next moment the window is "open" at-or-after `from`.
    // If currently open, returns `from` itself.
    function nextWindowOpen(from, opts) {
        if (isInWindow(from, opts.availableFrom, opts.availableTo)) return new Date(from);
        const d = new Date(from);
        // Step forward in 15-minute increments — cheap and exact enough.
        for (let i = 0; i < 24 * 4; i++) {
            d.setMinutes(d.getMinutes() + 15);
            if (isInWindow(d, opts.availableFrom, opts.availableTo)) {
                // Snap back to the exact hour boundary if we just crossed it.
                d.setMinutes(0, 0, 0);
                return d;
            }
        }
        return new Date(from); // fallback
    }

    function fmtClock(date) {
        return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
    }

    // Like fmtClock, but tags non-today dates with a weekday or "tmrw" suffix so
    // "next jump at 04:41" is unambiguous when it's actually tomorrow morning.
    function fmtFuture(date) {
        const now = new Date();
        if (date.toDateString() === now.toDateString()) return fmtClock(date);
        const oneDay = 24 * 3600 * 1000;
        const startToday = new Date(now); startToday.setHours(0, 0, 0, 0);
        const startThat = new Date(date); startThat.setHours(0, 0, 0, 0);
        const daysAhead = Math.round((startThat - startToday) / oneDay);
        if (daysAhead === 1) return `${fmtClock(date)} (tmrw)`;
        return `${date.toLocaleDateString(undefined, { weekday: 'short' })} ${fmtClock(date)}`;
    }

    // Returns the next Date strictly after `after` whose local clock hits `hour:00`.
    function nextClockHour(hour, after) {
        const d = new Date(after);
        d.setHours(hour, 0, 0, 0);
        if (d <= after) d.setDate(d.getDate() + 1);
        return d;
    }

    // Sub-engine: we're mid-cycle with the booster active. Decide between training,
    // popping the optional 2nd Xanax (Full method only), using the daily refill,
    // or just resting until the booster expires.
    function trainOrRestDecision(state, opts, details) {
        const refillUsed = !!state.refills.energy_refill_used;
        const canTrain = state.energy.current >= T.TRAIN_MIN_ENERGY;
        const nowS = Math.floor(Date.now() / 1000);
        // Happy overcap is the direct signal that the BBC + Ecstasy "happy
        // jump" boost is currently live. Booster CD only tells us BBC was
        // eaten *recently* — happy decays over time, so the boost can be
        // gone well before the 24h booster CD ends.
        const happyCur = state.happy ? state.happy.current : 0;
        const happyMax = state.happy ? state.happy.maximum : 0;
        const happyOverCap = happyCur > happyMax;
        const happyDeltaOver = happyOverCap ? (happyCur - happyMax) : 0;

        // Optional 2nd Xanax window — Full (Single) method only.
        // We derive firstXanaxAt from the live booster CD so the user doesn't
        // have to manually log when they popped Xanax. The chart's "Optional
        // Daytime Xanax Window" closes at hour 10 of the cycle (firstXanax+10h).
        // Opens once the 1st Xanax CD has actually ended (drug CD already 0).
        if (opts.mode === 'full' && opts.optionalXanax && state.drugCD === 0) {
            const derivedFirstXanaxAt = opts.firstXanaxAt
                || (nowS - (T.BOOSTER_CD_FULL_S - state.boosterCD) - T.ECSTASY_CD_S);
            const opensAt = derivedFirstXanaxAt + T.XANAX_CD_S;
            const closesAt = derivedFirstXanaxAt + T.OPTIONAL_XAN_END_AFTER_XAN_S;
            if (nowS >= opensAt && nowS <= closesAt) {
                return {
                    phase: 'OPTIONAL_XAN_WINDOW',
                    title: 'Bonus Xanax',
                    action: `Optional Daytime Xanax window is OPEN (closes ~${fmtClock(new Date(closesAt * 1000))}). Pop a 2nd Xanax for +250 extra energy to train through, then keep training.`,
                    color: 'go',
                    etaS: closesAt - nowS,
                    details,
                };
            }
        }

        // Compute the bedtime-Xanax stacking plan once. It applies to both the
        // "still training" and "winding down" branches below — the player needs
        // to know the stop-training time even while POST_REFILL_TRAIN is active.
        const restSecs = Math.max(0, state.boosterCD);
        const boosterEndAt = new Date((nowS + restSecs) * 1000);
        const stack = buildStackPlan(state, opts, nowS, boosterEndAt);

        if (canTrain) {
            let action;
            let etaS = restSecs;
            let color = 'go';

            if (stack && stack.applicable && refillUsed) {
                // Wind-down training: tell the player when to stop training so
                // energy can passively regen to cap before bedtime.
                if (stack.deficit === 0) {
                    action = `Energy is at cap. Don't train — let it sit until ${fmtClock(stack.bedTime)}, then pop Xanax to stack to 400.${stack.bleedNote}`;
                    color = 'wait';
                    etaS = stack.bedLeadS;
                } else if (stack.stopLeadS > 60) {
                    action = `Train any spare energy until ~${fmtClock(stack.stopTrainingAt)} (${fmtDur(stack.stopLeadS)} from now) — regular gains, BBC happy boost is long gone. Then STOP and let energy regen to ${state.energy.maximum} by ${fmtClock(stack.bedTime)}. Pop Xanax then to stack to 400.${stack.bleedNote}`;
                    etaS = stack.stopLeadS;
                } else if (stack.stopLeadS > -30 * 60) {
                    action = `Stop training now and let energy fill to ${state.energy.maximum} by ${fmtClock(stack.bedTime)}. Pop Xanax then to stack to 400.${stack.bleedNote}`;
                    color = 'wait';
                    etaS = stack.bedLeadS;
                } else {
                    action = `⚠ Past optimal stop point. Currently you'd reach ~${stack.energyAtBedIfStopNow}/${state.energy.maximum} by ${fmtClock(stack.bedTime)} (stack ~${stack.stackAtBedIfStopNow}, not the full 400). Stop training now to save what you can.${stack.bleedNote}`;
                    color = 'warn';
                    etaS = stack.bedLeadS;
                }
            } else if (refillUsed) {
                if (happyOverCap) {
                    action = `Happy boost still live (+${happyDeltaOver} over cap). Train remaining energy now — cycle wraps once energy drops below 10.`;
                } else {
                    action = '⚠ Happy boost has worn off — training now gives regular gym gains, not the happy-jump multiplier. Train remaining energy anyway, cycle wraps once energy drops below 10.';
                }
            } else {
                // The big moment: post-BBC + Ecstasy, happy is doubled,
                // gym gains are massive. Happy overcap is the live signal.
                if (happyOverCap) {
                    action = `Happy jump LIVE — happy is at ${happyCur} (+${happyDeltaOver} over cap). Train ${state.energy.current} energy down NOW, then USE POINTS REFILL → train again.`;
                } else if (state.happy && happyMax > 0) {
                    action = `⚠ Happy is back to baseline (${happyCur}/${happyMax}) — the happy-jump window is gone. Train + refill anyway to clear the cycle, but gym gains will be regular.`;
                } else {
                    action = `Train your ${state.energy.current} energy down, then USE POINTS REFILL → train again.`;
                }
            }

            return {
                phase: refillUsed ? 'POST_REFILL_TRAIN' : 'READY_TO_TRAIN',
                title: refillUsed ? 'Train' : 'Train + Refill',
                action,
                color,
                etaS,
                details,
            };
        }

        // Energy under the train minimum.
        if (!refillUsed) {
            return {
                phase: 'USE_REFILL',
                title: 'Use Refill',
                action: `Energy is at ${state.energy.current} (< 10, can’t train). Use your daily Points refill (+150 energy) then train the rest.`,
                color: 'go',
                etaS: state.boosterCD,
                details,
            };
        }

        // Energy spent, refill used. If a stack plan applies, route into the
        // bedtime Xanax phases. Otherwise (half mode / drug CD active / tail),
        // fall through to the plain rest message at the bottom.
        if (stack && stack.applicable) {
            if (stack.boosterEndsBeforeBed) {
                return {
                    phase: 'AWAKE_WAIT_FOR_BOOSTER',
                    title: 'Train Regen',
                    action: `Booster CD ends at ${fmtClock(boosterEndAt)} — still inside your online hours. No bedtime Xanax needed; train regenerating energy as it comes (regular gains, BBC happy boost is long gone) and start the next cycle when booster clears.`,
                    color: 'idle',
                    etaS: restSecs,
                    details,
                };
            }

            if (stack.bedLeadS <= 5 * 60) {
                const stackNow = Math.min(state.energy.maximum, state.energy.current) + 250;
                const stackNote = state.energy.current >= state.energy.maximum
                    ? `Energy is full — stacks to ${stackNow}.`
                    : `⚠ Energy only at ${state.energy.current}/${state.energy.maximum} — stacks to ${stackNow} (not the full 400). Next time, stop training earlier so it regens to cap by bedtime.`;
                return {
                    phase: 'STACK_XAN_NOW',
                    title: 'Stack Xanax',
                    action: `It's bedtime. ${stackNote} Drug CD ends ${fmtClock(stack.xanCdOver)}, booster ends ${fmtClock(boosterEndAt)} — both during sleep.${stack.bleedNote}`,
                    color: 'go',
                    etaS: 0,
                    details,
                };
            }

            // Energy under train minimum but not bedtime yet — just wait,
            // passive regen will fill the bar.
            let action;
            let color = 'wait';
            if (stack.deficit === 0) {
                action = `Energy is at cap. Let it sit until ${fmtClock(stack.bedTime)}, then pop Xanax to stack to 400.${stack.bleedNote}`;
            } else if (stack.energyAtBedIfStopNow >= state.energy.maximum) {
                action = `Wait for energy to regen to ${state.energy.maximum} by ${fmtClock(stack.bedTime)}, then pop Xanax to stack to 400. Booster ends ${fmtClock(boosterEndAt)} during sleep.${stack.bleedNote}`;
            } else {
                action = `⚠ Energy won't fully refill by ${fmtClock(stack.bedTime)} (will reach ~${stack.energyAtBedIfStopNow}/${state.energy.maximum}). Xanax will stack to ~${stack.stackAtBedIfStopNow} instead of 400.${stack.bleedNote}`;
                color = 'warn';
            }
            return {
                phase: 'WAIT_STACK_XAN',
                title: 'Wind Down',
                action,
                color,
                etaS: stack.bedLeadS,
                details,
            };
        }

        // Final tail (<30min, half mode, or drug CD still active) — pure rest.
        return {
            phase: 'CYCLE_DONE_REST',
            title: 'Rest',
            action: `Nothing left to do (energy ${state.energy.current}/${state.energy.maximum}). Next Choco Jump in ${fmtDur(restSecs)} when booster CD clears (${fmtClock(boosterEndAt)}).`,
            color: 'idle',
            etaS: restSecs,
            details,
        };
    }

    // Builds the bedtime-Xanax-stacking plan. Returns an object describing
    // when the player should stop training, when bedtime is, energy projections,
    // etc. — used by both the "still training" and "winding down" code paths.
    // .applicable is false when the plan doesn't apply (wrong mode, drug CD,
    // booster too close to expiry, or booster ends while player is still awake).
    function buildStackPlan(state, opts, nowS, boosterEndAt) {
        const nowMs = nowS * 1000;
        const restSecs = Math.max(0, state.boosterCD);
        if (opts.mode !== 'full' || state.drugCD !== 0 || restSecs < 30 * 60) {
            return { applicable: false };
        }

        const bedTime = nextClockHour(opts.availableTo, new Date(nowMs));
        const wakeTime = nextClockHour(opts.availableFrom, bedTime);
        const boosterEndsBeforeBed = boosterEndAt <= bedTime;

        const sleepDurS = (wakeTime - bedTime) / 1000;
        const xanCdOver = new Date(bedTime.getTime() + T.XANAX_CD_S * 1000);
        const drugBleedsIntoMorningS = Math.max(0, (xanCdOver - wakeTime) / 1000);
        const bleedNote = drugBleedsIntoMorningS > 60
            ? ` ⚠ Sleep window is ${fmtDur(sleepDurS)}, shorter than the 8h Xanax CD — drug CD will overhang into your morning by ${fmtDur(drugBleedsIntoMorningS)}.`
            : '';

        const secsPerEnergy = state.energy.increment > 0
            ? state.energy.interval / state.energy.increment
            : 120; // default Torn: +5 per 600s → 120s per +1
        const deficit = Math.max(0, state.energy.maximum - state.energy.current);
        const timeToFullS = deficit * secsPerEnergy;
        const stopTrainingAt = new Date(bedTime.getTime() - timeToFullS * 1000);
        const stopLeadS = Math.floor((stopTrainingAt.getTime() - nowMs) / 1000);
        const bedLeadS = (bedTime.getTime() - nowMs) / 1000;

        const passiveRegenS = Math.max(0, bedLeadS);
        const energyAtBedIfStopNow = Math.min(
            state.energy.maximum,
            state.energy.current + Math.floor(passiveRegenS / secsPerEnergy)
        );
        const stackAtBedIfStopNow = energyAtBedIfStopNow + 250;

        return {
            applicable: true,
            boosterEndsBeforeBed,
            bedTime, wakeTime, xanCdOver, bleedNote,
            bedLeadS, stopTrainingAt, stopLeadS,
            deficit, energyAtBedIfStopNow, stackAtBedIfStopNow,
        };
    }

    // -------------------------------------------------------------------------
    // Phase engine — pure function
    // -------------------------------------------------------------------------
    // Returns { phase, title, action, color, etaS, details: [{label, secs}] }
    //   color: 'go' (green = act now) | 'wait' (amber) | 'idle' (grey) | 'warn' (red)
    function decide(state, opts, nowS) {
        if (!state) {
            return {
                phase: 'NO_DATA',
                title: 'Setup',
                action: 'Set your Torn API key to begin (click ⚙ on the right).',
                color: 'warn',
                etaS: 0,
                details: [],
            };
        }

        const boosterTarget = opts.mode === 'half' ? T.BOOSTER_CD_HALF_S : T.BOOSTER_CD_FULL_S;
        const bbcCount = opts.mode === 'half' ? '24' : '48';
        const energyFull = state.energy.current >= state.energy.maximum;
        const hasDrugCD = state.drugCD > 0;
        const hasBoosterCD = state.boosterCD > 0;
        const refillUsed = !!state.refills.energy_refill_used;

        // Happy: when overcap (post-BBC + Ecstasy), the value is the live
        // happy-jump boost. Annotate with a lightning bolt so it's obvious.
        const happyCur = state.happy ? state.happy.current : 0;
        const happyMax = state.happy ? state.happy.maximum : 0;
        const happyOverCap = happyCur > happyMax;
        const happyText = happyOverCap
            ? `${happyCur} ⚡`
            : `${happyCur}/${happyMax}`;

        const details = [
            { label: 'Energy', secs: state.energy.fulltime, text: `${state.energy.current}/${state.energy.maximum}` },
            { label: 'Happy', text: happyText },
            { label: 'Drug CD', secs: state.drugCD },
            { label: 'Booster CD', secs: state.boosterCD },
        ];

        // "Next jump" = earliest moment the next Choco Jump cycle can start
        //   (= when both booster and drug cooldowns will be 0). If currently 0,
        //   suppress the pill — the active phase already says what to do.
        const nextJumpInS = Math.max(state.boosterCD, state.drugCD);
        if (nextJumpInS > 0) {
            const nextJumpAt = new Date((nowS + nextJumpInS) * 1000);
            details.push({ label: 'Next jump', text: fmtFuture(nextJumpAt) });
        }

        // 1. Booster is cooling down — cycle is "done" until it expires (full method),
        //    or until it drops past the half-method window.
        if (hasBoosterCD && state.boosterCD > boosterTarget) {
            // Booster CD longer than the target — must wait either way.
            return {
                phase: 'CYCLE_COMPLETE',
                title: 'Rest',
                action: `Booster cooling down. Next Choco Jump available in ${fmtDur(state.boosterCD - (opts.mode === 'half' ? T.BOOSTER_CD_HALF_S : 0))}. Train spare energy, do other Torn stuff.`,
                color: 'idle',
                etaS: state.boosterCD,
                details,
            };
        }

        // 2. Drug CD still going and booster active → mid-cycle.
        if (hasDrugCD && hasBoosterCD) {
            return trainOrRestDecision(state, opts, details);
        }

        // 3. Drug CD active, no booster yet → we just popped Xanax, waiting for Ecstasy CD.
        if (hasDrugCD && !hasBoosterCD) {
            // Flag whether the BBC moment lands inside the player's online window.
            const bbcAt = new Date(Date.now() + state.drugCD * 1000);
            const bbcInWindow = isInWindow(bbcAt, opts.availableFrom, opts.availableTo);
            const warning = bbcInWindow
                ? ''
                : ` ⚠ Drug CD ends at ${fmtClock(bbcAt)} — OUTSIDE your set online hours. You'll miss the BBC moment.`;
            return {
                phase: 'XAN_ECSTASY_CD',
                title: 'Ecstasy CD',
                action: `Xanax popped. Travel / race / trade for ${fmtDur(state.drugCD)} until drug CD clears (~${fmtClock(bbcAt)}), then eat ${bbcCount} BBC + Ecstasy.${warning}`,
                color: bbcInWindow ? 'wait' : 'warn',
                etaS: state.drugCD,
                details,
            };
        }

        // 4. No drug CD, no booster → time to eat chocolate and pop Ecstasy.
        if (!hasDrugCD && !hasBoosterCD) {
            // Sub-case: energy is still not full → fill it before Xanax to not waste regen.
            if (!energyFull) {
                const fullAt = new Date(Date.now() + state.energy.fulltime * 1000);
                const bbcIfPopAtFull = new Date(fullAt.getTime() + T.ECSTASY_CD_S * 1000);
                const fullInWindow = isInWindow(fullAt, opts.availableFrom, opts.availableTo);
                const bbcInWindow = isInWindow(bbcIfPopAtFull, opts.availableFrom, opts.availableTo);
                const note = (fullInWindow && bbcInWindow)
                    ? ` Energy caps at ${fmtClock(fullAt)} — both Xanax and BBC moments land inside your hours.`
                    : ` ⚠ Energy caps at ${fmtClock(fullAt)}; popping then would put the BBC moment at ${fmtClock(bbcIfPopAtFull)} (outside hours). You may need to skip a day or shift your hours.`;
                return {
                    phase: 'FILL_ENERGY',
                    title: 'Fill Energy',
                    action: `Wait ${fmtDur(state.energy.fulltime)} for energy to cap (${state.energy.current}/${state.energy.maximum}), then pop Xanax.${note}`,
                    color: 'wait',
                    etaS: state.energy.fulltime,
                    details,
                };
            }
            // Energy full. Should we pop Xanax NOW? Only if we'll still be in the
            // online window 3.5h from now (when BBC needs to happen).
            const now = new Date();
            const bbcAt = new Date(now.getTime() + T.ECSTASY_CD_S * 1000);
            const nowInWindow = isInWindow(now, opts.availableFrom, opts.availableTo);
            const bbcInWindow = isInWindow(bbcAt, opts.availableFrom, opts.availableTo);

            if (nowInWindow && bbcInWindow) {
                return {
                    phase: 'READY_FOR_XANAX',
                    title: 'Pop Xanax',
                    action: `Energy full. Pop Xanax now → BBC moment at ~${fmtClock(bbcAt)} (inside your hours).`,
                    color: 'go',
                    etaS: 0,
                    details,
                };
            }
            // Find the next clock time that satisfies BOTH constraints:
            //   1. that time is inside the window (player online to pop)
            //   2. that time + 3.5h is inside the window (player online for BBC)
            let candidate = nextWindowOpen(now, opts);
            let bbcCandidate = new Date(candidate.getTime() + T.ECSTASY_CD_S * 1000);
            for (let i = 0; i < 96; i++) {
                if (
                    isInWindow(candidate, opts.availableFrom, opts.availableTo) &&
                    isInWindow(bbcCandidate, opts.availableFrom, opts.availableTo)
                ) break;
                candidate = new Date(candidate.getTime() + 15 * 60 * 1000);
                bbcCandidate = new Date(candidate.getTime() + T.ECSTASY_CD_S * 1000);
            }
            const waitS = Math.max(0, Math.floor((candidate.getTime() - now.getTime()) / 1000));
            return {
                phase: 'HOLD_FOR_WINDOW',
                title: 'Hold Off',
                action: `Energy is full but popping now would put the BBC moment outside your online hours (${pad2(opts.availableFrom)}:00–${pad2(opts.availableTo)}:00). Best Xanax time: ${fmtClock(candidate)} (BBC at ${fmtClock(bbcCandidate)}). Train spare energy or save Xanax until then.`,
                color: 'wait',
                etaS: waitS,
                details,
            };
        }

        // 5. No drug CD but booster is active → mid-cycle.
        if (!hasDrugCD && hasBoosterCD) {
            return trainOrRestDecision(state, opts, details);
        }

        return {
            phase: 'UNKNOWN',
            title: 'Idle',
            action: 'Unknown state.',
            color: 'idle',
            etaS: 0,
            details,
        };
    }

    // -------------------------------------------------------------------------
    // Formatting
    // -------------------------------------------------------------------------
    function pad2(n) { return n.toString().padStart(2, '0'); }

    function fmtDur(secs) {
        secs = Math.max(0, Math.floor(secs));
        if (secs === 0) return '0s';
        const h = Math.floor(secs / 3600);
        const m = Math.floor((secs % 3600) / 60);
        const s = secs % 60;
        if (h > 0) return `${h}h ${m}m`;
        if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`;
        return `${s}s`;
    }

    // -------------------------------------------------------------------------
    // UI
    // -------------------------------------------------------------------------
    function injectStyles() {
        if (document.getElementById('cjh-style')) return;

        // Try to pull in display + mono web fonts. If Torn's CSP blocks
        // fonts.googleapis.com, the system-font fallbacks in the CSS still
        // give a coherent look (Arial Narrow / Menlo / system sans).
        if (!document.getElementById('cjh-fontlink')) {
            const link = document.createElement('link');
            link.id = 'cjh-fontlink';
            link.rel = 'stylesheet';
            link.href = 'https://fonts.googleapis.com/css2?family=Bebas+Neue&family=JetBrains+Mono:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap';
            document.head.appendChild(link);
        }

        const s = document.createElement('style');
        s.id = 'cjh-style';
        s.textContent = `
            /* === Choco Jump Helper — Tactical HUD theme ============= */

            #cjh-banner, #cjh-settings {
                --cjh-bg-0: #0a0807;
                --cjh-bg-1: #15110d;
                --cjh-bg-2: #1f1812;
                --cjh-bg-3: #2a2116;
                --cjh-ink: #ead9bd;
                --cjh-ink-dim: #998873;
                --cjh-ink-faint: #5e5246;
                --cjh-brass: #c79552;
                --cjh-brass-dim: #8a6536;
                --cjh-line: rgba(199,149,82,0.18);
                --cjh-line-strong: rgba(199,149,82,0.42);

                --cjh-state: #c79552;
                --cjh-state-field: #1f1812;
                --cjh-state-line: rgba(199,149,82,0.42);
                --cjh-state-glow: rgba(199,149,82,0.20);
            }

            #cjh-banner.go,   #cjh-settings.go   { --cjh-state:#9bc46e; --cjh-state-field:#15220e; --cjh-state-line:rgba(155,196,110,0.50); --cjh-state-glow:rgba(155,196,110,0.22); }
            #cjh-banner.wait, #cjh-settings.wait { --cjh-state:#e3a93f; --cjh-state-field:#2b1d0a; --cjh-state-line:rgba(227,169,63,0.45); --cjh-state-glow:rgba(227,169,63,0.22); }
            #cjh-banner.idle, #cjh-settings.idle { --cjh-state:#7a6e5d; --cjh-state-field:#15120e; --cjh-state-line:rgba(122,110,93,0.45); --cjh-state-glow:rgba(122,110,93,0.18); }
            #cjh-banner.warn, #cjh-settings.warn { --cjh-state:#d24a3e; --cjh-state-field:#2a0d0a; --cjh-state-line:rgba(210,74,62,0.60); --cjh-state-glow:rgba(210,74,62,0.28); }

            /* === BANNER ===================================== */

            #cjh-banner {
                position: fixed;
                top: 0; left: 0; right: 0;
                z-index: 999999;
                min-height: 44px;
                box-sizing: border-box;
                padding: 6px 14px 6px 12px;

                display: grid;
                grid-template-columns: auto 1fr auto auto auto;
                align-items: center;
                gap: 12px;

                color: var(--cjh-ink);
                font-family: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
                font-size: 13px;
                font-weight: 500;
                letter-spacing: 0.005em;

                background:
                    /* state-tinted wash on the left edge */
                    radial-gradient(ellipse 32% 140% at 0% 50%,
                        var(--cjh-state-glow), transparent 70%),
                    /* CRT scan lines */
                    repeating-linear-gradient(0deg,
                        transparent 0 2px,
                        rgba(0,0,0,0.18) 2px 3px),
                    /* warm chocolate base */
                    linear-gradient(180deg, var(--cjh-bg-1) 0%, var(--cjh-bg-0) 100%);

                border-bottom: 1px solid var(--cjh-line);
                box-shadow:
                    inset 0 1px 0 rgba(199,149,82,0.06),
                    0 1px 0 rgba(0,0,0,0.6),
                    0 14px 28px -16px rgba(0,0,0,0.9);
                transition: background 250ms ease;
            }

            /* state-colored hairline at the very top */
            #cjh-banner::before {
                content: '';
                position: absolute;
                top: 0; left: 0; right: 0;
                height: 2px;
                background: var(--cjh-state);
                box-shadow: 0 0 14px var(--cjh-state-glow);
                opacity: 0.92;
                transition: background 250ms ease;
            }

            /* Title block — small tactical stencil tag with bracket corners */
            #cjh-banner .cjh-title {
                position: relative;
                z-index: 1;
                font-family: 'Bebas Neue', 'Oswald', 'Arial Narrow', sans-serif;
                font-weight: 400;
                font-size: 13px;
                line-height: 1;
                letter-spacing: 0.14em;
                text-transform: uppercase;
                color: var(--cjh-state);

                padding: 6px 10px 4px;
                white-space: nowrap;
                background:
                    linear-gradient(180deg,
                        rgba(255,255,255,0.02), transparent 60%),
                    var(--cjh-state-field);
                border: 1px solid var(--cjh-state-line);
                text-shadow: 0 0 14px var(--cjh-state-glow);
            }
            /* Corner brackets */
            #cjh-banner .cjh-title::before,
            #cjh-banner .cjh-title::after {
                content: '';
                position: absolute;
                width: 7px; height: 7px;
                border: 1px solid var(--cjh-state);
            }
            #cjh-banner .cjh-title::before {
                top: -2px; left: -2px;
                border-right: none; border-bottom: none;
            }
            #cjh-banner .cjh-title::after {
                bottom: -2px; right: -2px;
                border-left: none; border-top: none;
            }

            /* Action sentence — primary status text. Wraps to 2 lines on
               narrower viewports so important detail isn't cut off. */
            #cjh-banner .cjh-action {
                position: relative;
                z-index: 1;
                line-height: 1.35;
                font-size: 12.5px;
                color: var(--cjh-ink);
                padding-left: 12px;
                border-left: 1px solid var(--cjh-line);
                min-width: 0;
                display: -webkit-box;
                -webkit-line-clamp: 2;
                -webkit-box-orient: vertical;
                overflow: hidden;
            }
            /* Pulsing status dot ahead of action text */
            #cjh-banner .cjh-action::before {
                content: '';
                display: inline-block;
                vertical-align: 1px;
                width: 7px; height: 7px;
                background: var(--cjh-state);
                margin-right: 10px;
                box-shadow:
                    0 0 0 1px var(--cjh-bg-0),
                    0 0 12px var(--cjh-state);
                animation: cjh-pulse 2.2s ease-in-out infinite;
            }
            @keyframes cjh-pulse {
                0%, 100% { opacity: 1; transform: scale(1); }
                50%      { opacity: 0.45; transform: scale(0.85); }
            }

            /* HUD readout pills */
            #cjh-banner .cjh-details {
                position: relative;
                z-index: 1;
                display: flex;
                gap: 6px;
                white-space: nowrap;
            }
            #cjh-banner .cjh-pill {
                padding: 4px 10px;
                background:
                    linear-gradient(180deg, rgba(255,255,255,0.02), transparent 60%),
                    var(--cjh-bg-2);
                border: 1px solid var(--cjh-line);
                font-family: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
                font-size: 10px;
                font-weight: 500;
                letter-spacing: 0.10em;
                text-transform: uppercase;
                color: var(--cjh-ink-dim);
                line-height: 1.6;
            }
            #cjh-banner .cjh-pill b {
                display: inline-block;
                margin-left: 8px;
                padding-left: 8px;
                border-left: 1px solid var(--cjh-line);
                font-weight: 700;
                font-size: 12px;
                letter-spacing: 0.02em;
                text-transform: none;
                color: var(--cjh-ink);
                font-variant-numeric: tabular-nums;
            }

            /* Refresh + cog: square brass buttons */
            #cjh-banner .cjh-refresh,
            #cjh-banner .cjh-cog {
                position: relative;
                z-index: 1;
                width: 28px; height: 28px;
                background:
                    linear-gradient(180deg, rgba(255,255,255,0.03), transparent 60%),
                    var(--cjh-bg-2);
                border: 1px solid var(--cjh-line);
                color: var(--cjh-ink-dim);
                cursor: pointer;
                user-select: none;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                font-size: 14px;
                transition: all 0.15s ease;
            }
            #cjh-banner .cjh-refresh:hover,
            #cjh-banner .cjh-cog:hover {
                color: var(--cjh-brass);
                border-color: var(--cjh-line-strong);
                background:
                    linear-gradient(180deg, rgba(255,255,255,0.04), transparent 60%),
                    var(--cjh-bg-3);
                box-shadow: inset 0 0 14px rgba(199,149,82,0.10);
            }
            #cjh-banner .cjh-refresh:active,
            #cjh-banner .cjh-cog:active { transform: translateY(1px); }
            #cjh-banner .cjh-refresh.spinning {
                color: var(--cjh-brass);
                animation: cjh-spin 0.7s linear infinite;
            }
            @keyframes cjh-spin {
                from { transform: rotate(0deg); }
                to   { transform: rotate(360deg); }
            }

            /* Push Torn's page content down to clear the banner */
            body { padding-top: 52px !important; }

            /* === SETTINGS PANEL ============================ */
            #cjh-settings {
                position: fixed;
                top: 70px;
                right: 16px;
                z-index: 999999;
                width: 360px;
                box-sizing: border-box;
                padding: 18px 20px 22px;

                color: var(--cjh-ink);
                font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
                font-size: 12.5px;

                background:
                    repeating-linear-gradient(0deg,
                        transparent 0 2px,
                        rgba(0,0,0,0.14) 2px 3px),
                    linear-gradient(180deg, var(--cjh-bg-1) 0%, var(--cjh-bg-0) 100%);
                border: 1px solid var(--cjh-line-strong);
                box-shadow:
                    inset 0 1px 0 rgba(199,149,82,0.08),
                    0 28px 60px -16px rgba(0,0,0,0.9),
                    0 0 0 1px rgba(0,0,0,0.7);

                animation: cjh-slide-in 220ms cubic-bezier(0.2, 0.85, 0.3, 1.1);
            }
            #cjh-settings.hidden { display: none; }

            /* Brass hairline + corner bracket */
            #cjh-settings::before {
                content: '';
                position: absolute;
                top: 0; left: 0; right: 0;
                height: 2px;
                background: var(--cjh-brass);
                opacity: 0.85;
            }
            #cjh-settings::after {
                content: '';
                position: absolute;
                top: 6px; right: 6px;
                width: 10px; height: 10px;
                border-top: 1px solid var(--cjh-brass);
                border-right: 1px solid var(--cjh-brass);
                pointer-events: none;
                opacity: 0.7;
            }
            @keyframes cjh-slide-in {
                from { opacity: 0; transform: translateY(-8px) scale(0.985); }
                to   { opacity: 1; transform: translateY(0)    scale(1); }
            }

            #cjh-settings h3 {
                margin: 0 0 16px;
                padding: 0 0 12px;
                font-family: 'Bebas Neue', 'Oswald', 'Arial Narrow', sans-serif;
                font-weight: 400;
                font-size: 18px;
                letter-spacing: 0.16em;
                text-transform: uppercase;
                color: var(--cjh-brass);
                border-bottom: 1px solid var(--cjh-line);
            }

            #cjh-settings label {
                display: block;
                margin: 14px 0 6px;
                font-family: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
                font-size: 9.5px;
                font-weight: 500;
                letter-spacing: 0.16em;
                text-transform: uppercase;
                color: var(--cjh-ink-dim);
            }

            #cjh-settings input[type=text],
            #cjh-settings select {
                width: 100%;
                box-sizing: border-box;
                background: var(--cjh-bg-2);
                color: var(--cjh-ink);
                border: 1px solid var(--cjh-line);
                padding: 8px 10px;
                font-family: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
                font-size: 12px;
                transition: border-color 0.15s, background 0.15s;
            }
            #cjh-settings input[type=text]:focus,
            #cjh-settings select:focus {
                outline: none;
                border-color: var(--cjh-brass-dim);
                background: var(--cjh-bg-3);
                box-shadow: 0 0 0 1px rgba(199,149,82,0.12), inset 0 0 12px rgba(199,149,82,0.06);
            }

            #cjh-settings .cjh-row {
                display: flex;
                align-items: center;
                gap: 10px;
                margin-top: 10px;
            }
            /* Inline labels in rows render plain, not as section heads */
            #cjh-settings .cjh-row label {
                margin: 0;
                font-family: 'IBM Plex Sans', system-ui, sans-serif;
                font-size: 12.5px;
                letter-spacing: 0.01em;
                text-transform: none;
                color: var(--cjh-ink);
            }
            #cjh-settings input[type=checkbox] {
                accent-color: var(--cjh-brass);
                width: 14px; height: 14px;
            }

            #cjh-settings button {
                background:
                    linear-gradient(180deg, rgba(255,255,255,0.04), transparent 60%),
                    var(--cjh-bg-3);
                color: var(--cjh-brass);
                border: 1px solid var(--cjh-line-strong);
                padding: 8px 16px;
                cursor: pointer;
                font-family: 'Bebas Neue', 'Oswald', 'Arial Narrow', sans-serif;
                font-size: 14px;
                letter-spacing: 0.18em;
                text-transform: uppercase;
                transition: all 0.15s ease;
            }
            #cjh-settings button:hover {
                background: var(--cjh-brass);
                color: var(--cjh-bg-0);
                border-color: var(--cjh-brass);
                box-shadow: 0 0 18px rgba(199,149,82,0.30);
            }
            #cjh-settings button:active { transform: translateY(1px); }

            #cjh-settings .cjh-hint {
                font-size: 11px;
                line-height: 1.55;
                color: var(--cjh-ink-faint);
                margin: 6px 0 2px;
                padding-left: 10px;
                border-left: 1px solid var(--cjh-line);
            }
            #cjh-settings a {
                color: var(--cjh-brass);
                text-decoration: none;
                border-bottom: 1px dotted var(--cjh-brass-dim);
            }
            #cjh-settings a:hover {
                color: var(--cjh-ink);
                border-bottom-color: var(--cjh-ink);
            }

            #cjh-settings .cjh-credits {
                margin: 18px -20px -22px;
                padding: 10px 20px 12px;
                border-top: 1px solid var(--cjh-line);
                background: rgba(0,0,0,0.25);
                font-family: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
                font-size: 10.5px;
                letter-spacing: 0.06em;
                text-align: center;
                color: var(--cjh-ink-faint);
            }
            #cjh-settings .cjh-credits a {
                color: var(--cjh-brass);
                border-bottom: none;
            }
            #cjh-settings .cjh-credits a:hover { color: var(--cjh-ink); }
            #cjh-settings .cjh-heart {
                color: var(--cjh-warn, #d24a3e);
                display: inline-block;
                animation: cjh-heartbeat 1.6s ease-in-out infinite;
            }
            @keyframes cjh-heartbeat {
                0%, 100% { transform: scale(1);   opacity: 0.85; }
                50%      { transform: scale(1.2); opacity: 1; }
            }

            /* Narrow Torn layouts (incl. TornPDA mobile app) */
            @media (max-width: 1100px) {
                /* Drop the "Next jump" pill — keep the three core readouts only */
                #cjh-banner .cjh-pill:nth-child(n+4) { display: none; }
            }
            @media (max-width: 820px) {
                /* Drop pills entirely; action text remains the focus */
                #cjh-banner .cjh-details { display: none; }
                #cjh-banner { gap: 10px; }
            }
            @media (max-width: 560px) {
                /* TornPDA-style narrow: collapse title to brackets-only, drop padding */
                #cjh-banner {
                    padding: 6px 10px 6px 8px;
                    gap: 8px;
                }
                #cjh-banner .cjh-title {
                    font-size: 0;
                    padding: 0;
                    width: 6px; height: 22px;
                    background: var(--cjh-state);
                    border: none;
                    text-shadow: none;
                }
                #cjh-banner .cjh-title::before,
                #cjh-banner .cjh-title::after { display: none; }
                #cjh-banner .cjh-action { padding-left: 8px; font-size: 12px; }
                #cjh-banner .cjh-refresh,
                #cjh-banner .cjh-cog { width: 26px; height: 26px; font-size: 13px; }
                body { padding-top: 56px !important; }
            }
        `;
        document.head.appendChild(s);
    }

    let bannerEl, settingsEl;

    function buildBanner() {
        if (document.getElementById('cjh-banner')) return;
        const div = document.createElement('div');
        div.id = 'cjh-banner';
        div.className = 'idle';
        div.innerHTML = `
            <span class="cjh-title">Choco Jump Helper</span>
            <span class="cjh-action">Loading…</span>
            <span class="cjh-details"></span>
            <span class="cjh-refresh" title="Refresh now">↻</span>
            <span class="cjh-cog" title="Settings">⚙</span>
        `;
        document.body.appendChild(div);
        div.querySelector('.cjh-cog').addEventListener('click', toggleSettings);
        div.querySelector('.cjh-refresh').addEventListener('click', manualRefresh);
        bannerEl = div;
    }

    function buildSettings() {
        if (document.getElementById('cjh-settings')) return;
        const div = document.createElement('div');
        div.id = 'cjh-settings';
        div.className = 'hidden';
        div.innerHTML = `
            <h3>Choco Jump Helper — Settings</h3>
            <label for="cjh-apikey">Torn API key (Public scope is enough)</label>
            <input type="text" id="cjh-apikey" placeholder="paste your API key" />
            <div class="cjh-hint">Create one at <a href="https://www.torn.com/preferences.php#tab=api" target="_blank" rel="noopener">torn.com/preferences → API</a>. Only "Public" access is required.</div>

            <label for="cjh-mode">Method</label>
            <select id="cjh-mode">
                <option value="full">Full Choco Jump (48 BBC, 1×/day)</option>
                <option value="half">Half Choco Jump (~22 BBC, 2×/day)</option>
            </select>

            <div class="cjh-row">
                <input type="checkbox" id="cjh-optxan" />
                <label for="cjh-optxan" style="margin:0;">Suggest optional 2nd Xanax (Full method only)</label>
            </div>
            <div class="cjh-hint">Per HOU5E's Single Choco Jump chart: a ~3h window once the 1st Xanax's CD ends, during which a 2nd Xanax adds +250 energy to train through. Ignored in the Double (half) method.</div>

            <label>Online hours (local time)</label>
            <div class="cjh-row">
                <select id="cjh-avail-from" style="flex:1;"></select>
                <span>to</span>
                <select id="cjh-avail-to" style="flex:1;"></select>
            </div>
            <div class="cjh-hint">The hours you're realistically able to be in-game. The helper only recommends popping Xanax if the BBC step (≈3.5h later) also falls inside this window.</div>

            <div class="cjh-row" style="margin-top:12px;">
                <button id="cjh-save">Save</button>
                <button id="cjh-mark-xan" title="Record that you just popped Xanax — used for the optional 2nd Xanax window" style="background:#5a4a14;">I just popped Xanax</button>
            </div>

            <div class="cjh-credits">
                Crafted by <a href="https://www.torn.com/profiles.php?XID=3702492" target="_blank" rel="noopener">Nicram [3702492]</a> · all tips welcome <span class="cjh-heart">♥</span>
            </div>
        `;
        document.body.appendChild(div);
        // Populate hour dropdowns 0–23.
        const fromSel = div.querySelector('#cjh-avail-from');
        const toSel = div.querySelector('#cjh-avail-to');
        for (let h = 0; h < 24; h++) {
            const labelText = `${pad2(h)}:00`;
            fromSel.insertAdjacentHTML('beforeend', `<option value="${h}">${labelText}</option>`);
            toSel.insertAdjacentHTML('beforeend', `<option value="${h}">${labelText}</option>`);
        }
        div.querySelector('#cjh-apikey').value = settings.apiKey || '';
        div.querySelector('#cjh-mode').value = settings.mode;
        div.querySelector('#cjh-optxan').checked = !!settings.optionalXanax;
        fromSel.value = String(settings.availableFrom);
        toSel.value = String(settings.availableTo);
        div.querySelector('#cjh-save').addEventListener('click', onSave);
        div.querySelector('#cjh-mark-xan').addEventListener('click', () => {
            const now = Math.floor(Date.now() / 1000);
            settings.firstXanaxAt = now;
            save('firstXanaxAt', now);
            div.querySelector('#cjh-mark-xan').textContent = '✓ Xanax timestamp saved';
            setTimeout(() => {
                div.querySelector('#cjh-mark-xan').textContent = 'I just popped Xanax';
            }, 1500);
        });
        settingsEl = div;
    }

    function toggleSettings() {
        if (!settingsEl) return;
        settingsEl.classList.toggle('hidden');
    }

    function onSave() {
        const newKey = settingsEl.querySelector('#cjh-apikey').value.trim();
        settings.apiKey = newKey;
        settings.mode = settingsEl.querySelector('#cjh-mode').value;
        settings.optionalXanax = settingsEl.querySelector('#cjh-optxan').checked;
        settings.availableFrom = parseInt(settingsEl.querySelector('#cjh-avail-from').value, 10);
        settings.availableTo = parseInt(settingsEl.querySelector('#cjh-avail-to').value, 10);
        save('apiKey', settings.apiKey);
        save('mode', settings.mode);
        save('optionalXanax', settings.optionalXanax);
        save('availableFrom', settings.availableFrom);
        save('availableTo', settings.availableTo);
        settingsEl.classList.add('hidden');
        // Force immediate refetch + render.
        snapshot = null;
        save('lastSnapshot', null);
        refresh();
    }

    function renderBanner(decision) {
        if (!bannerEl) return;
        bannerEl.className = decision.color;
        bannerEl.querySelector('.cjh-title').textContent = decision.title;
        bannerEl.querySelector('.cjh-action').textContent = decision.action;
        const detailsEl = bannerEl.querySelector('.cjh-details');
        detailsEl.innerHTML = decision.details.map(d => {
            const v = d.text !== undefined ? d.text : (d.secs > 0 ? fmtDur(d.secs) : '—');
            return `<span class="cjh-pill">${d.label}: <b>${v}</b></span>`;
        }).join('');
    }

    // -------------------------------------------------------------------------
    // Tick + poll loop
    // -------------------------------------------------------------------------
    let lastFetchAt = 0;

    function pollInterval() {
        return document.hidden ? 60_000 : 15_000;
    }

    async function manualRefresh() {
        const btn = bannerEl && bannerEl.querySelector('.cjh-refresh');
        if (btn) btn.classList.add('spinning');
        lastFetchAt = 0; // force a fetch on next refresh()
        await refresh();
        if (btn) btn.classList.remove('spinning');
    }

    async function refresh() {
        const nowMs = Date.now();
        if (settings.apiKey && nowMs - lastFetchAt > pollInterval()) {
            lastFetchAt = nowMs;
            const fresh = await fetchState();
            if (fresh) snapshot = fresh;
        }
        const nowS = Math.floor(Date.now() / 1000);
        const projected = projectSnapshot(snapshot, nowS);
        const decision = decide(projected, settings, nowS);
        renderBanner(decision);
    }

    function start() {
        injectStyles();
        buildBanner();
        buildSettings();
        refresh();
        setInterval(refresh, 1000);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', start);
    } else {
        start();
    }
})();