Загрузка данных


(async () => {
    "use strict";

    /* ── config (Safe for users to edit) ────────────────────────── */

    const CONFIG = {
        NAME: "Orion",
        VERSION: "v4.11.5",
        THEME: "#5865F2",             // discord blurple
        SUCCESS: "#3BA55C",
        WARN: "#faa61a",
        ERR: "#f04747",
        HIDE_ACTIVITY: false,           // hide "Playing ..." while quests run (turns Discord's own
                                        // showCurrentGame off for the duration, restores it after)
        PLAY_SESSION_TAIL_MIN: 2,       // minutes to keep a finished game quest's fake process
                                        // running, picked at random between 40% and 100% of this
                                        // each time. 0 drops it on the tick that crossed the
                                        // target, which makes every session exactly as long as
                                        // the quest requirement. Costs no extra requests: Discord
                                        // ends the quest heartbeat itself on completion, so this
                                        // only extends the "Playing ..." presence.
        MAX_LOG_ITEMS: 60               // UI log limit
    };

    /* ── internal system limits (DO NOT EDIT) ─────────────────── */

    const SYS = Object.freeze({
        MAX_TIME: 25 * 60 * 1000,       // hard abort per task (25 min)
        HEARTBEAT_GRACE: 90 * 1000,     // GAME/STREAM: give up if Discord sends no heartbeat
        MAX_TASK_FAILURES: 5,           // consecutive network failures
        MAX_RETRIES: 3,                 // 429/5xx transient error retries
        IS_DESKTOP: typeof window.DiscordNative !== 'undefined'
    });

    // mutable runtime state lives here, CONFIG stays read-only
    const RUNTIME = {
        running: true,
        cleanups: new Set(),            // tracks active event listeners for safe shutdown
        autoEnroll: true,               // whether to auto-enroll in quests before execution
        autoClaim: false,               // whether to try auto-claiming quest rewards
        playSound: false,               // whether to play an audio cue on quest completion
        randomDelay: false              // whether to inject randomized 1-30min idle gaps between quests (anti-detection)
    };

    /* ── audio cue ───────────────────────────────────────────────── */
    const Sound = {
        _ctx: null,
        play(type) {
            if (!RUNTIME.playSound) return;
            try {
                const Ctx = window.AudioContext || window.webkitAudioContext;
                if (!Ctx) return;
                // reuse one context; Chromium caps ~6 concurrent AudioContexts and a long
                // run with sound on would otherwise hit the cap and go silently dead.
                if (!this._ctx || this._ctx.state === 'closed') this._ctx = new Ctx();
                const ctx = this._ctx;
                const o = ctx.createOscillator();
                const g = ctx.createGain();
                o.connect(g); g.connect(ctx.destination);
                o.type = 'sine';
                const t0 = ctx.currentTime;
                if (type === 'done') {
                    // C5 E5 G5 arpeggio
                    o.frequency.setValueAtTime(523.25, t0);
                    o.frequency.setValueAtTime(659.25, t0 + 0.12);
                    o.frequency.setValueAtTime(783.99, t0 + 0.24);
                    g.gain.setValueAtTime(0.55, t0);
                    g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.55);
                    o.start(t0); o.stop(t0 + 0.6);
                } else {
                    o.frequency.value = 880; // A5
                    g.gain.setValueAtTime(0.45, t0);
                    g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.18);
                    o.start(t0); o.stop(t0 + 0.2);
                }
            } catch (_) { }
        }
    };

    /* ── OAuth consent gate ──────────────────────────────────────
       Shown before the ACHIEVEMENT bypass OAuth-authorizes a quest's
       app on the user's account. Per-app, per-run, in-memory only and
       never persisted, so it can't silently authorize forever. Default
       decline: Cancel / Esc / backdrop / 60s idle all resolve false, so
       an AFK user never authorizes anything. CSP-safe (addEventListener,
       app name via textContent so it can't inject markup).
    ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */
    const Consent = {
        _granted: new Set(),
        TIMEOUT: 60000,
        SCOPES: ['identify', 'applications.commands', 'applications.entitlements'],
        ask(appId, appName) {
            if (this._granted.has(appId)) return Promise.resolve(true);
            return new Promise(resolve => {
                const ov = document.createElement('div');
                ov.id = 'orion-consent';
                ov.style.cssText = 'position:fixed;inset:0;z-index:1002;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6);font-family:var(--font-primary);';
                ov.innerHTML = `<div style="width:420px;max-width:92vw;background:var(--background-base-low);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);box-shadow:var(--shadow-button-overlay);color:var(--text-default);overflow:hidden;">
                    <div style="padding:14px 16px;background:var(--background-mod-muted);border-bottom:1px solid var(--border-subtle);font-weight:700;color:var(--text-strong);">Authorize application?</div>
                    <div style="padding:16px;font-size:13px;line-height:1.5;">
                        <div>To complete this achievement quest, Orion needs to OAuth-authorize this app on your Discord account:</div>
                        <div id="oc-app" style="font-weight:700;color:var(--text-strong);margin:6px 0;"></div>
                        <div style="color:var(--text-muted);">Scopes requested:</div>
                        <ul id="oc-scopes" style="margin:4px 0 10px;padding-left:18px;color:var(--text-muted);"></ul>
                        <div style="color:var(--text-feedback-warning);">The app's backend receives a real authorization code. Orion revokes the grant immediately after the quest is marked complete. Discord enforcement against quest automation can affect the whole account.</div>
                        <label style="display:flex;gap:8px;align-items:center;margin-top:12px;font-size:12px;color:var(--text-muted);">
                            <input type="checkbox" id="oc-remember" class="native-cb"> Don't ask again for this app this session</label>
                    </div>
                    <div style="display:flex;gap:10px;padding:12px 16px;border-top:1px solid var(--border-subtle);">
                        <button id="oc-no" class="quest-pick-btn deselect" style="flex:1;">Cancel</button>
                        <button id="oc-yes" class="quest-pick-btn start" style="flex:1;">Authorize</button>
                    </div></div>`;
                document.body.appendChild(ov);
                ov.querySelector('#oc-app').textContent = appName ? `${appName} (${appId})` : `App ${appId}`;
                const ul = ov.querySelector('#oc-scopes');
                this.SCOPES.forEach(s => { const li = document.createElement('li'); li.textContent = s; ul.appendChild(li); });
                let done = false;
                const finish = v => {
                    if (done) return; done = true;
                    clearTimeout(timer); document.removeEventListener('keydown', onKey);
                    if (v && ov.querySelector('#oc-remember').checked) this._granted.add(appId);
                    ov.remove(); resolve(v);
                };
                const onKey = e => { if (e.key === 'Escape') finish(false); };
                ov.querySelector('#oc-yes').addEventListener('click', () => finish(true));
                ov.querySelector('#oc-no').addEventListener('click', () => finish(false));
                ov.addEventListener('mousedown', e => { if (e.target === ov) finish(false); });
                document.addEventListener('keydown', onKey);
                const timer = setTimeout(() => finish(false), this.TIMEOUT);
            });
        }
    };

    const ICONS = Object.freeze({
		OPT: `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M10.56 1.1c-.46.05-.7.53-.64.98.18 1.16-.19 2.2-.98 2.53-.8.33-1.79-.15-2.49-1.1-.27-.36-.78-.52-1.14-.24-.77.59-1.45 1.27-2.04 2.04-.28.36-.12.87.24 1.14.96.7 1.43 1.7 1.1 2.49-.33.8-1.37 1.16-2.53.98-.45-.07-.93.18-.99.64a11.1 11.1 0 0 0 0 2.88c.06.46.54.7.99.64 1.16-.18 2.2.19 2.53.98.33.8-.14 1.79-1.1 2.49-.36.27-.52.78-.24 1.14.59.77 1.27 1.45 2.04 2.04.36.28.87.12 1.14-.24.7-.95 1.7-1.43 2.49-1.1.8.33 1.16 1.37.98 2.53-.07.45.18.93.64.99a11.1 11.1 0 0 0 2.88 0c.46-.06.7-.54.64-.99-.18-1.16.19-2.2.98-2.53.8-.33 1.79.14 2.49 1.1.27.36.78.52 1.14.24.77-.59 1.45-1.27 2.04-2.04.28-.36.12-.87-.24-1.14-.96-.7-1.43-1.7-1.1-2.49.33-.8 1.37-1.16 2.53-.98.45.07.93-.18.99-.64a11.1 11.1 0 0 0 0-2.88c-.06-.46-.54-.7-.99-.64-1.16.18-2.2-.19-2.53-.98-.33-.8.14-1.79 1.1-2.49.36-.27.52-.78.24-1.14a11.07 11.07 0 0 0-2.04-2.04c-.36-.28-.87-.12-1.14.24-.7.96-1.7 1.43-2.49 1.1-.8-.33-1.16-1.37-.98-2.53.07-.45-.18-.93-.64-.99a11.1 11.1 0 0 0-2.88 0ZM16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/></svg>`,
        BOLT: `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M11 21h-1l1-7H7.5c-.58 0-.57-.32-.29-.62L14.5 3h1l-1 7h3.5c.58 0 .57.32.29.62L11 21z"/></svg>`,
        VIDEO: `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M10 16.5l6-4.5-6-4.5v9zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>`,
        GAME: `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-10 7H8v3H6v-3H3v-2h3V8h2v3h3v2zm4.5 2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4-3c-.83 0-1.5-.67-1.5-1.5S18.67 9 19.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/></svg>`,
        STREAM: `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>`,
        ACTIVITY: `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>`,
        CHECK: `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`,
        CLOCK: `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><path d="M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>`,
        STOP: `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h12v12H6z"/></svg>`
    });

    const CONST = Object.freeze({
        ID: "1412491570820812933",  // blacklisted quest, known to break enrollment
        EVT: Object.freeze({
            HEARTBEAT: "QUESTS_SEND_HEARTBEAT_SUCCESS",
            // Discord dispatches this when its own heartbeat POST fails, carrying the error and
            // the quest id. Without it a failed beat is indistinguishable from silence, which is
            // how a single transient error used to end a game quest 90s later (issue #74).
            HEARTBEAT_FAIL: "QUESTS_SEND_HEARTBEAT_FAILURE",
            GAME: "RUNNING_GAMES_CHANGE",
            RPC: "LOCAL_ACTIVITY_UPDATE"
        })
    });

    // bail early if another instance is already running in this tab
    if (window.orionLock) {
        const existingUI = document.getElementById('orion-ui');
        if (existingUI) existingUI.style.display = 'flex';
        return console.warn(`[${CONFIG.NAME}] Already running.`);
    }
    window.orionLock = true;

    /* ── util ──────────────────────────────────────────────────── */

    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const rnd = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;

    // Escape server-controlled strings before they touch innerHTML. Quest and reward names
    // come from Discord API fields (questName, reward name) and can carry markup; a crafted
    // title like "><img src=x onerror=...> would otherwise inject into the dashboard DOM,
    // the exact inline-injection Discord's CSP exists to stop.
    const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

    // expiresAt is occasionally missing/malformed mid-rollout. new Date(undefined) is NaN and
    // `NaN > now` is always false, which would silently drop the quest with no log. Treat an
    // unparseable expiry as "not expired" so we attempt the quest instead of hiding it.
    // Discord keeps its own expiry verdict in QuestStore, refreshed on every user-status update
    // and rearmed by a timer, and its default when it has no entry is NOT expired. Ask it first
    // and only fall back to reading the timestamp ourselves.
    const notExpired = q => {
        try {
            const store = Mods.QuestStore;
            if (typeof store?.isQuestExpired === 'function') return store.isQuestExpired(q.id) !== true;
        } catch (_) { /* fall through to the timestamp */ }
        const e = new Date(q.config?.expiresAt ?? 0).getTime();
        return Number.isNaN(e) || e > Date.now();
    };

    // Orbs a quest pays out, as { orbs, premium }, or null when it pays none. Every reward entry
    // is summed. The quantity sits on the entry and a quest can list several, so reading
    // rewards[0] reports no Orbs at all for a quest that lists an in-game item first.
    // premiumOrbQuantity is the figure for an account Discord boosts (Nitro or Xbox Game Pass)
    // and is sometimes absent or equal, so it falls back to the base number rather than being
    // multiplied here.
    const orbReward = config => {
        const rewards = config?.rewardsConfig?.rewards;
        if (!Array.isArray(rewards)) return null;
        const amount = v => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);

        let orbs = 0, premium = 0;
        for (const r of rewards) {
            const base = amount(r?.orbQuantity);
            if (!base) continue;
            orbs += base;
            premium += amount(r?.premiumOrbQuantity) || base;
        }
        return orbs > 0 ? { orbs, premium } : null;
    };

    // Whether Discord pays this account the premium figure. Discord's own quest card decides
    // with canUseMoreQuestOrbs on its premium feature module, which covers Nitro and Xbox Game
    // Pass alike, so the same function is called with the signed-in user. When the module is
    // missing or the call throws the answer is false and the base figure is shown, since
    // under-promising is the safe side for a number used to pick what to farm.
    const canUseMoreQuestOrbs = () => {
        try {
            const user = Mods.UserStore?.getCurrentUser?.();
            return user != null && Mods.Premium?.canUseMoreQuestOrbs?.(user) === true;
        } catch {
            return false;
        }
    };

    // The figure the account will receive, so a total built from these sits on the same side
    // as the per-quest numbers it sums.
    const orbPayout = (reward, boosted) => (reward ? (boosted ? reward.premium : reward.orbs) : 0);

    const fmtOrbs = (reward, boosted) => (reward ? `${orbPayout(reward, boosted)} Orbs` : '');

    // The Orb balance Discord reports for the account, or null for anything that is not a whole
    // non-negative number. Zero is a real balance and is kept, unlike a payout of zero.
    const orbBalance = v => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);

    // Orbs on the account. VirtualCurrencyStore holds the figure Discord's own Orb pill shows
    // once the client has fetched it, so that is read first. Discord keeps it current itself:
    // the gateway pushes VIRTUAL_CURRENCY_BALANCE_UPDATE into it and LOGIN_SUCCESS clears it,
    // so a number there belongs to the account of the last login. Before then it is null and one
    // GET of the balance endpoint fills it in. Nothing polls; this runs once when the picker opens.
    // The store clears on LOGIN_SUCCESS and not on LOGOUT, so between the two it still holds the
    // old number. The read stops first when no account is signed in, which covers that gap and
    // the GET alike. The GET is also checked on both sides of the await, and a switch in between
    // discards the response.
    const readOrbBalance = async () => {
        const account = Mods.UserStore?.getCurrentUser?.()?.id ?? null;
        if (!account) throw new Error('no account is signed in');
        const store = Mods.OrbStore;
        const stored = orbBalance(store?.getCurrentBalance?.() ?? store?.balance);
        if (stored !== null) return stored;
        const res = await Mods.API.get({ url: '/users/@me/virtual-currency/balance' });
        const after = Mods.UserStore?.getCurrentUser?.()?.id ?? null;
        if (after !== account) throw new Error('the account changed during the read');
        return orbBalance(res?.body?.balance);
    };

    // The server-sealed attribution blob Discord echoes back when enrolling in or claiming a
    // quest. The server issues it per quest and ships it with the quest list; the client
    // returns it unmodified. Orion sent neither sealed field, so every enrollment and claim it
    // made lacked something present on every request the real client sends. Nothing is forged
    // here: this is the server's own value going back where it came from.
    const sealedFor = questId => {
        try { return Mods.QuestStore?.getQuest?.(questId)?.trafficMetadataSealed ?? null; }
        catch (_) { return null; }
    };

    // When Discord has blocked quest enrollment for this account, as a Date, otherwise null.
    // `QuestStore.questEnrollmentBlockedUntil` comes from the same `/quests/@me` response the
    // client already fetches, so reading it costs nothing and adds no request. A block is the
    // clearest signal Discord gives that it has taken an interest in the account, and carrying
    // on through it is both futile and the worst available response to it.
    // Console task keys. Discord groups them as CONSOLE and no desktop client can drive them.
    const CONSOLE_ONLY_KEYS = new Set(['PLAY_ON_XBOX', 'PLAY_ON_PLAYSTATION']);

    // The task table comes out of Discord's own store, which hands some payloads back as a Map.
    // Indexing one of those with [] reads undefined and looks like a quest with no tasks, so
    // every read of it goes through these two.
    const taskKeys = (tasks) => (tasks instanceof Map ? [...tasks.keys()] : Object.keys(tasks ?? {}));
    const taskAt = (tasks, key) => (tasks instanceof Map ? tasks.get(key) : tasks?.[key]);

    // Discord's current taskConfigV2 is authoritative when it carries tasks. Some payloads keep
    // the legacy taskConfig beside it, and nullish-coalescing legacy first routes the quest
    // through stale task/app metadata, which is how this script and the plugin could reach
    // different answers about the same quest. Same rule as selectQuestTaskConfig in
    // questConfig.ts, which has regression tests for it.
    const selectTaskConfig = (config) => {
        const current = config?.taskConfigV2;
        if (taskKeys(current?.tasks).length > 0) return current;
        const legacy = config?.taskConfig;
        if (taskKeys(legacy?.tasks).length > 0) return legacy;
        return current ?? legacy ?? null;
    };

    // Task keys Discord validates somewhere this script cannot reach, mapped to why.
    // ACHIEVEMENT_IN_GAME is not ACHIEVEMENT_IN_ACTIVITY: no embedded activity, no discordsays
    // backend to report progress to, so the bypass has nothing to authorize against. The quest
    // wants an achievement inside the retail game with the game linked to the account.
    const UNAUTOMATABLE_KEYS = new Map([
        ['ACHIEVEMENT_IN_GAME', 'That needs the game linked to your account and an achievement earned inside the game itself, which nothing running in Discord can do for you.']
    ]);

    /*
     * Why this client cannot drive a quest, as the sentence to log, or null when it can drive it.
     *
     * The loop used to answer this with five separate guards, four of which logged and returned
     * without adding the quest to `Tasks.skipped`. The active filter reads that set, so those four
     * handed the same quest back on the next cycle and the run looped on it until the user
     * stopped it (issue #78). Answering in one place keeps the decision and the marking together,
     * so a new reason cannot be added without the caller skipping the quest.
     *
     * Every reason is permanent for the life of a run: the task config, the desktop-ness of the
     * client and the target do not change while Orion runs, and a fresh start re-reads all three.
     */
    const questBlocker = ({ name, hasTaskConfig, keys, typeData, isDesktop }) => {
        if (!hasTaskConfig) return `"${name}" has no usable task config, so there is nothing to drive.`;

        if (!typeData) {
            if (!keys.length) return `"${name}" lists no tasks at all, so there is nothing to drive.`;
            if (keys.every(k => CONSOLE_ONLY_KEYS.has(k))) return `"${name}" is console-only (${keys.join(', ')}), so no desktop client can run it.`;

            // The reason belongs to one key, so a quest carrying several says which one it
            // explains rather than attaching the sentence to the whole list.
            const named = keys.find(k => UNAUTOMATABLE_KEYS.has(k));
            if (named) {
                return keys.length === 1
                    ? `"${name}" offers only ${named}. ${UNAUTOMATABLE_KEYS.get(named)}`
                    : `"${name}" offers ${keys.join(', ')}, and none of them run here. The one we know about is ${named}. ${UNAUTOMATABLE_KEYS.get(named)}`;
            }

            return `"${name}" uses an unsupported task type (${keys.join(', ')}).`;
        }

        const { type, target, appId } = typeData;

        if (!isDesktop && (type === 'GAME' || type === 'STREAM')) return `"${name}" needs the desktop app for its ${type} task.`;
        if (target <= 0) return `"${name}" has an invalid target (${target}).`;
        // GAME/STREAM impersonate a specific application. Without a real id the fake process is
        // unidentifiable and Discord silently never counts it, so skip rather than run a task
        // that cannot finish (issue #43).
        if ((type === 'GAME' || type === 'STREAM') && !appId) return `"${name}" has no application id in its config, so the game cannot be spoofed.`;

        return null;
    };

    /*
     * What happened to a quest, recorded where the run decides it. Completed wins over an earlier
     * blocked or failed: a quest this client could not drive and the user then finished by hand is
     * finished, and must not be counted in both columns.
     */
    const recordOutcome = (outcomes, id, outcome) => {
        if (outcomes.get(id) === 'completed') return;
        outcomes.set(id, outcome);
    };

    /*
     * The wrap-up for a run with nothing left to do. failTask puts a quest in the skipped set as
     * surely as questBlocker does, so a run whose only quest died in a handler used to empty the
     * active list and announce that every quest was completed, with the done sound.
     */
    const summarizeRun = (outcomes) => {
        let finished = 0, blocked = 0, failed = 0;
        for (const outcome of outcomes.values()) {
            if (outcome === 'completed') finished++;
            else if (outcome === 'blocked') blocked++;
            else failed++;
        }

        if (!blocked && !failed) return { finished, blocked, failed, line: 'All available quests are completed!', playDone: true };

        const parts = [];
        if (finished) parts.push(`${finished} quest(s) finished`);
        if (blocked) parts.push(`${blocked} skipped because this client cannot drive them`);
        if (failed) parts.push(`${failed} failed`);
        return { finished, blocked, failed, line: `Nothing left to run. ${parts.join(', ')}.`, playDone: finished > 0 };
    };

    const futureDate = (raw) => {
        try {
            if (!raw) return null;
            const when = raw instanceof Date ? raw : new Date(raw);
            return Number.isNaN(when.getTime()) || when.getTime() <= Date.now() ? null : when;
        } catch (_) { return null; }
    };

    const enrollmentBlockedUntil = () => futureDate(Mods.QuestStore?.questEnrollmentBlockedUntil);

    // Discord returns this beside questEnrollmentBlockedUntil on the quest list fetch and its own
    // client refuses to start any unfinished quest while it is set. Enrollment being blocked stops
    // new quests; access being suspended stops everything.
    const questAccessSuspendedUntil = () => {
        const store = Mods.QuestStore;
        if (!store) return null;
        const explicit = futureDate(store.questAccessSuspendedUntil);
        if (explicit) return explicit;
        return store.isQuestAccessSuspended === true ? new Date(0) : null;
    };

    // Discord joins stream key parts with a colon and the trailing component is the stream
    // owner: `call:<channelId>:<ownerId>` for a DM, `guild:<guildId>:<channelId>:<ownerId>`
    // for a guild voice channel, and its decoder reads them back in that order. This used to
    // build `call:<channelId>:<rnd(1000,9999)>`, which put a four digit number where a user
    // snowflake belongs and used the DM prefix even for a guild channel.
    const buildStreamKey = () => {
        try {
            const ownerId = Mods.UserStore?.getCurrentUser?.()?.id;
            if (!ownerId) return null;

            const dm = Mods.ChanStore?.getSortedPrivateChannels()?.[0]?.id;
            if (dm) return `call:${dm}:${ownerId}`;

            for (const g of Object.values(Mods.GuildChanStore?.getAllGuilds() ?? {})) {
                const vc = g?.VOCAL?.[0]?.channel;
                const guildId = vc?.guild_id ?? g?.id;
                if (vc?.id && guildId) return `guild:${guildId}:${vc.id}:${ownerId}`;
            }
            return null;
        } catch (e) {
            Logger.log(`[Task] Stream key lookup error: ${e.message}`, 'debug');
            return null;
        }
    };

    /* ── error classification ─────────────────────────────────── */
    // Traffic uses this to decide: retry, skip, or propagate.
    // 429/5xx = transient → backoff & retry.  4xx = permanent → skip quest.

    const ErrorHandler = {
        RETRYABLE: new Set([429, 500, 502, 503, 504, 408]),
        CLIENT_ERRORS: new Set([400, 403, 404, 409, 410]),

        classify(error) {
            const status = error?.status ?? error?.statusCode;
            return {
                isRetryable: this.RETRYABLE.has(status),
                isClientError: this.CLIENT_ERRORS.has(status),
                status,
                message: error?.message ?? error?.body?.message ?? `HTTP ${status ?? 'UNKNOWN'}`
            };
        },

        // 404 = quest removed server-side, 403 = region/permission, 410 = gone
        isSkippableQuest(error) {
            const status = error?.status;
            return status === 404 || status === 403 || status === 410;
        }
    };

    /* ── UI + logger ────────────────────────────────────────────
       Injects a draggable dashboard into Discord's DOM.
       Doubles as task-state store. render() rebuilds on every update.
    ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */

    const Logger = {
        root: null, tasks: new Map(), tickerId: null, _hotkey: null,

        init() {
            const oldUI = document.getElementById('orion-ui'); if (oldUI) oldUI.remove();
            const oldStyle = document.getElementById('orion-styles'); if (oldStyle) oldStyle.remove();

            const style = document.createElement('style');
            style.id = 'orion-styles';
            style.innerHTML = `
                @keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
                @keyframes fadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); margin: 0; padding: 0; height: 0; border: none; } }

                #orion-ui {
                    position: fixed; top: 32px; left: auto; right: 20px; width: 380px;
                    max-height: 53vh;
                    background: var(--background-base-low); color: var(--text-default);
                    border: 1px solid var(--border-subtle); border-radius: var(--radius-lg);
                    box-shadow: var(--shadow-button-overlay); z-index: 1001;
                    font-family: var(--font-primary);
                    overflow: hidden; animation: slideIn 0.3s ease; 
                    display: flex; flex-direction: column; box-sizing: border-box;
                    user-select: none;-webkit-app-region: no-drag;
                }

                #orion-head { padding: 12px 16px; background: var(--background-mod-muted); flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-subtle); cursor: grab; }
                #orion-head.dragging { cursor: grabbing; background: var(--control-secondary-background-default); }
                #orion-title { font-weight: 700; font-size: 15px; color: var(--text-strong); display: flex; align-items: center; gap: 8px; }
                #orion-title svg { color: var(--text-brand); }
                .dev-credit { font-size: 12px; margin-left: -4px; padding-top: 2px; font-weight: 500; color: var(--text-muted); }

                #orion-controls { display: flex; gap: 10px; align-items: center; }
                .ctrl-btn { cursor: pointer; transition: 0.2s; display: flex; align-items: center; }
                .ctrl-hide, .ctrl-opts { font-size: 11px; font-weight: 600; color: var(--text-muted); }
                .ctrl-hide:hover, .ctrl-opts:hover { color: var(--text-default); }
                .ctrl-stop { font-size: 11px; font-weight: 700; gap: 4px; padding: 3px 8px 3px 6px; border-radius: var(--radius-sm); background: transparent; border: 1px solid var(--control-critical-primary-background-default); color: var(--control-critical-primary-background-default); }
                .ctrl-stop:hover { background: var(--control-critical-primary-background-default); color: #fff; }

                #orion-logs { padding: 10px 14px; background: var(--background-base-lower); flex: 0 0 auto; font-family: 'Consolas', 'Monaco', monospace; font-size: 11px; height: 110px; overflow-y: auto; border-top: 1px solid var(--border-subtle); scroll-behavior: smooth; }
                .log-item { margin-bottom: 6px; display: flex; gap: 8px; line-height: 1.4; padding-bottom: 4px; }
                .log-ts { opacity: 0.5; min-width: 50px; font-size: 10px; }
                .c-info { color: var(--text-feedback-info); opacity: .8; } .c-success { color: var(--text-feedback-positive); } .c-err { color: var(--text-feedback-critical); } .c-warn { color: var(--text-feedback-warning); } .c-debug { color: #949ba4; }

                #orion-body { flex: 1 1 auto; padding: 12px; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }

                #orion-picker-form { display: flex; flex-direction: column; min-height: 0; }

                #orion-ui ::-webkit-scrollbar { width: 4px; height: 4px; }
                #orion-ui ::-webkit-scrollbar-track { background: transparent; }
                #orion-ui ::-webkit-scrollbar-thumb { background: var(--scrollbar-auto-scrollbar-color-thumb); border-radius: 4px; }

                .task-card { 
                    --state-color: var(--ansi-bright-blue);
                    --icon-bg-opacity: 15%;
                    --icon-color: var(--state-color);

                    display: flex; gap: 12px; padding: 10px 12px; margin-bottom: 8px; align-items: center;
                    background: var(--control-secondary-background-default); 
                    border-radius: var(--radius-sm); border: 1px solid var(--border-muted); 
                    border-left: 4px solid var(--state-color);
                    box-shadow: var(--shadow-low); transition: 0.3s; flex-shrink: 0;
                }
                .task-card.removing { animation: fadeOut 0.4s forwards; }
                .task-card.done { --state-color: var(--ansi-green); --icon-bg-opacity: 100%; --icon-color: #fff; }
                .task-card.failed { --state-color: var(--ansi-red); }
                .task-card.pending { --state-color: var(--ansi-bright-yellow); }

                .task-icon { position: relative; width: 40px; height: 40px; border-radius: 50%; flex: 0 0 auto; background-color: color-mix(in srgb, var(--state-color) var(--icon-bg-opacity), transparent); display: flex; align-items: center; justify-content: center; }

                .task-card.running .task-icon::before { content: ''; position: absolute; inset: 0; border-radius: 50%; z-index: 1; background: conic-gradient(lch(71 59 139) 0% var(--p, 0%), var(--border-subtle) var(--p, 0%) 100%); -webkit-mask-image: radial-gradient(circle at center, transparent 16px, black 17px); mask-image: radial-gradient(circle at center, transparent 16px, black 17px); }

                .task-icon-inner { z-index: 2; color: var(--icon-color); display: flex; transition: filter 0.2s, opacity 0.2s; }
                .task-card.running:hover .task-icon-inner { filter: blur(2px); opacity: 0.3; }

                .task-icon-overlay { position: absolute; inset: 0; z-index: 3; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; color: var(--text-default); opacity: 0; transition: opacity 0.2s; pointer-events: none; }
                .task-card.running:hover .task-icon-overlay { opacity: 1; }

                .task-info { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; justify-content: center; }
                .task-status { font-size: 10px; font-weight: 800; color: var(--state-color); text-transform: uppercase; letter-spacing: 0.5px; }
                .task-name { font-size: 13px; font-weight: 700; color: var(--text-strong); letter-spacing: 0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2; }
                .task-meta { font-size: 11px; font-weight: 700; color: var(--text-muted); display: flex; justify-content: space-between; }
                .task-actions { flex: 0 0 auto; display: flex; align-items: center; margin-left: 4px; }

                .claim-btn, .goto-btn { padding: 6px 10px; border: none; border-radius: var(--radius-sm); font-size: 11px; font-weight: 700; cursor: pointer; transition: 0.2s; text-transform: uppercase; letter-spacing: 0.2px; white-space: nowrap; font-family: inherit; color: #fff; }
                .claim-btn { background: var(--control-connected-background-default); }
                .claim-btn:hover:not(:disabled) { background: var(--control-connected-background-hover); }
                .claim-btn:disabled { opacity: 0.5; cursor: not-allowed; }
                .claim-btn.failed { background: var(--control-secondary-background-active); color: var(--text-default); }
                .goto-btn { background: var(--control-primary-background-default); }
                .goto-btn:hover:not(:disabled) { background: var(--control-primary-background-hover); }

                .picker-section-title { font-size: 11px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; flex-shrink: 0; }
                .reward-filters { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; flex-shrink: 0; }

                .reward-filter, .type-filter { background-color: transparent; border: 2px solid; padding: 4px 10px; border-radius: 24px; font-size: 10px; font-weight: 600; cursor: pointer; transition: 0.2s; color: var(--text-subtle); font-family: inherit; }
                .reward-filter:hover, .type-filter:hover { background-color: color-mix(in srgb, currentColor 25%, transparent); }
                .reward-filter.off, .type-filter.off { background: transparent; color: var(--text-muted); opacity: 0.4; }

                .picker-quest-list { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-height: 50px; overflow-y: auto; padding-right: 4px; }

                .quest-pick { display: flex; gap: 12px; padding: 10px; background: var(--control-secondary-background-default); border-radius: var(--radius-sm); border: 1px solid var(--border-muted); border-left-width: 4px; cursor: pointer; transition: 0.2s; align-items: center; user-select: none; flex-shrink: 0; }
                .quest-pick:hover { filter: brightness(1.15); }
                .quest-pick.hidden { display: none !important; }

                .native-cb { appearance: none; width: 20px; height: 20px; margin: 0; flex-shrink: 0; border: 1px solid var(--checkbox-border-default); border-radius: var(--radius-xs); background: transparent; cursor: pointer; transition: 0.15s; display: grid; place-content: center; }
                .native-cb::before {
                    content: ''; width: 12px; height: 12px; opacity: 0; transition: 0.1s;
                    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
                    background-size: contain; background-repeat: no-repeat; background-position: center;
                }
                .native-cb:checked { background: var(--checkbox-background-selected-default); border-color: var(--checkbox-border-selected-default); }
                .native-cb:checked::before { opacity: 1; }

                .picker-options { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
                .orion-option { display: flex; justify-content: space-between; align-items: center; background: var(--control-secondary-background-default); padding: 8px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-muted); }
                .orion-option-label { font-size: 13px; font-weight: 500; color: var(--text-default); }

                .native-toggle { appearance: none; width: 40px; height: 20px; margin: 0; flex-shrink: 0; background: var(--control-secondary-background-default); border-radius: 12px; cursor: pointer; position: relative; transition: 0.2s; border: 1px solid var(--border-muted); }
                .native-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: white; border-radius: 50%; box-shadow: var(--shadow-low); transition: 0.2s; }
                .native-toggle:checked { background: var(--control-primary-background-default); }
                .native-toggle:checked::after { transform: translateX(20px); }

                .orb-total { display: none; padding-top: 12px; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: #5865F2; text-align: center; flex-shrink: 0; }
                .picker-actions { display: flex; gap: 10px; padding-top: 12px; flex-shrink: 0; }
                .quest-pick-btn { flex: 1; padding: 10px; border: 1px solid; border-radius: var(--radius-sm, 8px); font-size: 13px; font-weight: 700; cursor: pointer; transition: 0.2s; display: flex; align-items: center; justify-content: center; gap: 6px; font-family: inherit; color: #fff;}
                .quest-pick-btn.start { background-color: var(--control-connected-background-default); border-color: var(--control-connected-border-default); }
                .quest-pick-btn.start:hover:not(:disabled) { background: var(--control-connected-background-hover); border-color: var(--control-connected-border-hover); }
                .quest-pick-btn.deselect { background-color: var(--control-secondary-background-default); border-color: var(--control-secondary-border-default); color: var(--text-default); }
                .quest-pick-btn.deselect:hover:not(:disabled) { background: var(--control-secondary-background-hover); }
                .quest-pick-btn:disabled { opacity: 0.5; cursor: not-allowed; }
            `;
            document.head.appendChild(style);

            this.root = document.createElement('div');
            this.root.id = 'orion-ui';
            this.root.innerHTML = `
                <div id="orion-head">
                    <span id="orion-title">${ICONS.BOLT} ${CONFIG.NAME}
                        <span class="dev-credit">by syntt_</span>
                        <span style="opacity:0.6; font-size:10px; margin-left:4px; padding-top: 3px; font-weight:500;">${CONFIG.VERSION}</span>
                    </span>
                    <div id="orion-controls">
                        <span class="ctrl-btn ctrl-stop" id="orion-stop" title="Stop script">${ICONS.STOP} STOP</span>
                        <span class="ctrl-btn ctrl-hide" id="orion-close" title="Shift + .">HIDE</span>
                        <span class="ctrl-btn ctrl-opts" id="orion-opts" title="Options">${ICONS.OPT}</span>
                    </div>
                </div>
                <div id="orion-body"><div style="text-align:center; padding:30px; color:var(--text-muted); font-size:12px; font-weight:500;">Initializing System...</div></div>
                <div id="orion-logs"></div>
            `;
            document.body.appendChild(this.root);

            const head = document.getElementById('orion-head');

            let collapsed = false;
            head.addEventListener('dblclick', e => {
                if (e.target.closest('.ctrl-btn')) return;
                collapsed = !collapsed;
                this.root.style.height = collapsed ? '50px' : '';
            });

            head.addEventListener('mousedown', e => {
                if (e.target.closest('.ctrl-btn')) return;

                head.classList.add('dragging');

                const startX = e.clientX, startY = e.clientY;
                const rect = this.root.getBoundingClientRect();
                const initialLeft = rect.left, initialTop = rect.top;

                this.root.style.left = `${initialLeft}px`;
                this.root.style.top = `${initialTop}px`;
                this.root.style.right = 'auto';
                e.preventDefault();

                const onMouseMove = ev => {
                    this.root.style.left = `${Math.max(0, Math.min(initialLeft + (ev.clientX - startX), window.innerWidth - this.root.offsetWidth))}px`;
                    this.root.style.top = `${Math.max(0, Math.min(initialTop + (ev.clientY - startY), window.innerHeight - 50))}px`;
                };

                const onMouseUp = () => {
                    head.classList.remove('dragging');
                    document.removeEventListener('mousemove', onMouseMove);
                    document.removeEventListener('mouseup', onMouseUp);
                };

                document.addEventListener('mousemove', onMouseMove);
                document.addEventListener('mouseup', onMouseUp);
            });

            document.getElementById('orion-body').addEventListener('click', async (e) => {
                if (e.target.classList.contains('goto-btn')) {
                    if (Mods.Router) Mods.Router.transitionTo('/quest-home');
                    return;
                }

                if (e.target.classList.contains('claim-btn')) {
                    const btn = e.target;
                    if (btn.disabled) return;

                    const questId = btn.getAttribute('data-id');
                    const taskData = this.tasks.get(questId);
                    if (!taskData) return;

                    btn.innerText = "WAITING...";
                    btn.disabled = true;
                    btn.style.opacity = "0.5";

                    // save state so render() respects it
                    this.updateTask(questId, { ...taskData, claimState: 'WAITING' });

                    try {
                        const claimRes = await Tasks.claimReward(questId);

                        if (claimRes?.body?.claimed_at) {
                            btn.innerText = "CLAIMED!";
                            this.log(`[Claim] Reward for "${taskData.name}" claimed successfully!`, 'success');

                            this.updateTask(questId, { ...taskData, status: "CLAIMED", claimable: false, claimState: null });
                            setTimeout(() => this.removeTask(questId), 2000);
                        } else {
                            // 2xx without claimed_at. The reward isn't claimed, and without this
                            // branch claimState stayed WAITING forever, so the button was dead for
                            // the rest of the session with no way back.
                            this.log(`[Claim] Reward for "${taskData.name}" wasn't confirmed. Claim it from Discord's Quests page.`, 'warn');
                            this.updateTask(questId, { ...taskData, claimState: 'FAILED' });
                        }
                    } catch (err) {
                        this.log(`[Claim] Action required for "${taskData.name}". Check Discord UI for captcha.`, 'warn');
                        // formally update state to FAILED so render() locks it permanently
                        this.updateTask(questId, { ...taskData, claimState: 'FAILED' });
                    }
                }
            });

            document.getElementById('orion-close').onclick = () => this.toggle();
            document.getElementById('orion-stop').onclick = () => this.shutdown();

            // Keep the reference so shutdown() can detach it. An anonymous listener here
            // survived STOP and stacked one more copy per paste, each closing over a dead
            // dashboard, the same stacking the gear handler below is careful to avoid.
            this._hotkey = e => (e.key === '>' || (e.shiftKey && e.key === '.')) && this.toggle();
            document.addEventListener('keydown', this._hotkey);

            // gear toggles the picker options panel. Those nodes only exist during the picker
            // phase, so look them up at click time and no-op otherwise. Registered once here
            // (not in showQuestPicker) so a fresh listener doesn't stack every time it opens.
            document.getElementById('orion-opts').addEventListener('click', () => {
                const panel = document.getElementById('orion-options-panel');
                if (!panel) return;
                const open = panel.style.display === 'none';
                panel.style.display = open ? '' : 'none';
                const list = document.getElementById('orion-quest-list');
                if (list) list.style.display = open ? 'none' : '';
                const actions = document.querySelector('#orion-picker-form .picker-actions');
                if (actions) actions.style.display = open ? 'none' : '';
            });

            this.startTicker();
        },

        toggle() {
            if (!this.root?.parentElement) return;  // dashboard already torn down
            this.root.style.display = this.root.style.display === 'none' ? 'flex' : 'none';
        },

        shutdown() {
            if (!RUNTIME.running) return;
            RUNTIME.running = false;
            this.log("[System] Stopping script & cleaning up...", "warn");

            if (this.tickerId) clearInterval(this.tickerId);
            if (this._hotkey) { document.removeEventListener('keydown', this._hotkey); this._hotkey = null; }

            for (const cleanupFn of RUNTIME.cleanups) {
                try { cleanupFn(); } catch (e) { this.log(`[Cleanup] ${e.message}`, 'debug'); }
            }
            RUNTIME.cleanups.clear();

            Patcher.clean();

            // Free the re-entry guard now, not in a second. Holding it for the grace period
            // meant a paste inside that window was refused with "Already running.", and the
            // UI that guard had just re-shown was then removed by this very timer, leaving
            // nothing on screen and no hint that pasting again would work.
            window.orionLock = false;

            // Capture the nodes to drop instead of looking them up by id when the timer fires:
            // a new instance reuses the same ids, so a late getElementById('orion-styles')
            // would delete the *new* dashboard's stylesheet.
            const styles = document.getElementById('orion-styles');
            const root = this.root;
            setTimeout(() => {
                if (styles?.parentElement) styles.remove();
                if (root?.parentElement) root.remove();
            }, 1000);
        },

        _getPct(t) {
            if (t.done) return 100;
            if (t.pending || t.failed || !t.max) return 0;
            return Math.min(100, (t.cur / t.max) * 100);
        },

        // Cosmetic smoothing between real progress updates. VIDEO and ACTIVITY compute their
        // own progress locally, so free-running +1/s matches reality closely enough.
        // GAME/STREAM don't: their only source of truth is Discord's heartbeat reply, which
        // lands every ~30s. Free-running there invented a moving bar even when Discord was
        // reporting nothing at all. It reached 100% while the quest sat at 0% in Discord's
        // own UI (issue #43). So they extrapolate strictly from the last heartbeat: the bar
        // is smooth while beats arrive, and frozen at the last known value when they stop.
        // ACHIEVEMENT is a milestone count, not seconds, so it is never ticked.
        SERVER_DRIVEN: ["GAME", "STREAM"],

        startTicker() {
            if (this.tickerId) clearInterval(this.tickerId);
            this.tickerId = setInterval(() => {
                if (!RUNTIME.running) return clearInterval(this.tickerId);
                for (const [id, task] of this.tasks.entries()) {
                    if (task.status !== "RUNNING" || task.type === "ACHIEVEMENT") continue;

                    let cur;
                    if (this.SERVER_DRIVEN.includes(task.type)) {
                        if (task.serverAt == null) continue;  // no heartbeat yet, show nothing
                        cur = Math.min(task.serverCur + (Date.now() - task.serverAt) / 1000, task.max);
                    } else {
                        cur = Math.min(task.cur + 1, task.max);
                    }
                    this.updateTask(id, { cur });
                }
            }, 1000);
        },

        updateTask(id, data) {
            const oldData = this.tasks.get(id);
            const isPending = data.status === "PENDING" || data.status === "QUEUE";
            const isDone = data.status === "COMPLETED" || data.status === "CLAIMED";
            const isFailed = data.status === "FAILED";

            const newData = { ...oldData, ...data, done: isDone, pending: isPending, failed: isFailed };
            this.tasks.set(id, newData);

            if (oldData && oldData.status === newData.status && oldData.removing === newData.removing &&
                oldData.claimable === newData.claimable && oldData.claimState === newData.claimState &&
                oldData.actionRequired === newData.actionRequired) {
                // must match the escaped id render() writes, or the in-place update misses
                const card = document.getElementById(`orion-task-${esc(id)}`);
                if (card) {
                    const pct = this._getPct(newData);

                    const iconContainer = card.querySelector('.task-icon');
                    if (iconContainer) iconContainer.style.setProperty('--p', `${pct}%`);

                    const overlay = card.querySelector('.task-icon-overlay');
                    if (overlay) overlay.textContent = `${Math.floor(pct)}%`;

                    const progressText = card.querySelector('.progress-text');
                    if (progressText) {
                        const unit = newData.type === 'ACHIEVEMENT' ? '' : 's';
                        progressText.textContent = `${Math.min(Math.floor(newData.cur), newData.max)} / ${newData.max}${unit}`;
                    }
                    return;
                }
            }
            this.render();
        },

        removeTask(id) {
            if (this.tasks.has(id)) {
                this.tasks.get(id).removing = true;
                this.render();
                setTimeout(() => { this.tasks.delete(id); this.render(); }, 500);
            }
        },

        log(msg, type = 'info') {
            const colors = { info: "#5865F2", success: "#3BA55C", warn: "#faa61a", err: "#f04747", debug: "#999" };
            console.log(`%c[ORION] %c${msg}`, `color: ${CONFIG.THEME}; font-weight: bold;`, `color: ${colors[type] || colors.info}`);
            try {
                const box = document.getElementById('orion-logs');
                if (box && type !== 'debug') {
                    const el = document.createElement('div'); el.className = `log-item c-${type}`;
                    el.innerHTML = `<span class="log-ts">${new Date().toLocaleTimeString().split(' ')[0]}</span> <span>${esc(msg)}</span>`;
                    box.appendChild(el); box.scrollTop = box.scrollHeight;
                    while (box.children.length > CONFIG.MAX_LOG_ITEMS) box.firstChild.remove();
                }
            } catch (e) { console.debug('[Logger] DOM error:', e.message); }
        },

        render() {
            if (document.getElementById('orion-picker-form')) return;
            const body = document.getElementById('orion-body');
            if (!body) return;
            if (!this.tasks.size) return body.innerHTML = `<div style="text-align:center; padding:30px; color:var(--text-muted); font-size:13px;">Waiting for tasks...</div>`;

            const sorted = [...this.tasks.entries()].sort((a, b) => {
                const ta = a[1], tb = b[1];
                if (ta.done !== tb.done) return ta.done ? 1 : -1;
                if (ta.failed !== tb.failed) return ta.failed ? 1 : -1;
                if (ta.pending !== tb.pending) return ta.pending ? 1 : -1;
                // among active tasks, highest progress first
                if (!ta.done && !ta.pending && !tb.done && !tb.pending) {
                    const pctA = ta.max ? ta.cur / ta.max : 0;
                    const pctB = tb.max ? tb.cur / tb.max : 0;
                    return pctB - pctA;
                }
                return 0;
            });

            body.innerHTML = sorted.map(([id, t]) => {
                const pct = t.pending || t.failed ? 0 : Math.min(100, (t.cur / t.max) * 100).toFixed(1);
                // state-based icons (done/failed/pending) win over type-based icons.
                const icon =
                    t.done ? ICONS.CHECK :
                    t.failed ? ICONS.STOP :
                    t.pending ? ICONS.CLOCK :
                    t.type === 'VIDEO' ? ICONS.VIDEO :
                    t.type === 'ACHIEVEMENT' ? ICONS.ACTIVITY :
                    t.type?.includes('GAME') ? ICONS.GAME :
                    t.type?.includes('STREAM') ? ICONS.STREAM :
                    ICONS.BOLT;

                let statusText = t.status === 'CLAIMED' ? 'CLAIMED' : t.done ? 'COMPLETED' : t.status;
                let progressLabel = t.pending ? 'In Queue' : t.failed ? 'Aborted' : 'Progress';
                const unit = t.type === 'ACHIEVEMENT' ? '' : 's';

                let actionBtn = '';

                if (t.claimable) {
                    if (t.claimState === 'WAITING') actionBtn = `<button class="claim-btn" disabled>WAITING...</button>`;
                    else if (t.claimState === 'FAILED') actionBtn = `<button class="claim-btn failed" disabled>ACTION REQUIRED</button>`;
                    else actionBtn = `<button class="claim-btn" data-id="${esc(id)}">CLAIM REWARD</button>`;
                } else if (t.actionRequired === 'ENROLL') {
                    statusText = 'ACTION REQUIRED'; progressLabel = 'Accept quest in Discord';
                    actionBtn = `<button class="goto-btn">GO TO QUESTS</button>`;
                } else if (t.type === 'ACHIEVEMENT' && t.status === 'RUNNING') {
                    statusText = 'ACTION REQUIRED'; progressLabel = 'Please, complete manually';
                    actionBtn = `<button class="goto-btn">GO TO QUESTS</button>`;
                }

                const stateClass = t.done ? 'done' : t.failed ? 'failed' : t.pending ? 'pending' : 'running';
                const removingClass = t.removing ? 'removing' : '';

                let taskMetaHtml = '';
                if (!t.done) {
                    taskMetaHtml = `
                    <div class="task-meta">
                        <span>${progressLabel}</span>
                        ${actionBtn ? '' : `<span class="progress-text">${Math.min(Math.floor(t.cur), t.max)} / ${t.max}${unit}</span>`}
                    </div>`;
                }

                return `
                <div id="orion-task-${esc(id)}" class="task-card ${stateClass} ${removingClass}">
                    <div class="task-icon" style="--p: ${pct}%">
                        <div class="task-icon-inner">${icon}</div>
                        ${stateClass === 'running' ? `<div class="task-icon-overlay">${Math.floor(pct)}%</div>` : ''}
                    </div>
                    <div class="task-info">
                        <div class="task-status">${statusText}</div>
                        <div class="task-name" title="${esc(t.name)}">${esc(t.name)}</div>
                        ${taskMetaHtml}
                    </div>
                    ${actionBtn ? `<div class="task-actions">${actionBtn}</div>` : ''}
                </div>`;
            }).join('');
        },

        showQuestPicker(quests) {
            return new Promise((resolve) => {
                const body = document.getElementById('orion-body');
                const logs = document.getElementById('orion-logs');

                const closePicker = (data) => {
                    if (logs) logs.style.display = 'block';
                    if (body) { body.classList.remove('picker-mode'); body.innerHTML = ''; }
                    resolve(data);
                };

                if (!body) return closePicker({ selectedQuests: new Set(), autoEnroll: false, autoClaim: false, playSound: false });
                if (logs) logs.style.display = 'none';

                const items = [];
                const rewardTypes = new Map();
                const questTypes = new Set();
                // Decided once for the whole list, so every card and the selected total print
                // the same side of the payout.
                const boosted = canUseMoreQuestOrbs();

                const REWARD_META = { 1: { label: "IN-GAME", color: "#e67e22" }, 3: { label: "AVATAR DECORATION", color: "#a358f2" }, 4: { label: "ORBS", color: "#5865F2" } };
                const REWARD_FALLBACK = { label: "OTHER", color: "#949ba4" };

                quests.forEach(q => {
                    const cfg = selectTaskConfig(q.config);
                    if (!cfg?.tasks) return;

                    const typeData = Tasks.detectType(cfg, q.config?.application?.id);
                    if (!typeData) return;
                    // exclude desktop-only quests (play/stream) on any non-desktop clients
                    if (!SYS.IS_DESKTOP && (typeData.type === 'GAME' || typeData.type === 'STREAM')) return;

                    const rw = q.config?.rewardsConfig?.rewards?.[0];
                    const orbs = orbReward(q.config);
                    // A quest is filed under ORBS when any of its rewards pays Orbs. Filing it by
                    // rewards[0] alone put a quest listing an in-game item ahead of its Orbs under
                    // IN-GAME, so choosing ORBS hid a quest that pays them. orbReward already sums
                    // every entry for the payout line; the filter was the one place still reading
                    // the first. It also has to agree with the plugin's Orb-only setting.
                    const rewardType = orbs ? 4 : (rw?.type ?? 0);
                    const rewardText = rw?.messages?.name ?? "Unknown Reward";

                    const meta = REWARD_META[rewardType] ?? REWARD_FALLBACK;

                    const displayType = typeData.type === 'WATCH_VIDEO' ? 'VIDEO' : typeData.type;
                    questTypes.add(displayType);

                    if (!rewardTypes.has(rewardType)) {
                        rewardTypes.set(rewardType, { label: meta.label, count: 0, type: rewardType, color: meta.color });
                    }
                    rewardTypes.get(rewardType).count++;

                    items.push({
                        id: q.id,
                        name: q.config?.messages?.questName ?? "Unknown Quest",
                        type: displayType,
                        rewardType,
                        rewardText,
                        orbs: orbPayout(orbs, boosted),
                        orbText: fmtOrbs(orbs, boosted),
                        color: meta.color
                    });
                });

                if (!items.length) return closePicker({ selectedQuests: new Set(), autoEnroll: false, autoClaim: false, playSound: false });

                const buildCard = (q) => `
                    <label class="quest-pick" data-rt="${q.rewardType}" data-qt="${q.type}" data-orbs="${q.orbs}" style="border-left-color: ${q.color};">
                        <input type="checkbox" name="quests" value="${q.id}" class="native-cb" checked>
                        <div class="task-info">
                            <div class="task-name" title="${esc(q.name)}">${esc(q.name)}</div>
                            <div class="task-meta" style="justify-content: flex-start; gap: 8px;">
                                <span style="text-transform: uppercase; color: var(--text-subtle);">${esc(q.type)}</span>
                                <span style="color: ${q.color};">${esc(q.orbText || q.rewardText)}</span>
                            </div>
                        </div>
                    </label>`;

                const buildToggle = (name, label, isChecked) => `
                    <div class="orion-option">
                        <span class="orion-option-label">${label}</span>
                        <input type="checkbox" name="${name}" class="native-toggle" ${isChecked ? 'checked' : ''}>
                    </div>`;

                body.innerHTML = `
                    <form id="orion-picker-form">
                        <div id="orion-options-panel" style="display:none;">
                            ${rewardTypes.size > 1 ? `
                                <div class="picker-section-title">Filter By Reward</div>
                                <div class="reward-filters">
                                    ${[...rewardTypes.values()].map(rt => `<button type="button" class="reward-filter" data-rt="${rt.type}" style="color: ${rt.color}; border-color: ${rt.color};">${rt.label} (${rt.count})</button>`).join('')}
                                </div>
                            ` : ''}
                            ${questTypes.size > 1 ? `
                                <div class="picker-section-title">Filter By Type</div>
                                <div class="reward-filters">
                                    ${[...questTypes].map(t => `<button type="button" class="type-filter" data-qt="${t}">${t}</button>`).join('')}
                                </div>
                            ` : ''}
                            <div class="picker-section-title">Options</div>
                            <div class="picker-options">
                                ${buildToggle('autoEnroll', 'Auto-enroll in quests', RUNTIME.autoEnroll)}
                                ${buildToggle('autoClaim', 'Auto-claim rewards', RUNTIME.autoClaim)}
                                ${buildToggle('playSound', 'Sound on completion', RUNTIME.playSound)}
                                ${buildToggle('randomDelay', 'Random 1-30min delay between cycles', RUNTIME.randomDelay)}
                            </div>
                        </div>

                        <div id="orion-quest-list" class="picker-quest-list">${items.map(buildCard).join('')}
                            <div id="orion-no-quests" style="display: none; margin: auto; text-align: center; color: var(--text-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;">
                                No quests available
                            </div>
                        </div>

                        <div id="orion-orb-total" class="orb-total"></div>

                        <div class="picker-actions">
                            <button type="button" class="quest-pick-btn deselect" id="select-all-btn">DESELECT ALL</button>
                            <button type="submit" class="quest-pick-btn start" id="start-btn">${ICONS.BOLT} <span id="start-btn-text">START (${items.length})</span></button>
                        </div>
                    </form>`;

                const form = document.getElementById('orion-picker-form');
                const selectAllBtn = document.getElementById('select-all-btn');
                const startBtn = document.getElementById('start-btn');

                const getVisibleCheckboxes = () => Array.from(form.querySelectorAll('.quest-pick input[type="checkbox"]'))
                    .filter(cb => !cb.closest('.quest-pick').classList.contains('hidden'));

                // Filled in once the balance read below settles; empty until then and on failure.
                let balanceText = '';

                const syncUI = () => {
                    const visibleCbs = getVisibleCheckboxes();
                    const totalChecked = visibleCbs.filter(cb => cb.checked).length;

                    // Counts the selected quests only, so a reward filter or a deselect moves the
                    // number. A list-wide total would stay put while the selection changed under it.
                    const orbTotal = document.getElementById('orion-orb-total');
                    if (orbTotal) {
                        const picked = visibleCbs.filter(cb => cb.checked).map(cb => cb.closest('.quest-pick'));
                        const orbs = picked.reduce((total, el) => total + (Number(el?.getAttribute('data-orbs')) || 0), 0);
                        const selectedText = orbs > 0 ? `${orbs} Orbs selected` : '';
                        const text = [selectedText, balanceText].filter(Boolean).join(' · ');
                        orbTotal.textContent = text;
                        orbTotal.style.display = text ? 'block' : 'none';
                    }

                    const startBtnText = document.getElementById('start-btn-text');
                    if (startBtnText) startBtnText.textContent = `START (${totalChecked})`;
                    startBtn.disabled = totalChecked === 0;

                    if (visibleCbs.length === 0) {
                        selectAllBtn.disabled = true;
                        selectAllBtn.textContent = 'SELECT ALL';
                    } else {
                        selectAllBtn.disabled = false;
                        selectAllBtn.textContent = visibleCbs.every(cb => cb.checked) ? 'DESELECT ALL' : 'SELECT ALL';
                    }

                    const noQuestsMsg = document.getElementById('orion-no-quests');
                    if (noQuestsMsg) {
                        noQuestsMsg.style.display = visibleCbs.length === 0 ? 'block' : 'none';
                    }
                };

                form.addEventListener('change', (e) => { if (e.target.name === 'quests') syncUI(); });

                const activeRewards = new Set([...rewardTypes.keys()].map(String));
                const activeTypes = new Set([...questTypes]);

                const applyFilters = () => {
                    form.querySelectorAll('.quest-pick').forEach(el => {
                        const rt = el.getAttribute('data-rt');
                        const qt = el.getAttribute('data-qt');
                        el.classList.toggle('hidden', !(activeRewards.has(rt) && activeTypes.has(qt)));
                    });
                    syncUI();
                };

                const FILTER_KINDS = [
                    { cls: 'reward-filter', attr: 'data-rt', set: activeRewards },
                    { cls: 'type-filter', attr: 'data-qt', set: activeTypes }
                ];

                form.addEventListener('click', (e) => {
                    const kind = FILTER_KINDS.find(k => e.target.classList.contains(k.cls));
                    if (kind) {
                        e.preventDefault();
                        const value = e.target.getAttribute(kind.attr);
                        e.target.classList.toggle('off');
                        if (e.target.classList.contains('off')) kind.set.delete(value);
                        else kind.set.add(value);
                        applyFilters();
                        return;
                    }

                    if (e.target.id === 'select-all-btn') {
                        e.preventDefault();
                        const visibleCbs = getVisibleCheckboxes();
                        if (visibleCbs.length === 0) return;

                        const shouldCheck = !visibleCbs.every(cb => cb.checked);
                        visibleCbs.forEach(cb => { cb.checked = shouldCheck; });
                        syncUI();
                    }
                });

                form.addEventListener('submit', (e) => {
                    e.preventDefault();

                    const selected = getVisibleCheckboxes().filter(cb => cb.checked);
                    if (selected.length === 0) return;

                    const data = new FormData(form);

                    closePicker({
                        selectedQuests: new Set(selected.map(cb => cb.value)),
                        autoEnroll: data.has('autoEnroll'),
                        autoClaim: data.has('autoClaim'),
                        playSound: data.has('playSound'),
                        randomDelay: data.has('randomDelay')
                    });
                });

                // apply layout lock and sync initial button states
                body.classList.add('picker-mode');
                syncUI();

                readOrbBalance()
                    .then(balance => {
                        if (balance === null) {
                            Logger.log('[System] Discord sent no Orb balance, so the picker shows none.', 'warn');
                            return;
                        }
                        balanceText = `${balance} Orbs on the account`;
                        syncUI();
                    })
                    .catch(e => Logger.log(`[System] Could not read the Orb balance: ${e?.message ?? e}`, 'warn'));
            });
        }
    };

    /* ── request queue ────────────────────────────────────────────
       FIFO queue processed one-at-a-time to respect rate limits.
       Retryable errors (429, 5xx) re-queue with exponential backoff.
       Client errors (4xx) reject immediately. The caller decides what to do.
    ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */

    const Traffic = {
        queue: [], processing: false,

        async enqueue(url, body) {
            if (!RUNTIME.running) return Promise.reject(new Error("Stopped"));
            return new Promise((resolve, reject) => {
                this.queue.push({ url, body, resolve, reject, attempts: 0 });
                this.process();
            });
        },

        async process() {
            if (this.processing || this.queue.length === 0) return;
            this.processing = true;

            while (this.queue.length > 0) {
                if (!RUNTIME.running) {
                    this.queue.forEach(req => req.reject(new Error("Shutdown")));
                    this.queue = [];
                    this.processing = false;
                    return;
                }

                const req = this.queue.shift();
                try {
                    const res = await Mods.API.post({ url: req.url, body: req.body });
                    req.resolve(res);
                } catch (e) {
                    const err = ErrorHandler.classify(e);

                    if (err.isRetryable && req.attempts < SYS.MAX_RETRIES) {
                        req.attempts++;
                        const delay = (e.body?.retry_after ?? Math.pow(2, req.attempts)) * 1000;
                        const isGlobal = e.body?.global === true;

                        Logger.log(`[Network] Retry ${req.attempts}/${SYS.MAX_RETRIES} in ${(delay / 1000).toFixed(1)}s (HTTP ${err.status})`, 'warn');

                        const retryJitter = rnd(200, 800);

                        if (isGlobal) {
                            // Freeze queue on global rate limits to prevent API abuse
                            this.queue.unshift(req);
                            await sleep(delay + retryJitter);
                        } else {
                            // Non-blocking retry for endpoint-specific limits
                            setTimeout(() => {
                                if (RUNTIME.running) {
                                    this.queue.push(req);
                                    this.process();
                                } else {
                                    // settle on shutdown so an awaiter doesn't hang until the task timeout
                                    req.reject(new Error('Shutdown'));
                                }
                            }, delay + retryJitter);
                        }
                    } else if (err.isClientError) {
                        Logger.log(`[Network] HTTP ${err.status}: ${req.url}`, 'debug');
                        req.reject(e);
                    } else {
                        Logger.log(`[Network] Request to ${req.url} failed: ${err.message}`, 'err');
                        req.reject(e);
                    }
                }

                await sleep(rnd(1200, 1800)); // delay between API calls
            }
            this.processing = false;
        }
    };

    /* ── store patching ───────────────────────────────────────────
       Monkey-patches Discord's RunStore/StreamStore so the client
       believes a game process is running. Fake PIDs, exePaths, and
       RPC payloads are injected and cleaned up on task completion.
    ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */

    let Mods = {};  // populated by loadModules(), holds Discord webpack internals

    /* HIDE_ACTIVITY note: skipping our own LOCAL_ACTIVITY_UPDATE dispatch is not enough
       to hide the status. Discord builds the "Playing X" presence from the store itself:
       LocalActivityStore/ActivityTrackingStore read getVisibleRunningGames()/getVisibleGame(),
       which we patch below for quest eligibility (issue #43). So the only way to stay
       invisible while keeping the spoof is to turn Discord's own status.showCurrentGame off
       for the duration and restore it on cleanup. Same setting Vencord's
       GameActivityToggle drives. */

    const Patcher = {
        games: [], real: {}, active: false,
        savedShowCurrentGame: null,  // value before we forced it off; null = not suppressed
        _unhide: null,               // pagehide listener, detached once the setting is restored

        // Overriding getRunningGames alone is no longer enough. Canary derives quest
        // eligibility from the "visible"/"candidate" views, and a game absent from those
        // never gets a heartbeat scheduled, so the quest sits at 0% forever (issue #43).
        // Older builds don't expose all of these, so each is patched only if present.
        PATCHED: ['getRunningGames', 'getGameForPID', 'getVisibleGame', 'getVisibleRunningGames',
                  'getRunningDiscordApplicationIds', 'getCandidateGames'],

        // stash originals so we can restore them on cleanup
        init(Store) {
            if (!Store) return;
            this.real = {};
            for (const name of this.PATCHED) {
                if (typeof Store[name] === 'function') this.real[name] = Store[name];
            }
            const absent = this.PATCHED.filter(n => !this.real[n]);
            if (absent.length) Logger.log(`[Patcher] Store lacks ${absent.join(', ')}, not patching those.`, 'debug');
        },

        // swap between real and patched store methods
        toggle(on) {
            const S = Mods.RunStore;
            const real = this.real;

            if (on && !this.active) {
                S.getRunningGames = () => [...real.getRunningGames.call(S), ...this.games];
                S.getGameForPID = (pid) => this.games.find(g => g.pid === pid) || real.getGameForPID.call(S, pid);

                // our game wins as "the" visible one, which is the whole point of the spoof
                if (real.getVisibleGame) S.getVisibleGame = () => this.games[0] ?? real.getVisibleGame.call(S);
                if (real.getVisibleRunningGames) S.getVisibleRunningGames = () => [...real.getVisibleRunningGames.call(S), ...this.games];
                if (real.getCandidateGames) S.getCandidateGames = () => [...real.getCandidateGames.call(S), ...this.games];
                if (real.getRunningDiscordApplicationIds) {
                    S.getRunningDiscordApplicationIds = () => {
                        const ids = real.getRunningDiscordApplicationIds.call(S);
                        const ours = this.games.map(g => String(g.id));
                        // shape varies by build, so preserve whichever collection came back
                        return ids instanceof Set ? new Set([...ids, ...ours]) : [...(ids ?? []), ...ours];
                    };
                }
                this.active = true;
            } else if (!on && this.active) {
                for (const [name, fn] of Object.entries(real)) S[name] = fn;
                this.active = false;
            }
        },

        // Force showCurrentGame off while a fake game is up and HIDE_ACTIVITY is on,
        // restore it once the last one goes away.
        syncPresenceSuppression() {
            const setting = Mods.ShowCurrentGame;
            const shouldSuppress = CONFIG.HIDE_ACTIVITY && this.games.length > 0;

            if (!setting) {
                if (shouldSuppress) Logger.log('[Patcher] status.showCurrentGame not found, cannot hide activity.', 'warn');
                return;
            }

            if (shouldSuppress && this.savedShowCurrentGame === null) {
                try {
                    // undefined = couldn't read it; assume it was on (Discord's default) so
                    // cleanup restores rather than silently leaving the user's status off
                    this.savedShowCurrentGame = setting.getSetting() !== false;
                    if (this.savedShowCurrentGame) {
                        Promise.resolve(setting.updateSetting(false)).catch(e =>
                            Logger.log(`[Patcher] Could not turn showCurrentGame off, activity stays visible: ${e.message}`, 'warn'));
                        // Reload skips shutdown entirely, and reloading is the first thing most
                        // people try when something looks stuck. Attempt the restore on the way
                        // out, and say plainly what to do if it doesn't land, because the write
                        // is async and a page teardown gives it no guarantee of completing.
                        this._unhide = () => { try { setting.updateSetting(true); } catch (_) { } };
                        window.addEventListener('pagehide', this._unhide);
                        Logger.log('[Patcher] Your "Display current activity as a status message" setting is off while quests run. STOP puts it back. If Discord is closed or reloaded before then, re-enable it in Discord settings.', 'warn');
                    }
                } catch (e) {
                    this.savedShowCurrentGame = null;
                    Logger.log(`[Patcher] showCurrentGame read failed, cannot hide activity: ${e.message}`, 'warn');
                }
            } else if (!shouldSuppress && this.savedShowCurrentGame !== null) {
                const restore = this.savedShowCurrentGame;
                this.savedShowCurrentGame = null;
                if (this._unhide) { window.removeEventListener('pagehide', this._unhide); this._unhide = null; }
                if (restore) {
                    try {
                        Promise.resolve(setting.updateSetting(true)).catch(e =>
                            Logger.log(`[Patcher] Failed to restore showCurrentGame. Re-enable "Display current activity as a status message" in Discord settings: ${e.message}`, 'err'));
                    } catch (e) {
                        Logger.log(`[Patcher] Failed to restore showCurrentGame. Re-enable "Display current activity as a status message" in Discord settings: ${e.message}`, 'err');
                    }
                }
            }
        },

        add(g) {
            if (this.games.some(x => x.pid === g.pid)) return;
            this.games.push(g);
            this.toggle(true);
            this.syncPresenceSuppression();
            this.dispatch([g], []);
            this.rpc(g);
        },

        remove(g) {
            const before = this.games.length;
            this.games = this.games.filter(x => x.pid !== g.pid);
            if (this.games.length === before) return;

            this.dispatch([], [g]);
            this.syncPresenceSuppression();
            if (!this.games.length) {
                this.toggle(false);
                this.rpc(null);
            } else {
                this.rpc(this.games[0]);
            }
        },

        // callers pass arrays; don't re-wrap (a re-wrap turned [] into [[]] and emitted
        // malformed added/removed shapes to any Flux consumer). Matches the Vencord port.
        dispatch(added, removed) {
            Mods.Dispatcher?.dispatch({
                type: CONST.EVT.GAME,
                added,
                removed,
                games: Mods.RunStore.getRunningGames()
            });
        },

        rpc(g) {
            if (CONFIG.HIDE_ACTIVITY && g) return;
            try {
                Mods.Dispatcher?.dispatch({
                    type: CONST.EVT.RPC,
                    socketId: null,
                    // use a fake PID (9999) and null activity to clear the playing status
                    pid: g ? g.pid : 9999,
                    activity: g ? {
                        application_id: g.id,
                        name: g.name,
                        type: 0,
                        details: null,
                        state: null,
                        timestamps: { start: g.start },
                        icon: g.icon,
                        assets: null
                    } : null
                });
            } catch (e) {
                Logger.log(`[RPC Cleanup] ${e.message}`, 'debug');
            }
        },

        clean() {
            this.games = [];
            this.toggle(false);
            // games emptied first, so this always restores showCurrentGame
            this.syncPresenceSuppression();
            this.rpc(null);
        }
    };

    // Pull something a user can act on out of a QUESTS_SEND_HEARTBEAT_FAILURE payload. Discord
    // wraps the failure in its own error type, so the useful parts sit at different depths
    // depending on whether the request got a response at all.
    const describeHeartbeatError = payload => {
        const e = payload?.error ?? payload;
        const parts = [];
        const status = e?.status ?? e?.httpStatus;
        if (status) parts.push(`HTTP ${status}`);
        const code = e?.body?.code ?? e?.code;
        if (code != null && code !== status) parts.push(`code ${code}`);
        const message = e?.body?.message ?? e?.message;
        if (message) parts.push(String(message));
        if (!parts.length) {
            try { parts.push(JSON.stringify(e).slice(0, 160)); } catch { parts.push(String(e)); }
        }
        return parts.join(', ') || 'no detail';
    };

    /* ── task handlers ────────────────────────────────────────────
       Each quest type (VIDEO, GAME, STREAM, ACTIVITY) has its own
       handler. GAME/STREAM share a generic() path that patches stores
       and listens for heartbeat events. VIDEO and ACTIVITY poll in a
       loop instead. Failed quest IDs go into `skipped` so we don't
       re-attempt them on the next cycle.
    ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */

    const Tasks = {
        skipped: new Set(),  // quest IDs that returned 4xx, no point retrying
        // What happened to each quest this run. The closing line counts these rather than
        // quests that gained completedAt while the run was alive, which cannot tell this
        // script's work from the user finishing the same quest by hand.
        outcomes: new Map(),
        _streamReal: undefined,  // untouched StreamStore method, captured before the first spoof
        _streamSpoofs: 0,        // active STREAM tasks holding the spoof

        // userStatus.progress is a plain object over REST, but dispatched payloads go through
        // the client's own transform first, so the shape isn't ours to assume. Defensive: if
        // it ever arrives as a Map, indexing with [] would read undefined and silently look
        // like "no progress".
        readProgress(userStatus, key) {
            const p = userStatus?.progress;
            const entry = p instanceof Map ? p.get(key) : p?.[key];
            return entry?.value ?? userStatus?.streamProgressSeconds ?? 0;
        },

        sanitize(name) { return name.replace(/[^a-zA-Z0-9 ]/g, "").trim().replace(/\s+/g, " "); },

        // Newer quest configs (taskConfigV2) carry the app per task as
        // tasks[key].applications[]; older ones had a single config.application.id.
        // A GAME quest built with the wrong id produces a fake process Discord can't
        // match to the quest, so it never schedules a heartbeat (issue #43).
        appIdFor(cfg, keyName, legacyAppId) {
            return taskAt(cfg?.tasks, keyName)?.applications?.[0]?.id ?? legacyAppId ?? null;
        },

        // Match task keys from the quest config to our handler types.
        //
        // The exact keys come first and the loose prefixes last, and that order is the whole
        // point. This used to test `k.includes("PLAY")` before anything else, and
        // "PLAY_ACTIVITY".includes("PLAY") is true, so every activity quest was routed to the
        // GAME handler: it injected a fake process and then waited for heartbeats Discord does
        // not send for an activity task, so the quest never finished and the ACTIVITY handler
        // was unreachable for its own quest type. Verified against a live client.
        //
        // The prefix entries still catch platform variants (PLAY_ON_XBOX, WATCH_VIDEO_ON_MOBILE)
        // and anything new Discord adds under the same families.
        /*
         * Task keys this client can actually drive. Most quests offer several: 38 of the 66 on a live
         * account carry two or three, always a desktop key beside console or mobile variants. Matching
         * by prefix and taking whichever the server happened to list first works only while the server
         * keeps listing the desktop one first. If that order ever changes, Orion picks PLAY_ON_XBOX,
         * injects a desktop process for it, reads progress under a key Discord never credits, and looks
         * healthy for 25 minutes before timing out. Prefer the exact key, and refuse the console-only
         * quests outright instead of pretending to run them.
         */
        detectType(cfg, applicationId) {
            const keys = taskKeys(cfg.tasks);
            // STREAM sits last of the desktop families on purpose. Discord will not open a
            // heartbeat for STREAM_ON_DESKTOP unless you are really Going Live with someone else
            // in the channel, and the engine only fakes the third of those checks, so that task
            // can only time out. A quest carrying STREAM_ON_DESKTOP beside PLAY_ON_DESKTOP or
            // WATCH_VIDEO used to be routed to the one path that cannot finish. See issue #75.
            const typeMap = [
                { match: k => k === "ACHIEVEMENT_IN_ACTIVITY", type: "ACHIEVEMENT" },
                { match: k => k === "PLAY_ACTIVITY", type: "ACTIVITY" },
                { match: k => k.includes("VIDEO"), type: "WATCH_VIDEO", prefer: ["WATCH_VIDEO"] },
                { match: k => k.startsWith("PLAY"), type: "GAME", prefer: ["PLAY_ON_DESKTOP"] },
                { match: k => k.startsWith("STREAM"), type: "STREAM", prefer: ["STREAM_ON_DESKTOP"] },
                { match: k => k.includes("ACTIVITY"), type: "ACTIVITY" }
            ];

            for (const { match, type, prefer } of typeMap) {
                const keyName = (prefer && keys.find(k => prefer.includes(k)))
                    || keys.find(k => match(k) && !CONSOLE_ONLY_KEYS.has(k));
                if (keyName) {
                    return {
                        type, keyName,
                        target: taskAt(cfg.tasks, keyName)?.target ?? 0,
                        appId: this.appIdFor(cfg, keyName, applicationId)
                    };
                }
            }

            // Every key was a console one, so there is nothing this client can do with the quest.
            if (keys.length > 0 && keys.every(k => CONSOLE_ONLY_KEYS.has(k))) return null;

            if (applicationId) {
                return {
                    type: "GAME", keyName: "PLAY_ON_DESKTOP",
                    target: taskAt(cfg.tasks, keys[0])?.target ?? 0,
                    appId: applicationId
                };
            }

            return null;
        },

        // Which win32 executable to claim, and where to say it lives.
        //
        // Discord's executables[] entries are not always a bare file name, and the array does not
        // come back in a stable order. Live on 2026-09-21: Dragonheir's entries include
        // "dragonheir silent gods/dragonheir.exe", and Marvel Rivals came back as
        // [test, shipping, marvel, launcher] and then as [test, launcher, marvel, shipping]
        // minutes apart. Taking the first win32 entry and pasting it after "C:\Program Files\
        // <Game>\" doubled the game folder, left a slash inside the reported executable name,
        // which no real process report contains, and named a different binary run to run.
        // relPath is relative to Program Files and always uses forward slashes.
        pickExecutable(executables, cleanName) {
            const loose = v => v.replace(/[^a-z0-9]/gi, "").toLowerCase();
            const fallbackExe = `${cleanName.replace(/\s+/g, "")}.exe`;

            const win32 = (Array.isArray(executables) ? executables : [])
                .filter(x => x?.os === "win32" && typeof x.name === "string" && x.name.length > 0)
                .map(x => x.name.replace(">", "").replace(/\\/g, "/"));
            if (win32.length === 0) return { exeName: fallbackExe, relPath: `${cleanName}/${fallbackExe}` };

            const rank = n => [
                /(^|[-_./ ])test([-_. ]|\.exe$)/i.test(n) ? "1" : "0",   // an internal build
                /launcher/i.test(n) ? "1" : "0",                          // the launcher, not the game
                n.includes("/") ? "1" : "0",                              // needs a directory guessed
                n
            ].join("");
            const chosen = win32.slice().sort((a, b) => (rank(a) < rank(b) ? -1 : rank(a) > rank(b) ? 1 : 0))[0];
            const parts = chosen.split("/").filter(Boolean);
            const exeName = parts[parts.length - 1];
            const dirs = parts.slice(0, -1);

            // Discord sometimes spells the game folder into the entry itself; prepending our own
            // copy on top is what produced ".../dragonheir silent gods/dragonheir silent gods/".
            const alreadyRooted = dirs.length > 0 && loose(dirs[0]) === loose(cleanName);
            return { exeName, relPath: (alreadyRooted ? parts : [cleanName, ...parts]).join("/") };
        },

        // pull real exe metadata from Discord's app registry; falls back to synthetic paths
        async fetchGameData(appId, appName) {
            try {
                const res = await Mods.API.get({ url: `/applications/public?application_ids=${appId}` });
                const appData = res?.body?.[0];
                const cleanName = this.sanitize(appData?.name || appName);
                const { exeName, relPath } = this.pickExecutable(appData?.executables, cleanName);

                return {
                    name: appData?.name || appName,
                    icon: appData?.icon,
                    exeName,
                    cmdLine: `C:\\Program Files\\${relPath.replace(/\//g, "\\")}`,
                    exePath: `c:/program files/${relPath.toLowerCase()}`,
                    id: appId
                };
            } catch (e) {
                Logger.log(`[FetchGame] Fallback for ${appName}: ${e?.message ?? e}`, 'debug');
                const cleanName = this.sanitize(appName);
                const safeExe = `${cleanName.replace(/\s+/g, "")}.exe`;
                return {
                    name: appName, exeName: safeExe,
                    cmdLine: `C:\\Program Files\\${cleanName}\\${safeExe}`,
                    exePath: `c:/program files/${cleanName.toLowerCase()}/${safeExe}`,
                    id: appId
                };
            }
        },

        async claimReward(questId) {
            return await Mods.API.post({
                url: `/quests/${questId}/claim-reward`,
                // Shaped after Discord's own claim action, which sends platform, location,
                // is_targeted and the two sealed fields, and nothing else. Orion used to add
                // metadata_raw and traffic_metadata_raw, which Discord never sends, while
                // nulling the sealed value that is sitting on the quest record.
                body: { platform: 0, location: 11, is_targeted: false, metadata_sealed: null, traffic_metadata_sealed: sealedFor(questId) }
            });
        },

        // safely aborts a broken or timed-out task, marks it as FAILED in the UI,
        // and adds it to the skip list to prevent infinite retry loops
        failTask(q, t, reason) {
            const currentProgress = Logger.tasks.get(q.id)?.cur ?? 0;
            Logger.updateTask(q.id, { name: t.name, type: t.type, cur: currentProgress, max: t.target, status: "FAILED" });
            Logger.log(`[Task] Aborted "${t.name}": ${reason}`, 'err');
            Tasks.skipped.add(q.id);
            // The active filter reads the skipped set and nothing else, so a quest that died
            // here leaves the rotation exactly like one this client never could drive.
            recordOutcome(Tasks.outcomes, q.id, 'failed');
            setTimeout(() => Logger.removeTask(q.id), 2000); 
        },

        // sends fake video-progress timestamps until Discord marks the quest done
        async VIDEO(q, t, s) {
            // read progress from actual task key, fall back to type name
            let cur = s?.progress?.[t.keyName]?.value ?? s?.progress?.[t.type]?.value ?? 0;
            let failCount = 0;

            Logger.updateTask(q.id, { name: t.name, type: "VIDEO", cur, max: t.target, status: "RUNNING" });

            const startTime = Date.now();
            let calls = 0;

            // No synthetic first ping. It used to fire 200-350ms in with a timestamp of
            // 0.200-0.250, which meant every video quest from every user opened with a value
            // inside the same 50ms window. A real player reports its first tick on its own
            // cadence, so the loop below is left to send it.

            while (cur < t.target && RUNTIME.running) {
                // Match Discord's native player cadence. A shorter interval buys nothing:
                // `cur` advances by real elapsed time below, so the quest still takes `target`
                // seconds of wall clock either way, and halving the delay only doubles the
                // number of requests. Measured: 68s target finished in 73s at 18 requests.
                const delayMs = rnd(7000, 9500);
                await sleep(delayMs);

                // calculate elapsed time with execution jitter
                const elapsedSec = (delayMs / 1000) + (Math.random() * 0.02 - 0.01);
                cur += elapsedSec;

                // match Discord's 6-decimal float format
                const payloadTs = Number(Math.min(t.target, cur).toFixed(6));

                try {
                    const r = await Traffic.enqueue(`/quests/${q.id}/video-progress`, { timestamp: payloadTs });
                    calls++;
                    // sync with server if it reports higher progress
                    const serverVal = r?.body?.progress?.[t.keyName]?.value ?? r?.body?.progress?.WATCH_VIDEO?.value;
                    if (serverVal > cur) cur = Math.min(t.target, serverVal);
                    if (r?.body?.completed_at) break;
                    failCount = 0;
                } catch (e) {
                    failCount++;
                    const err = ErrorHandler.classify(e);
                    if (err.isClientError) {
                        Logger.log(`[Task] Video quest unavailable (HTTP ${err.status}). Skipping.`, 'warn');
                        return Tasks.failTask(q, t, `Client Error ${err.status}`);
                    }
                    if (failCount >= SYS.MAX_TASK_FAILURES) {
                        return Tasks.failTask(q, t, 'Too many network failures');
                    }
                    Logger.log(`[Task] VIDEO progress failed (${failCount}/${SYS.MAX_TASK_FAILURES}): ${err.message}`, 'debug');
                }

                Logger.updateTask(q.id, { name: t.name, type: "VIDEO", cur, max: t.target, status: "RUNNING" });

                if (Date.now() - startTime > SYS.MAX_TIME) {
                    return Tasks.failTask(q, t, 'Timeout exceeded');
                }
            }
            if (RUNTIME.running) {
                Logger.log(`[Task] VIDEO "${t.name}" done in ${calls} API calls`, 'debug');
                Tasks.finish(q, t);
            }
        },

        GAME(q, t, s) { return Tasks.generic(q, t, "GAME", "PLAY_ON_DESKTOP", s); },
        STREAM(q, t, s) { return Tasks.generic(q, t, "STREAM", "STREAM_ON_DESKTOP", s); },

        // shared path for GAME/STREAM. Injects a fake process and subscribes to heartbeat events.
        async generic(q, t, type, fallbackKey, s) {
            if (!RUNTIME.running) return;
            // Prefer the key detected from the quest config. detectType matches task keys by
            // substring, so a renamed variant (a PLAY_ON_DESKTOP_V2, say) still resolves,
            // but reading progress under a hardcoded legacy name would return undefined and
            // pin the task at 0 until the safety timer kills it.
            const key = t.keyName || fallbackKey;
            const gameData = await this.fetchGameData(t.appId, t.name);

            // Re-check after the await, not only before it. Fetching the app metadata is a
            // network round trip, and STOP landing inside it used to let this continuation
            // wake up afterwards and patch the store for a run that had already torn down.
            if (!RUNTIME.running) return;

            return new Promise(resolve => {
                const pid = rnd(2500, 12500) * 4;
                const game = {
                    id: gameData.id, name: gameData.name, icon: gameData.icon,
                    pid, pidPath: [pid], processName: gameData.name, start: Date.now(),
                    exeName: gameData.exeName, exePath: gameData.exePath, cmdLine: gameData.cmdLine,
                    executables: [{ os: 'win32', name: gameData.exeName, is_launcher: false }],
                    windowHandle: 0, fullscreenType: 0, overlay: true, sandboxed: false,
                    hidden: false, isLauncher: false
                };

                let cleanupHook;
                let cleaned = false;
                let spoofReleased = false;
                let safetyTimer;
                let watchdogTimer;
                let beats = 0;
                let failedBeats = 0;          // failures over the whole task, for the message
                let consecutiveFailures = 0;  // reset by any successful beat, this one gives up
                let lastFailure = null;

                if (type === "STREAM") {
                    if (Mods.StreamStore) {
                        // Capture the untouched method once and refcount the spoof. Stashing it
                        // per-task meant a second concurrent STREAM task stashed the first task's
                        // spoof and later "restored" that, leaving the store patched for good.
                        if (Tasks._streamSpoofs === 0) Tasks._streamReal = Mods.StreamStore.getStreamerActiveStreamMetadata;
                        Tasks._streamSpoofs++;
                        Mods.StreamStore.getStreamerActiveStreamMetadata = () => ({ id: gameData.id, pid, sourceName: gameData.name });
                    }
                    // restore unconditionally when the store exists. Assigning back an
                    // undefined original is the correct revert. Guarding on it being truthy
                    // would leave our fake installed when the method was undefined at patch time.
                    cleanupHook = () => {
                        if (Mods.StreamStore && Tasks._streamSpoofs > 0 && --Tasks._streamSpoofs === 0) {
                            Mods.StreamStore.getStreamerActiveStreamMetadata = Tasks._streamReal;
                        }
                    };
                } else {
                    Patcher.add(game);
                    cleanupHook = () => Patcher.remove(game);
                }

                // Seed from progress the server already holds. Painting 0 here made a resumed
                // quest look like it had restarted from scratch until the next heartbeat
                // (~30s later) corrected it.
                const seeded = Tasks.readProgress(s, key);
                Logger.updateTask(q.id, { name: t.name, type, cur: seeded, max: t.target, status: "RUNNING" });
                Logger.log(`[Task] Started ${type}: ${gameData.name}`, 'info');

                // Stopping the watching and releasing the fake process used to be one step. They
                // are separate because a finished quest holds the process a little longer than it
                // needs it, and the watchdog must not be alive for that window: Discord ends its
                // own heartbeat the moment it sees completedAt, so every beat the watchdog would
                // wait for after completion is one that is never going to arrive.
                const stopWatching = () => {
                    if (cleaned) return;
                    cleaned = true;
                    clearTimeout(safetyTimer);
                    clearTimeout(watchdogTimer);
                    try { Mods.Dispatcher?.unsubscribe(CONST.EVT.HEARTBEAT, check); } catch (e) {
                        Logger.log(`[Dispatcher] Unsubscribe failed: ${e.message}`, 'debug');
                    }
                    try { Mods.Dispatcher?.unsubscribe(CONST.EVT.HEARTBEAT_FAIL, onFail); } catch (e) {
                        Logger.log(`[Dispatcher] Unsubscribe failed: ${e.message}`, 'debug');
                    }
                };

                const releaseSpoof = () => {
                    if (spoofReleased) return;
                    spoofReleased = true;
                    try { cleanupHook(); } catch (e) { Logger.log(`[Task] Cleanup: ${e.message}`, 'debug'); }
                    RUNTIME.cleanups.delete(abort);
                };

                const finish = () => { stopWatching(); releaseSpoof(); };

                // A real play session does not end on the second the quest target is reached, and
                // its overshoot is different every time. Dropping the process on the exact beat
                // that crossed the target made every session precisely as long as the quest
                // requirement, on every quest. Interruptible: Stop drops it at once.
                const linger = async () => {
                    const maxMinutes = Number(CONFIG.PLAY_SESSION_TAIL_MIN ?? 0);
                    if (!Number.isFinite(maxMinutes) || maxMinutes <= 0) return;
                    const maxMs = Math.round(maxMinutes * 60 * 1000);
                    const tailMs = rnd(Math.round(maxMs * 0.4), maxMs);
                    Logger.log(`[Task] Holding ${type} "${gameData.name}" for ${Math.round(tailMs / 1000)}s after completion.`, 'debug');
                    const step = 500;
                    for (let waited = 0; waited < tailMs && RUNTIME.running && !spoofReleased; waited += step) {
                        await sleep(Math.min(step, tailMs - waited));
                    }
                };

                // What shutdown runs. finish() alone clears the timers that would otherwise have
                // resolved this promise, so registering it bare left the task pending forever.
                // Separate from finish() so the completion path can still await onComplete
                // (auto-claim) before resolving.
                const abort = () => { finish(); resolve(); };

                safetyTimer = setTimeout(() => {
                    if (RUNTIME.running) Tasks.failTask(q, t, 'Timeout exceeded (25m)');
                    finish();
                    resolve();
                }, SYS.MAX_TIME);

                // Discord drives these quests: it sends /quests/{id}/heartbeat itself while it
                // believes the game runs, and we only read the replies. If it never accepts the
                // injected process, no heartbeat ever arrives and the task would sit "RUNNING"
                // for the full 25 minutes with nothing happening. Give up after 90s of silence
                // and say why.
                // Re-armed on every beat rather than checked once, so it also catches a quest that
                // beats a few times and then goes silent. A one-shot `beats > 0` test would let that
                // sit RUNNING for the full 25 minutes while the ticker extrapolated the bar to 100%,
                // which is the same misleading display issue #43 was about.
                const armWatchdog = () => {
                    clearTimeout(watchdogTimer);
                    watchdogTimer = setTimeout(() => {
                        if (cleaned || !RUNTIME.running) return;
                        const failureTail = failedBeats > 0
                            ? ` and ${failedBeats} failed beat(s), the last one ${lastFailure}`
                            : '';
                        Logger.log(beats === 0
                            ? (failedBeats > 0
                                ? `[Task] Discord never reported progress for "${t.name}". All ${failedBeats} heartbeat(s) it tried failed, the last one ${lastFailure}.`
                                : `[Task] Discord never reported progress for "${t.name}". It is not accepting the injected process on this client, so there is nothing to wait for.`)
                            : `[Task] Discord stopped reporting progress for "${t.name}" after ${beats} update(s)${failureTail}. Giving up instead of idling.`, 'err');
                        Tasks.failTask(q, t, failedBeats > 0
                            ? `No heartbeat from Discord (${failedBeats} failed beat(s))`
                            : 'No heartbeat from Discord');
                        finish();
                        resolve();
                    }, SYS.HEARTBEAT_GRACE);
                };
                armWatchdog();

                const check = (d) => {
                    if (!RUNTIME.running) { finish(); resolve(); return; }
                    if (d?.questId !== q.id) return;

                    beats++;
                    consecutiveFailures = 0;
                    armWatchdog();
                    const prog = Tasks.readProgress(d.userStatus, key);
                    // anchor for the ticker: it extrapolates from here until the next beat
                    Logger.updateTask(q.id, {
                        name: t.name, type, cur: prog, max: t.target, status: "RUNNING",
                        serverCur: prog, serverAt: Date.now()
                    });

                    if (prog >= t.target) {
                        stopWatching();
                        Promise.resolve(Tasks.finish(q, t))
                            .then(linger)
                            .finally(() => { releaseSpoof(); resolve(); });
                    }
                };

                // Discord does not retry a failed beat: the sender dispatches the failure and the
                // next attempt is the tick it already scheduled 60s out. So a failure is proof
                // Discord is still driving the quest, and the watchdog is rearmed for it rather
                // than counting it as silence. Consecutive failures are what give up, which also
                // makes Discord's own status code reachable instead of a guess (issue #74).
                // The exception is a failure before any credited beat: that keeps the original
                // deadline, because "not accepting the injected process" is the issue #43 answer
                // and is worth a beat and a half rather than five minutes of retries.
                const onFail = (d) => {
                    if (!RUNTIME.running) { finish(); resolve(); return; }
                    if (d?.questId !== q.id) return;

                    failedBeats++;
                    consecutiveFailures++;
                    lastFailure = describeHeartbeatError(d);

                    if (consecutiveFailures >= SYS.MAX_TASK_FAILURES) {
                        Logger.log(`[Task] Discord's heartbeat for "${t.name}" failed ${consecutiveFailures} times in a row: ${lastFailure}. Giving up rather than waiting out the watchdog.`, 'err');
                        Tasks.failTask(q, t, `Discord could not report progress (${lastFailure})`);
                        finish();
                        resolve();
                        return;
                    }

                    Logger.log(`[Task] Discord's heartbeat for "${t.name}" failed (${consecutiveFailures}/${SYS.MAX_TASK_FAILURES}): ${lastFailure}`, 'debug');
                    if (beats > 0) armWatchdog();
                };

                Mods.Dispatcher?.subscribe(CONST.EVT.HEARTBEAT, check);
                Mods.Dispatcher?.subscribe(CONST.EVT.HEARTBEAT_FAIL, onFail);
                RUNTIME.cleanups.add(abort);
            });
        },

        // OAuth2 → discordsays.com bypass for ACHIEVEMENT_IN_ACTIVITY.
        // Discord trusts the activity backend to validate progress, so a forged
        // POST from an authorized session is accepted. Flow:
        //   1) /oauth2/authorize the quest's app (returns code in location URL)
        //   2) /applications/{appId}/proxy-tickets (returns proxy ticket)
        //   3) POST {appId}.discordsays.com/.proxy/acf/authorize {code} → DS token
        //   4) POST {appId}.discordsays.com/.proxy/acf/quest/progress {progress: target}
        //   5) /oauth2/tokens + DELETE to clean up the grant
        //
        // Steps 3-4 are blocked by Discord's renderer CSP (connect-src doesn't
        // include *.discordsays.com). _bypassPost picks the best available
        // transport that escapes CSP: VencordNative if our plugin is installed,
        // DiscordNative if it exposes anything, else direct fetch.
        // POST to a CSP-restricted URL via the best available transport.
        // Returns { ok, status, body } on success, throws { status, body } on HTTP error
        // and TypeError on CSP/network. Probes in order: localhost relay (no mod needed
        // see tools/orion-relay/), VencordNative plugin (CSP-free via IPC to main
        // process), DiscordNative HTTP candidates (if any exist), raw fetch.
        _relayUrl: 'http://127.0.0.1:43210',
        _relayProbe: null,
        _relayProbeAt: 0,
        RELAY_PROBE_TTL: 60000,   // re-probe after a minute instead of trusting one answer forever

        // Cache the in-flight probe promise so concurrent callers (video runs at
        // concurrency 2) all await the same result, instead of a second caller seeing
        // a half-set flag and getting undefined (falsy) back. Cached for a TTL rather than
        // for the whole run: a permanent cache meant a relay started mid-run was never
        // picked up, and a relay that died was still treated as available.
        _probeRelay() {
            if (this._relayProbe && Date.now() - this._relayProbeAt < this.RELAY_PROBE_TTL) return this._relayProbe;
            this._relayProbeAt = Date.now();
            return this._relayProbe = (async () => {
                try {
                    const r = await Promise.race([
                        fetch(`${this._relayUrl}/health`, { method: 'GET', redirect: 'error' }),
                        new Promise((_, reject) => setTimeout(() => reject(new Error('probe timeout')), 800))
                    ]);
                    if (!RUNTIME.running) return false;
                    if (r.ok) Logger.log('[Bypass] Orion Relay detected on 127.0.0.1:43210.', 'info');
                    return r.ok;
                } catch (_) {
                    return false;
                }
            })();
        },

        async _bypassPost(url, headers, jsonBody) {
            // 1) Localhost relay (tools/orion-relay/). CSP allows http://127.0.0.1:*,
            //    the relay forwards to discordsays.com from outside the browser sandbox.
            //    This is the no-mod path: standalone userscript + tiny helper.
            const relayAvailable = await this._probeRelay();
            if (!RUNTIME.running) throw new Error('Shutdown');
            if (relayAvailable) {
                let r;
                try {
                    r = await fetch(`${this._relayUrl}/proxy`, {
                        method: 'POST',
                        // X-Orion-Relay is required by the relay so that a browser can never
                        // reach /proxy as an unpreflighted "simple request". Without it, any
                        // open tab could drive the relay by posting text/plain. Relay >= v4.11.3
                        // refuses the POST if it is missing.
                        headers: { 'Content-Type': 'application/json', 'X-Orion-Relay': '1' },
                        body: JSON.stringify({ url, headers, body: jsonBody }),
                        redirect: 'error'
                    });
                    if (!RUNTIME.running) throw new Error('Shutdown');
                } catch (e) {
                    if (!RUNTIME.running) throw e;
                    // relay went away between probe and POST, so drop the cached answer and let
                    // the next transport try, instead of failing the whole bypass on a dead port
                    this._relayProbe = null;
                    Logger.log(`[Bypass] Relay stopped responding, trying other transports: ${e?.message ?? e}`, 'debug');
                }
                if (r) {
                    if (!r.ok) {
                        const body = await r.text();
                        if (!RUNTIME.running) throw new Error('Shutdown');
                        // A relay older than v4.11.3 does not know the header and answers 403.
                        // That is a stale helper, not a failed bypass, so say which one it is
                        // and let the other transports try instead of failing the quest.
                        if (r.status === 403 && body.includes('X-Orion-Relay')) {
                            this._relayProbe = null;
                            Logger.log('[Bypass] The running Orion Relay is out of date. Update it from tools/orion-relay/ (it now requires the X-Orion-Relay header so a web page cannot drive it). Trying other transports.', 'warn');
                            r = null;
                        } else {
                            throw { status: r.status, body };
                        }
                    }
                }
                if (r) {
                    const result = await r.json();
                    if (!RUNTIME.running) throw new Error('Shutdown');
                    if (!result.ok) throw { status: result.status, body: result.body };
                    return result;
                }
            }

            // 2) Vencord plugin native module. Works if the user has the OrionQuests Vencord plugin
            //    installed (`native.ts` at the repo root). Routes through Electron main process.
            try {
                const helper = window.VencordNative?.pluginHelpers?.OrionQuests;
                if (helper) {
                    const u = new URL(url);
                    const appId = u.hostname.split('.')[0];
                    const questId = headers['X-Discord-Quest-ID'];
                    const referrer = headers['Referer'];
                    if (u.pathname.endsWith('/acf/authorize')) {
                        const { code } = JSON.parse(jsonBody);
                        const r = await helper.discordsaysAuthorize({ appId, questId, authCode: code, referrer });
                        if (!RUNTIME.running) throw new Error('Shutdown');
                        if (!r.ok) throw { status: r.status, body: r.body };
                        return { ok: true, status: r.status, body: r.body };
                    }
                    if (u.pathname.endsWith('/acf/quest/progress')) {
                        const { progress } = JSON.parse(jsonBody);
                        const token = headers['X-Auth-Token'];
                        const r = await helper.discordsaysProgress({ appId, questId, token, target: progress, referrer });
                        if (!RUNTIME.running) throw new Error('Shutdown');
                        if (!r.ok) throw { status: r.status, body: r.body };
                        return { ok: true, status: r.status, body: r.body };
                    }
                }
            } catch (e) {
                if (!RUNTIME.running) throw e;
                if (e?.status) throw e;
                Logger.log(`[Bypass] VencordNative path errored: ${e?.message ?? e}`, 'debug');
            }

            // 3) DiscordNative HTTP probe. Best-effort, since no documented method exists,
            //    but new Discord builds occasionally expose one. We probe several
            //    plausible paths and use the first that's a callable function.
            const dn = window.DiscordNative;
            if (dn) {
                // Only members that plausibly *are* an HTTP client. fileManager.fetchURL and
                // processUtils.fetch were in this list, and neither is one: the first retrieves
                // a URL into the file layer (a write or a save dialog, not a POST) and the second
                // belongs to process management. Calling privileged IPC speculatively with a
                // POST-shaped argument risks a side effect the status-shape check can't even see.
                const probes = [
                    () => dn.http?.makeRequest,
                    () => dn.app?.makeRequest,
                ];
                for (const probe of probes) {
                    if (!RUNTIME.running) throw new Error('Shutdown');
                    try {
                        const fn = probe();
                        if (typeof fn === 'function') {
                            const r = await fn.call(dn, { method: 'POST', url, headers, body: jsonBody });
                            if (!RUNTIME.running) throw new Error('Shutdown');
                            if (r && (r.status || r.statusCode)) {
                                const status = r.status ?? r.statusCode;
                                return { ok: status >= 200 && status < 300, status, body: r.body ?? r.responseText ?? '' };
                            }
                        }
                    } catch (e) {
                        if (!RUNTIME.running) throw e;
                    }
                }
            }

            // 4) Direct fetch. Works on web Discord (no CSP) or relaxed clients.
            //    CSP-blocked on Discord Desktop: throws TypeError "Failed to fetch".
            //    redirect:'error' so a 3xx from discordsays can't bounce our auth token
            //    and proxy-ticket Referer to an arbitrary host.
            if (!RUNTIME.running) throw new Error('Shutdown');
            const res = await fetch(url, { method: 'POST', headers, body: jsonBody, redirect: 'error' });
            if (!RUNTIME.running) throw new Error('Shutdown');
            const body = await res.text();
            if (!RUNTIME.running) throw new Error('Shutdown');
            if (!res.ok) throw { status: res.status, body };
            return { ok: true, status: res.status, body };
        },

        async bypassAchievement(q, t) {
            // taskConfigV2 moved the app off config.application and onto the task, so reading
            // the legacy field alone resolves null on every current quest and this bailed out
            // before it ever tried. t.appId already carries whatever Tasks.appIdFor resolved,
            // so prefer it and keep the legacy read as the last fallback (issue #43).
            const appId = t.appId || q.config?.application?.id;
            let reason = null;
            if (!appId) {
                reason = 'this quest carries no application id, so there is nothing to authorize against';
                return { ok: false, reason };
            }
            // appId is interpolated straight into discordsays URLs. Refuse anything
            // non-numeric so a malformed/hostile id can't redirect the request elsewhere.
            if (!/^\d+$/.test(String(appId))) {
                reason = `the quest's application id ("${appId}") is not numeric, so it was refused before any request went out`;
                Logger.log(`[Bypass] Refusing non-numeric appId "${appId}".`, 'warn');
                return { ok: false, reason };
            }

            // Snapshot the grants this app already has BEFORE we authorize, so cleanup
            // revokes only the grant we create and never one the user made themselves.
            // The snapshot is a PRECONDITION: if it fails we abort before authorizing, so we
            // never create a grant we can't later identify and revoke.
            let preGrantIds;
            try {
                const before = await Mods.API.get({ url: '/oauth2/tokens' });
                if (!RUNTIME.running) return { ok: false, reason };
                preGrantIds = new Set((before?.body || []).filter(tk => tk.application?.id === appId).map(tk => tk.id));
            } catch (e) {
                if (!RUNTIME.running) return { ok: false, reason };
                Logger.log(`[Bypass] Couldn't snapshot existing grants; aborting so we never leave an un-revocable authorization: ${e?.message}`, 'warn');
                return { ok: false, reason };
            }

            try {
                // Consent gate: never OAuth-authorize a third-party app without the user's
                // explicit, informed OK. Resolve the app's display name for the prompt; a
                // decline aborts before the irreversible authorize. preGrantIds is already a
                // Set here (snapshot failure returned above), so no grant leaks on decline.
                let appName = null;
                try {
                    const a = await Mods.API.get({ url: `/applications/public?application_ids=${appId}` });
                    if (!RUNTIME.running) return { ok: false, reason };
                    appName = a?.body?.[0]?.name ?? null;
                } catch (_) {
                    if (!RUNTIME.running) return { ok: false, reason };
                }
                const consented = await Consent.ask(appId, appName);
                if (!RUNTIME.running) return { ok: false, reason };
                if (!consented) {
                    Logger.log(`[Bypass] Consent declined for "${t.name}". Not authorizing the app.`, 'warn');
                    return { ok: false, reason };
                }

                Logger.log(`[Bypass] Trying Discord Says auth flow for "${t.name}"...`, 'info');

                const authRes = await Mods.API.post({
                    url: '/oauth2/authorize',
                    query: {
                        response_type: 'code',
                        client_id: appId,
                        scope: 'identify applications.commands applications.entitlements'
                    },
                    body: {
                        permissions: '0',
                        authorize: true,
                        integration_type: 1,
                        location_context: { guild_id: '10000', channel_id: '10000', channel_type: 10000 }
                    }
                });
                if (!RUNTIME.running) return { ok: false, reason };
                const location = authRes?.body?.location;
                if (!location) throw new Error('no location in /oauth2/authorize response');
                const authCode = new URL(location).searchParams.get('code');
                if (!authCode) throw new Error('no code in authorize location');

                const ticketRes = await Mods.API.post({ url: `/applications/${appId}/proxy-tickets`, body: {} });
                if (!RUNTIME.running) return { ok: false, reason };
                const proxyTicket = ticketRes?.body?.ticket;
                if (!proxyTicket) throw new Error('no proxy ticket');

                const referrer = `https://${appId}.discordsays.com/?instance_id=example-cl-instance&platform=desktop&discord_proxy_ticket=${encodeURIComponent(proxyTicket)}`;

                const dsAuthRes = await Tasks._bypassPost(
                    `https://${appId}.discordsays.com/.proxy/acf/authorize`,
                    { 'Content-Type': 'application/json', 'X-Auth-Token': '', 'X-Discord-Quest-ID': q.id, 'Referer': referrer },
                    JSON.stringify({ code: authCode })
                );
                if (!RUNTIME.running) return { ok: false, reason };
                let dsToken;
                try { dsToken = JSON.parse(dsAuthRes.body)?.token; }
                catch { throw new Error('discordsays returned non-JSON: ' + String(dsAuthRes.body).slice(0, 120)); }
                if (!dsToken) throw new Error('no discordsays token');

                await Tasks._bypassPost(
                    `https://${appId}.discordsays.com/.proxy/acf/quest/progress`,
                    { 'Content-Type': 'application/json', 'X-Auth-Token': dsToken, 'X-Discord-Quest-ID': q.id, 'Referer': referrer },
                    JSON.stringify({ progress: t.target })
                );
                if (!RUNTIME.running) return { ok: false, reason };

                Logger.log(`[Bypass] Success. "${t.name}" completed via Discord Says.`, 'success');
                return { ok: true, reason: null };
            } catch (e) {
                if (!RUNTIME.running) return { ok: false, reason };
                // Discord renderer CSP blocks connect-src to *.discordsays.com. fetch() throws
                // TypeError "Failed to fetch" without a status. There's no userscript workaround
                // for this: the bypass needs a main-process HTTP client (Vencord plugin native module).
                if (e instanceof TypeError && /failed to fetch|networkerror/i.test(e.message)) {
                    Logger.log(`[Bypass] Discord's CSP blocks the script from reaching discordsays.com. Use the Vencord plugin port for the auto-bypass, since a userscript cannot bypass CSP. Skipping "${t.name}".`, 'warn');
                    return { ok: false, reason };
                }
                // Discord's API client rejects with {status, body:{code,message}}, not Error, so stringify properly
                const code = e?.body?.code;
                // 50165 = Cannot launch Age-Gated Activity: age-gated or delisted
                if (code === 50165) {
                    reason = 'the activity is age-gated or delisted, so Discord refuses the proxy ticket on this account';
                    Logger.log(`[Bypass] "${t.name}" can't be launched (age-gated or delisted). Discord blocks the proxy ticket, so there is nothing we can do.`, 'warn');
                    return { ok: false, reason };
                }
                const parts = [];
                if (e?.status) parts.push(`HTTP ${e.status}`);
                if (code) parts.push(`code ${code}`);
                if (e?.body?.message) parts.push(e.body.message);
                else if (e?.message) parts.push(e.message);
                else if (typeof e === 'string') parts.push(e);
                else if (e) { try { parts.push(JSON.stringify(e).slice(0, 200)); } catch { parts.push(String(e)); } }
                reason = `the Discord Says bypass failed (${parts.join(', ') || 'unknown error'})`;
                Logger.log(`[Bypass] Failed: ${parts.join(', ') || 'unknown'}`, 'warn');
                return { ok: false, reason };
            } finally {
                // Revoke ONLY the grant we created, diffed against the pre-flow snapshot.
                // Deliberately not gated on RUNTIME.running: a request sent before STOP may
                // already have created a grant that still needs compensating cleanup.
                if (preGrantIds) {
                    try {
                        const after = await Mods.API.get({ url: '/oauth2/tokens' });
                        const ours = (after?.body || []).filter(tk => tk.application?.id === appId && !preGrantIds.has(tk.id));
                        for (const g of ours) await Mods.API.del({ url: `/oauth2/tokens/${g.id}` });
                    } catch (e) {
                        Logger.log(`[Bypass] Deauthorize cleanup non-fatal: ${e?.message}`, 'debug');
                    }
                }
            }
        },

        // ACHIEVEMENT_IN_ACTIVITY. Target is usually 1 (a milestone, not seconds).
        // 1) heartbeat spoof (works for some quests)
        // 2) discordsays OAuth bypass (silver bullet)
        // 3) skip on failure, with no 25-minute passive wait
        async ACHIEVEMENT(q, t) {
            Logger.updateTask(q.id, { name: t.name, type: "ACHIEVEMENT", cur: 0, max: t.target, status: "RUNNING" });

            // attempt active heartbeat spoofing
            const key = buildStreamKey();

            if (key) {
                Logger.log(`[Task] Attempting heartbeat spoofing for "${t.name}"...`, 'info');
                // Discord's own heartbeat always carries application_id. Sending only
                // stream_key and terminal made every Orion beat structurally different.
                const beat = { stream_key: key, application_id: String(t.appId || ''), terminal: false };
                let cur = 0;
                let failCount = 0;

                while (cur < t.target && RUNTIME.running) {
                    try {
                        const r = await Traffic.enqueue(`/quests/${q.id}/heartbeat`, beat);
                        if (!RUNTIME.running) return;
                        cur = r?.body?.progress?.[t.keyName]?.value ?? r?.body?.progress?.ACHIEVEMENT_IN_ACTIVITY?.value ?? cur;
                        Logger.updateTask(q.id, { name: t.name, type: "ACHIEVEMENT", cur, max: t.target, status: "RUNNING" });
                        failCount = 0;

                        if (cur >= t.target) {
                            try { await Traffic.enqueue(`/quests/${q.id}/heartbeat`, { ...beat, terminal: true }); }
                            catch (_) { }
                            break;
                        }
                    } catch (e) {
                        if (!RUNTIME.running) return;
                        failCount++;
                        const err = ErrorHandler.classify(e);
                        if (err.isClientError) {
                            Logger.log(`[Achievement] Heartbeat rejected (HTTP ${err.status}). Falling back to passive mode.`, 'warn');
                            break;
                        }
                        if (failCount >= SYS.MAX_TASK_FAILURES) {
                            Logger.log(`[Achievement] Too many failures. Falling back to passive mode.`, 'warn');
                            break;
                        }
                    }
                    await sleep(rnd(19000, 22000));
                }

                if (cur >= t.target && RUNTIME.running) return Tasks.finish(q, t);
            }

            // heartbeat path failed or skipped, so try the Discord Says auth bypass
            if (!RUNTIME.running) return;
            const bypass = await Tasks.bypassAchievement(q, t);
            if (!RUNTIME.running) return;
            if (bypass.ok) return Tasks.finish(q, t);

            // both auto-paths failed: skip the quest. no more 25-min passive wait.
            Logger.log(`[Task] Skipping "${t.name}". No auto-completion path worked (heartbeat rejected, bypass blocked). Likely age-gated/delisted on your account.`, 'warn');
            // The bypass records why it gave up, so pass that on rather than a bare
            // "Cannot auto-complete", which left nothing to act on.
            return Tasks.failTask(q, t, bypass.reason ?? 'no auto-completion path worked');
        },

        // heartbeat loop against a voice channel to simulate activity participation
        async ACTIVITY(q, t) {
            const key = buildStreamKey();
            if (!key) {
                return Tasks.failTask(q, t, 'No voice channel found');
            }

            // Discord's own heartbeat always carries application_id. Sending only stream_key
            // and terminal made every Orion beat structurally different from a real one.
            const beat = { stream_key: key, application_id: String(t.appId || ''), terminal: false };
            let cur = 0;
            let failCount = 0;
            let stalledBeats = 0;
            Logger.updateTask(q.id, { name: t.name, type: "ACTIVITY", cur, max: t.target, status: "RUNNING" });

            const startTime = Date.now();

            while (cur < t.target && RUNTIME.running) {
                try {
                    const r = await Traffic.enqueue(`/quests/${q.id}/heartbeat`, beat);
                    const reported = r?.body?.progress?.[t.keyName]?.value ?? r?.body?.progress?.PLAY_ACTIVITY?.value;
                    // Never invent progress. This used to fall back to `cur + 20`, so a server
                    // that credited nothing still walked the counter to target and the quest
                    // was reported complete on the strength of numbers Orion made up. Count
                    // the silent beats and give up instead.
                    if (typeof reported === 'number') { cur = reported; stalledBeats = 0; }
                    else if (++stalledBeats >= SYS.MAX_TASK_FAILURES) return Tasks.failTask(q, t, 'Discord credited no progress');
                    Logger.updateTask(q.id, { name: t.name, type: "ACTIVITY", cur, max: t.target, status: "RUNNING" });
                    failCount = 0;
                    if (cur >= t.target) {
                        try { await Traffic.enqueue(`/quests/${q.id}/heartbeat`, { ...beat, terminal: true }); }
                        catch (e) { Logger.log(`[ACTIVITY] Final heartbeat failed: ${e?.message}`, 'debug'); }
                        break;
                    }
                } catch (e) {
                    failCount++;
                    const err = ErrorHandler.classify(e);
                    if (err.isClientError) {
                        Logger.log(`[Task] Activity quest unavailable (HTTP ${err.status}). Skipping.`, 'warn');
                        return Tasks.failTask(q, t, `Client Error ${err.status}`);
                    }
                    if (failCount >= SYS.MAX_TASK_FAILURES) {
                        return Tasks.failTask(q, t, 'Too many network failures');
                    }
                    Logger.log(`[Task] ACTIVITY heartbeat failed (${failCount}/${SYS.MAX_TASK_FAILURES}): ${err.message}`, 'debug');
                }

                if (Date.now() - startTime > SYS.MAX_TIME) {
                    return Tasks.failTask(q, t, 'Timeout exceeded');
                }
                await sleep(rnd(19000, 22000));
            }
            if (RUNTIME.running && cur >= t.target) Tasks.finish(q, t);
        },

        async finish(q, t) {
            Logger.updateTask(q.id, { name: t.name, type: t.type, cur: t.target, max: t.target, status: "COMPLETED" });
            // The one place the run knows a quest finished because of its own work.
            recordOutcome(Tasks.outcomes, q.id, 'completed');
            Logger.log(`[Task] Completed "${t.name}"!`, 'success');
            Sound.play('tick');

            try {
                if (typeof Notification !== 'undefined') {
                    // ask lazily on the first completion, not on paste (a permission prompt
                    // before the user has even picked a quest is intrusive).
                    if (Notification.permission === 'default') { try { await Notification.requestPermission(); } catch (_) { } }
                    if (Notification.permission === 'granted') {
                        new Notification("Orion: Quest Completed", { body: t.name, icon: "https://cdn.discordapp.com/emojis/1120042457007792168.webp", tag: `orion-${q.id}` });
                    }
                }
            } catch (e) { Logger.log(`[Notification] ${e.message}`, 'debug'); }

            if (RUNTIME.autoClaim) {
                try {
                    await sleep(rnd(2500, 6000));
                    if (!RUNTIME.running) return;
                    // optimistic claim. Try without captcha, show the button if challenged.
                    const claimRes = await this.claimReward(q.id);

                    if (claimRes?.body?.claimed_at) {
                        Logger.log(`[Claim] Reward for "${t.name}" claimed automatically!`, 'success');
                        Logger.updateTask(q.id, { name: t.name, type: t.type, cur: t.target, max: t.target, status: "CLAIMED" });
                        setTimeout(() => Logger.removeTask(q.id), 2000);  // ms before clearing finished tasks
                        return;
                    }
                } catch (e) {
                    // captcha required or other error, so fall through to the claim button
                    const needsCaptcha = e?.body?.captcha_key || e?.body?.captcha_sitekey;
                    if (needsCaptcha) {
                        Logger.log(`[Claim] Captcha required for "${t.name}". Use UI button.`, 'warn');
                    } else {
                        Logger.log(`[Claim] Auto-claim failed for "${t.name}": ${e?.body?.message ?? e?.message}`, 'err');
                    }
                }
            }

            // show claim button instead of auto-removing
            Logger.updateTask(q.id, { name: t.name, type: t.type, cur: t.target, max: t.target, status: "COMPLETED", claimable: true, questId: q.id });
        }
    };

    /* ── webpack module extraction ───────────────────────────────
       Uses getName() as a stable discriminator for Flux stores.
       Real stores return their name (e.g. "QuestStore"), fakes
       return "[object Object]". No hardcoded property paths needed.
       Dispatcher and API use structural checks instead.
    ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── */

    /* Adapter over Discord's protobuf user settings, used to flip status.showCurrentGame
       for HIDE_ACTIVITY. Deliberately not going through Vencord's UserSettingsAPI: that
       one throws unless the *calling plugin* declared it as a dependency, and a console
       script has no plugin manifest. The proto action creators are identified by
       ProtoClass.typeName (Vencord's own discriminator) or the updateAsync+type===1 pair
       (BetterDiscord's), and the BoolValue wrapper by its proto typeName. The setting
       fields are wrapper messages, not raw booleans. */
    function findShowCurrentGameSetting(moduleCache) {
        if (!moduleCache) return undefined;

        let actions, delay, BoolValue;
        for (const m of Object.values(moduleCache)) {
            try {
                const exp = m?.exports;
                if (!exp || typeof exp !== 'object') continue;

                for (const key of Object.keys(exp)) {
                    const p = exp[key];
                    if (!p) continue;

                    // Match by proto typeName over any export, not by an export named BoolValue:
                    // the key is minified on some builds, and then a name-only lookup misses and
                    // the write below throws. This is BetterDiscord's discriminator.
                    if (!BoolValue && typeof p.create === 'function'
                        && String(p.typeName ?? '').includes('Bool')) BoolValue = p;

                    if (!actions && typeof p.updateAsync === 'function'
                        && (p.ProtoClass?.typeName?.endsWith('.PreloadedUserSettings') || p.type === 1)) {
                        actions = p;
                        // enum member name varies; 0 is the "act now" delay every build accepts
                        delay = exp.UserSettingsDelay?.INFREQUENT_USER_ACTION ?? 0;
                    }
                }
                if (actions && BoolValue) break;  // module cache is large, so stop once both are in hand
            } catch { }
        }
        if (!actions) return undefined;
        if (!BoolValue) Logger.log('[Patcher] BoolValue proto type not found. Hiding activity will only work if the setting already exists.', 'debug');

        return {
            // undefined when the shape moved. Callers treat that as "unknown, assume on".
            getSetting: () => actions.getCurrentValue?.()?.status?.showCurrentGame?.value,
            updateSetting: value => actions.updateAsync('status', settings => {
                if (settings.showCurrentGame && typeof settings.showCurrentGame.value === 'boolean') {
                    settings.showCurrentGame.value = value;
                } else if (BoolValue) {
                    settings.showCurrentGame = BoolValue.create({ value });
                } else {
                    throw new Error('BoolValue proto type not found');
                }
            }, delay)
        };
    }

    function loadModules() {
        try {
            // === VENCORD USAGE ===
            if (typeof window.Vencord !== 'undefined' && window.Vencord.Webpack) {
                Logger.log('[System] Vencord detected. Using Vencord Webpack API...', 'info');
                const W = window.Vencord.Webpack;

                let routerModule;
                try {
                    const m = W.findByCode('transitionTo -');
                    if (m) {
                        for (const prop of [m, m.default, ...Object.values(m)]) {
                            if (typeof prop === 'function' && prop.toString().includes('transitionTo -')) {
                                routerModule = { transitionTo: prop };
                                break;
                            }
                        }
                    }
                } catch (e) { }

                Mods = {
                    QuestStore: W.findStore('QuestStore') || W.findStore('QuestsStore'),
                    RunStore: W.findStore('RunningGameStore'),
                    StreamStore: W.findStore('ApplicationStreamingStore'),
                    ChanStore: W.findStore('ChannelStore'),
                    GuildChanStore: W.findStore('GuildChannelStore'),
                    UserStore: W.findStore('UserStore'),
                    OrbStore: W.findStore('VirtualCurrencyStore'),
                    Premium: W.findByProps('canUseMoreQuestOrbs', 'canUseShopDiscounts'),
                    Dispatcher: W.Common?.FluxDispatcher || W.findByProps('dispatch', 'subscribe', 'flushWaitQueue'),
                    API: W.Common?.RestAPI || W.findByProps('get', 'post', 'del'),
                    Router: routerModule,
                    // Vencord exposes the raw webpack require, so the same cache scan works here
                    ShowCurrentGame: findShowCurrentGameSetting(W.cache || W.wreq?.c)
                };

                const required = ['QuestStore', 'API', 'Dispatcher', 'RunStore'];
                const missing = required.filter(k => !Mods[k]);

                if (missing.length === 0) {
                    const optional =['StreamStore', 'ChanStore', 'GuildChanStore', 'Router'];
                    optional.forEach(k => { if (!Mods[k]) Logger.log(`[System] Optional module '${k}' not found. Features may be limited.`, 'warn'); });

                    Patcher.init(Mods.RunStore);
                    return true;
                }
                Logger.log(`[System] Vencord extraction missed: ${missing.join(', ')}. Falling back to native...`, 'warn');
            }

            // === NATIVE FALLBACK (Canary / PTB without mods) ===
            if (typeof webpackChunkdiscord_app === 'undefined') {
                throw new Error("Webpack chunk not found - is this running inside Discord?");
            }

            // The push callback fires once per registered webpack runtime. Discord ships
            // Sentry's stripped runtime alongside the real one, and Sentry's `req.c` is tiny.
            // Pick the require with the largest cache so we ignore the Sentry instance.
            let req;
            webpackChunkdiscord_app.push([[Symbol()], {}, (r) => {
                const cur = Object.keys(req?.c || {}).length;
                const incoming = Object.keys(r?.c || {}).length;
                if (incoming > cur) req = r;
            }]);
            webpackChunkdiscord_app.pop();

            if (!req?.c) throw new Error("Module registry not available - Discord build incompatible (see issue #20)");

            const modules = Object.values(req.c);

            // real Flux stores have constructor.displayName set to their class name
            // fakes have displayName "Object". This check never triggers Proxy traps.
            function findStore(storeName) {
                for (const m of modules) {
                    try {
                        const exp = m?.exports;
                        if (!exp || typeof exp !== 'object') continue;
                        for (const key of Object.keys(exp)) {
                            const prop = exp[key];
                            if (prop && typeof prop === 'object'
                                && prop.__proto__?.constructor?.displayName === storeName) {
                                return prop;
                            }
                        }
                    } catch { }
                }
                return undefined;
            }

            // Dispatcher has _subscriptions + subscribe on proto, no valid getName
            function findDispatcher() {
                for (const m of modules) {
                    try {
                        const exp = m?.exports;
                        if (!exp || typeof exp !== 'object') continue;
                        for (const key of Object.keys(exp)) {
                            const prop = exp[key];
                            if (prop && prop._subscriptions
                                && typeof prop.subscribe === 'function'
                                && typeof prop.dispatch === 'function'
                                && typeof prop.__proto__?.flushWaitQueue === 'function') {
                                return prop;
                            }
                        }
                    } catch { }
                }
                return undefined;
            }

            // Discord's API client has .del (not .delete), which distinguishes it
            // from generic HTTP wrappers. Also has get/post/put/patch as own props.
            function findAPI() {
                for (const m of modules) {
                    try {
                        const exp = m?.exports;
                        if (!exp || typeof exp !== 'object') continue;
                        for (const key of Object.keys(exp)) {
                            const prop = exp[key];
                            if (prop && typeof prop.get === 'function'
                                && typeof prop.post === 'function'
                                && typeof prop.del === 'function'
                                && !prop._dispatcher) {
                                return prop;
                            }
                        }
                    } catch { }
                }
                return undefined;
            }

            // Navigation functions are exported standalone and minified.
            // transitionTo is identified by searching its source code for the "transitionTo -" signature.
            function findRouter() {
                for (const m of modules) {
                    try {
                        const exp = m?.exports;
                        if (!exp) continue;

                        for (const prop of [exp, exp.default, ...Object.values(exp)]) {
                            if (typeof prop === 'function' && prop.toString().includes('transitionTo -')) {
                                return { transitionTo: prop };
                            }
                        }
                    } catch { }
                }
                return undefined;
            }

            // Discord's premium feature checks sit on one plain object of named functions.
            // Two of them together are specific enough; nothing else exports both.
            function findPremiumFeatures() {
                for (const m of modules) {
                    try {
                        const exp = m?.exports;
                        if (!exp || typeof exp !== 'object') continue;
                        for (const prop of [exp, ...Object.values(exp)]) {
                            if (prop && typeof prop.canUseMoreQuestOrbs === 'function'
                                && typeof prop.canUseShopDiscounts === 'function') {
                                return prop;
                            }
                        }
                    } catch { }
                }
                return undefined;
            }

            Mods = {
                QuestStore: findStore('QuestStore'),
                RunStore: findStore('RunningGameStore'),
                StreamStore: findStore('ApplicationStreamingStore'),
                ChanStore: findStore('ChannelStore'),
                GuildChanStore: findStore('GuildChannelStore'),
                UserStore: findStore('UserStore'),
                OrbStore: findStore('VirtualCurrencyStore'),
                Premium: findPremiumFeatures(),
                Dispatcher: findDispatcher(),
                API: findAPI(),
                Router: findRouter(),
                ShowCurrentGame: findShowCurrentGameSetting(req.c)
            };

            const required = ['QuestStore', 'API', 'Dispatcher', 'RunStore'];
            const missing = required.filter(k => !Mods[k]);
            if (missing.length > 0) throw new Error(`Core modules not found: ${missing.join(', ')}`);

            const optional = ['StreamStore', 'ChanStore', 'GuildChanStore', 'Router'];
            optional.forEach(k => { if (!Mods[k]) Logger.log(`[System] Optional module '${k}' not found. Features may be limited.`, 'warn'); });
            Patcher.init(Mods.RunStore);
            return true;
        } catch (e) {
            Logger.log(`[System] Module loading error: ${e.message ?? e}`, 'err');
            console.error(e);
            return false;
        }
    }

    /* ── main loop ─────────────────────────────────────────────── */

    // run async tasks concurrently up to a specified limit
    async function runConcurrent(tasks, limit) {
        const executing = new Set();

        for (const task of tasks) {
            if (!RUNTIME.running) break;

            const p = task().finally(() => executing.delete(p));
            executing.add(p);

            await sleep(rnd(1500, 4000)); // stagger initialization to avoid API bursts

            if (executing.size >= limit) {
                await Promise.race(executing);
            }
        }

        // use allSettled to prevent a single rejection from crashing the batch
        return Promise.allSettled(executing);
    }

    async function main() {
        Logger.init();
        if (!loadModules()) return Logger.log('[System] Failed to load Discord modules. Aborting.', 'err');

        // show quest picker and wait for user selection
        const getQuests = () => {
            const q = Mods.QuestStore.quests;
            return q instanceof Map ? [...q.values()] : Object.values(q);
        };

        // Discord fetches its quest list a few seconds after the client boots, so a paste into a
        // console that was opened immediately can land on an empty store. Wait for it rather than
        // reporting "all completed" for a list that has not arrived yet (issue #74).
        if (!getQuests().length) {
            Logger.log('[System] Waiting for Discord to send its quest list...', 'info');
            for (let waited = 0; waited < 15000 && !getQuests().length && RUNTIME.running; waited += 250) {
                await new Promise(r => setTimeout(r, 250));
            }
        }
        if (!RUNTIME.running) return;

        const allQuests = getQuests();
        let quests = allQuests.filter(q =>
            !q.userStatus?.completedAt
            && notExpired(q)
            && q.id !== CONST.ID
            && !Tasks.skipped.has(q.id)
        );

        if (!quests.length) {
            // An empty store and a store full of finished quests read identically to the user
            // otherwise, and only one of them means "come back later".
            if (!allQuests.length) {
                Logger.log('[System] Discord has not given this client a quest list. Reload Discord (Ctrl+R) and paste again.', 'err');
            } else {
                Logger.log('[System] All available quests are completed!', 'success');
            }
            return Logger.shutdown();
        }

        const pickerResult = await Logger.showQuestPicker(quests);
        if (!RUNTIME.running) return;

        // Propagate UI options to global runtime state
        RUNTIME.autoEnroll = pickerResult.autoEnroll;
        RUNTIME.autoClaim = pickerResult.autoClaim;
        RUNTIME.playSound = pickerResult.playSound;
        RUNTIME.randomDelay = pickerResult.randomDelay;

        if (pickerResult.selectedQuests.size === 0) {
            Logger.log('[System] No quests selected. Shutting down.', 'info');
            return Logger.shutdown();
        }

        let loopCount = 1;

        while (RUNTIME.running) {
            try {
                Logger.log(`[Cycle] Starting loop #${loopCount}...`, 'info');

                // Discord tells the client when the account may not enroll in anything, and
                // the quest list carries the timestamp. Checked every cycle rather than once
                // at start, because the block can land mid-run, and running past it means
                // hammering an endpoint that is already refusing us.
                const suspendedUntil = questAccessSuspendedUntil();
                if (suspendedUntil) {
                    const when = suspendedUntil.getTime() === 0 ? 'for now' : `until ${suspendedUntil.toLocaleString()}`;
                    Logger.log(`[System] Discord has suspended quest access on this account ${when}. Its own client refuses to start a quest in this state, so Orion stops too.`, 'err');
                    break;
                }

                const blockedUntil = enrollmentBlockedUntil();
                if (blockedUntil) {
                    Logger.log(`[System] Discord has blocked quest enrollment on this account until ${blockedUntil.toLocaleString()}. Stopping instead of retrying.`, 'err');
                    break;
                }

                quests = getQuests();

                // Filter out completed, expired, blacklisted, skipped, AND unselected quests
                const active = quests.filter(q =>
                    pickerResult.selectedQuests.has(q.id)
                    && !q.userStatus?.completedAt
                    && notExpired(q)
                    && q.id !== CONST.ID
                    && !Tasks.skipped.has(q.id)
                );

                if (!active.length) {
                    // Saying "all completed" here is how a quest this client skipped, or one that
                    // died in a handler, used to read as a finished one. The counts come from what
                    // the run recorded as it went, not from which quests gained completedAt while
                    // it was alive, which cannot tell this script's work from the user's.
                    const summary = summarizeRun(Tasks.outcomes);
                    Logger.log(`[System] ${summary.line}`, summary.blocked || summary.failed ? 'warn' : 'success');
                    if (summary.playDone) Sound.play('done');
                    break;
                }

                const queues = { video: [], game: [] };

                active.forEach(q => {
                    try {
                        const cfg = selectTaskConfig(q.config);
                        const questName = q.config?.messages?.questName ?? q.id;
                        const hasTaskConfig = !!cfg?.tasks && typeof cfg.tasks === 'object';
                        const typeData = hasTaskConfig ? Tasks.detectType(cfg, q.config?.application?.id) : null;
                        const blocker = questBlocker({
                            name: questName,
                            hasTaskConfig,
                            keys: hasTaskConfig ? taskKeys(cfg.tasks) : [],
                            typeData,
                            isDesktop: SYS.IS_DESKTOP
                        });
                        if (blocker) {
                            Logger.log(`[Quest] ${blocker} Skipping it for the rest of this run.`, 'warn');
                            // Marking the quest skipped is the point: the active filter reads this
                            // set, and without it the same quest comes back every cycle (issue #78).
                            Tasks.skipped.add(q.id);
                            recordOutcome(Tasks.outcomes, q.id, 'blocked');
                            return;
                        }

                        const { type, keyName, target, appId } = typeData;

                        const tInfo = {
                            id: q.id,
                            appId: appId ?? 0,
                            name: q.config?.messages?.questName ?? "Unknown Quest",
                            target,
                            type,
                            keyName  // actual task key from config (e.g. WATCH_VIDEO_ON_MOBILE)
                        };

                        // handle disabled auto-enroll (wait for user)
                        if (!q.userStatus?.enrolledAt && !RUNTIME.autoEnroll) {
                            Logger.updateTask(tInfo.id, {
                                name: tInfo.name, type: tInfo.type, cur: 0, max: tInfo.target,
                                status: "PENDING", actionRequired: 'ENROLL'
                            });
                            return; // skip execution queue, wait for next cycle
                        }

                        if (Logger.tasks.has(q.id) && Logger.tasks.get(q.id).status === "RUNNING") return;

                        // clear the action button if user enrolled manually
                        Logger.updateTask(tInfo.id, {
                            name: tInfo.name, type: tInfo.type, cur: 0, max: tInfo.target,
                            status: "QUEUE", actionRequired: null
                        });

                        const taskFunc = async () => {
                            // JIT enrollment (only if autoEnroll is true or user already enrolled)
                            if (!q.userStatus?.enrolledAt) {
                                Logger.log(`[Enroll] Accepting quest: ${tInfo.name}`, 'info');
                                try {
                                    await Traffic.enqueue(`/quests/${q.id}/enroll`, {
                                        location: 11,
                                        is_targeted: false,
                                        metadata_sealed: null,
                                        traffic_metadata_sealed: sealedFor(q.id)
                                    });
                                    await sleep(rnd(800, 1500));
                                } catch (e) {
                                    const err = ErrorHandler.classify(e);
                                    if (ErrorHandler.isSkippableQuest(e)) {
                                        Tasks.skipped.add(q.id);
                                        Logger.log(`[Enroll] ${tInfo.name} unavailable (${err.status}). Skipping.`, 'warn');
                                    } else {
                                        Logger.log(`[Enroll] Failed for ${tInfo.name}: ${err.message}`, 'err');
                                    }
                                    return Tasks.failTask(q, tInfo, `Enrollment failed`);
                                }
                            }

                            if (type === "WATCH_VIDEO") return Tasks.VIDEO(q, tInfo, q.userStatus);
                            if (type === "ACHIEVEMENT") return Tasks.ACHIEVEMENT(q, tInfo);
                            const runner = type === "STREAM" ? Tasks.STREAM : (type === "ACTIVITY" ? Tasks.ACTIVITY : Tasks.GAME);
                            return runner(q, tInfo, q.userStatus);
                        };

                        if (type === "WATCH_VIDEO") queues.video.push(taskFunc);
                        else queues.game.push(taskFunc);
                    } catch (e) {
                        Logger.log(`[Quest] Error processing ${q.id}: ${e.message}`, 'err');
                    }
                });

                const totalTasks = queues.video.length + queues.game.length;

                if (totalTasks > 0) {
                    Logger.log(`[Cycle] Processing: ${queues.video.length} videos, ${queues.game.length} games.`, 'info');
                    const pGames = runConcurrent(queues.game, 1);
                    const pVideos = runConcurrent(queues.video, 2);
                    await Promise.all([pGames, pVideos]);
                } else {
                    if (active.length === 0) { Logger.log('[System] All available quests are completed!', 'success'); break; }
                    else await sleep(rnd(4000, 6000));  // idle loop wait
                }

                if (!RUNTIME.running) break;

                // anti-detection: opt-in randomized idle gap between cycles. each cycle
                // typically completes 1+ quests, so this throttles overall quest velocity.
                if (RUNTIME.randomDelay) {
                    const delayMs = rnd(60000, 1800000); // 1-30 minutes
                    Logger.log(`[Cycle] Loop #${loopCount} complete. Random delay: ${Math.round(delayMs / 60000)}m before rescan.`, 'info');
                    await sleep(delayMs);
                } else {
                    Logger.log(`[Cycle] Loop #${loopCount} complete. Waiting before rescan...`, 'info');
                    await sleep(rnd(2500, 4500));
                }
                loopCount++;

            } catch (cycleError) {
                Logger.log(`[Cycle] Error in loop #${loopCount}: ${cycleError?.message ?? cycleError}`, 'err');
                console.error(cycleError);
                await sleep(3000);
                loopCount++;
            }
        }

        // Keep the UI alive if there are unclaimed rewards waiting. Losing the
        // dashboard the moment the queue finishes means users miss the CLAIM
        // buttons (especially when auto-claim is off). User can click STOP manually.
        const hasUnclaimed = [...Logger.tasks.values()].some(t => t.claimable && !t.removing);
        if (hasUnclaimed) {
            Logger.log('[System] Quest cycle finished. Claim your rewards above, then click STOP.', 'info');
            return; // skip shutdown; the STOP button still works
        }

        Logger.shutdown();
    }

    main().catch(e => {
        const msg = e?.message ?? e?.toString?.() ?? "Unknown fatal error";
        console.error('[Orion Fatal]', e);
        try { Logger.log(`[System] FATAL: ${msg}`, 'err'); } catch (_) { }
        Logger.shutdown();

        // Failsafe: release lock unconditionally so user can retry without reloading tab
        setTimeout(() => { window.orionLock = false; }, 1500);
    });
})();