Choco Jump Helper

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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