DeepCo Frogger

Dept-focused logging and enhanced Frogview

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         DeepCo Frogger
// @version      2026-04-12
// @namespace    frogger
// @description  Dept-focused logging and enhanced Frogview
// @author       M3P / ChatGPT
// @license      MIT
// @match        https://deepco.app/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=deepco.app
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(() => {
    'use strict';
    const getVar = (k, d, description = '') => {
        const o = GM_getValue('aaa_user_variables', {});

        if (!(k in o)) {
            o[k] = { value: d, description };
            GM_setValue('aaa_user_variables', o);
            return d;
        }

        // Backwards compatibility: convert raw values to object form
        if (typeof o[k] !== 'object' || o[k] === null || !('value' in o[k])) {
            o[k] = { value: o[k], description };
            GM_setValue('aaa_user_variables', o);
            return o[k].value;
        }

        // Update description if missing (but don't overwrite existing)
        if (description && !o[k].description) {
            o[k].description = description;
            GM_setValue('aaa_user_variables', o);
        }

        return o[k].value;
    };

    // ---------- User adjustable config ----------
    // After first run, go to "Storage" and search for "aaa", tweak as needed.
    // -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
    const DATA_KEY = 'frogger_data_v4';
    const DEBUG = getVar('DEBUG',false,"You probably don't want this true.");
    const TICK_MS = getVar('TICK_MS',250,'How quickly it checks for data');
    const MAX_HISTORY = getVar('MAX_HISTORY',5000,'How many records it holds');
    const HOLD_MS = getVar('HOLD_MS',1500,'How long to punch the frog to spin it');
    const FROGOVER_MS = getVar('FROGOVER_MS',500,'How long the Frogover stays visible');
    const K_SQUISH = getVar('K_SQUISH',true,"Don't like big numbers?");
    const SUMMARY_WINDOWS = getVar('SUMMARY_WINDOWS',[1, 5, 10, 20, 50, 100],'Rolling window sizes');
    const SPIN_TIMEOUT_MS = getVar('SPIN_TIMEOUT_MS',30000,'How many ms to wait for a second frog spin before resetting the counter');

    const TILE_LABELS = {
        1: 'Mega',
        16: 'Dense',
        64: 'Corrupted',
        100: 'Normal',
    };

    const BASE_NUM_FIELDS = [
        'pending_rc',
        'balance_dc',
        'balance_rc',
        'accumulated_dc',
        'damage_rating',
        'crit_chance',
        'chain_chance',
        'min_damage',
        'max_damage',
        'dig_cooldown',
        'pr_min',
        'pr_max',
        'tiles_defeated',
        'operators',
        'difficulty',
        'rewards',
        'async_doom_standing',
        'async_doom_bonus',
        'async_rc_potential_per_sec',
        'async_dc_per_sec',
        'rc_min_processing_bonus',
        'rc_max_processing_bonus',
        'rc_total_processing_bonus',
        'rc_potential_limit',
        'rc_deepcoin_yield',
        'cluster_tiles',
        'cluster_dc',
    ];

    const DEPT_NUM_FIELDS = [
        'pending_rc_start',
        'pending_rc_end',
        'pending_rc_delta',
        'tiles_start',
        'tiles_end',
        'tiles_defeated_in_dept',
        'dc_start',
        'dc_end',
        'dc_earned',
        'duration_ms',
        'duration_hr',
        'rc_delta',
        'rc_per_block',
        'dc_per_block',
        'rc_per_hr',
        'dc_per_hr',
    ];

    const NUM_FIELDS = [...BASE_NUM_FIELDS, ...DEPT_NUM_FIELDS];

    const STR_FIELDS = [
        'entry_type',
        'row_reason',
        'department_label',
        'department_id',
        'async_lastupdated',
        'rc_lastupdated',
        'layer_complete_text',
        'selected_department',
        'dept_start_time',
        'dept_end_time',
    ];

    const WATCH_FIELDS = [
        'async_doom_standing',
        'async_doom_bonus',
        'async_rc_potential_per_sec',
        'async_dc_per_sec',
        'rc_min_processing_bonus',
        'rc_max_processing_bonus',
        'rc_total_processing_bonus',
        'rc_potential_limit',
        'rc_deepcoin_yield',
        'chain_chance',
        'crit_chance',
        'damage_rating',
        'department_id',
        'department_label',
        'dig_cooldown',
        'max_damage',
        'min_damage',
        'operators',
        'balance_rc',
    ];



    // ---------- runtime ----------
    let data = null;
    let state = null;
    let clusterDcAccumulator = 0;

    // ---------- helpers ----------
    const localeParts = new Intl.NumberFormat().formatToParts(12345.6);
    const GROUP = localeParts.find(p => p.type === "group")?.value || ",";
    const DECIMAL = localeParts.find(p => p.type === "decimal")?.value || ".";

    const $ = (s) => document.querySelector(s);
    const clone = (o) => ({ ...o });
    const nowIso = () => new Date().toISOString();

    const ts = () => {
        const d = new Date();
        return d.getFullYear() + '-' +
            String(d.getMonth() + 1).padStart(2, '0') + '-' +
            String(d.getDate()).padStart(2, '0') + ' ' +
            String(d.getHours()).padStart(2, '0') + ':' +
            String(d.getMinutes()).padStart(2, '0') + ':' +
            String(d.getSeconds()).padStart(2, '0');
    };

    const normalizeNumberString = (s, GROUP, DECIMAL) => {
        return s
            .replace(/\u00A0|\u202F/g, " ") // normalize unicode spaces
            .replace(new RegExp(`\\${GROUP}`, "g"), "") // remove thousands sep
            .replace(new RegExp(`\\${DECIMAL}`), "."); // normalize decimal
    };

    const escapeHtml = (s) => String(s)
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');

    const csvEscape = (v) => {
        if (v == null) return '';
        const s = String(v);
        return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
    };

    const extractNumber = (t) => {
        if (!t) return null;

        const s = t.trim();
        if (!s) return null;

        // grab numeric-looking token (allow unicode spaces)
        const m = s.match(/[-+]?\d[\d\s\u00A0\u202F.,]*/);
        if (!m) return null;

        let numStr = m[0];

        // normalize unicode spaces
        numStr = numStr.replace(/[\u00A0\u202F]/g, ' ');

        // detect decimal separator: last , or . followed by 1-2 digits
        const decMatch = numStr.match(/[.,](\d{1,2})\s*$/);

        if (decMatch) {
            // cents-style: remove all non-digits and divide
            const digits = numStr.replace(/[^\d]/g, '');
            const n = parseFloat(digits);
            return isNaN(n) ? null : n / Math.pow(10, decMatch[1].length);
        }

        // integer case: keep digits + leading minus
        const cleaned = numStr.replace(/(?!^-)[^\d]/g, '');
        const n = parseFloat(cleaned);

        return isNaN(n) ? null : n;
    };

    const extractPercent = (t) => {
        const n = extractNumber(t);
        return n == null ? null : n / 100;
    };

    const isMeaningful = (v) => {
        if (typeof v === 'number') return Number.isFinite(v) && v !== 0;
        if (typeof v === 'string') return v.trim() !== '';
        return v != null;
    };

    const getText = (sels) => {
        for (const s of sels) {
            const el = $(s);

            if (DEBUG) {
                const val = el?.innerHTML ?? null;
                if (logThrottle('gettext+'+s, val)) {
                    console.log(`s- ${'gettext+'+s}   el- ${val ?? 'NULL'}`);
                }
            }

            if (el) return el.innerHTML.trim();
        }
        return '';
    };

    const formatNum = (v, digits = 2) => {
        if (v == null || v === '' || !Number.isFinite(Number(v))) return '';
        return Number(v).toLocaleString(undefined, {
            minimumFractionDigits: digits,
            maximumFractionDigits: digits,
        });
    };

    function metricRow(obj) {
        return {
            rc_per_hr: Number(obj.rc_per_hr ?? 0),
            dc_per_hr: Number(obj.dc_per_hr ?? 0),
            rc_per_block: Number(obj.rc_per_block ?? 0),
            dc_per_block: Number(obj.dc_per_block ?? 0),
            tiles_defeated_in_dept: Number(obj.tiles_defeated_in_dept ?? 0),
            duration_hr: Number(obj.duration_hr ?? 0),
        };
    }

    function defaultState() {
        const o = {};
        NUM_FIELDS.forEach((k) => (o[k] = 0));
        STR_FIELDS.forEach((k) => (o[k] = ''));
        return o;
    }

    function defaultData() {
        return {
            live: defaultState(),
            active: null,
            history: [],
            spin_stage: 0,
        };
    }

    function normalizeEntry(entry) {
        if (!entry) return null;

        if (entry.snapshot && typeof entry.snapshot === 'object') {
            const snap = { ...entry.snapshot };
            if (!snap.entry_type) snap.entry_type = 'dept';
            return {
                timestamp: entry.timestamp || snap.dept_end_time || nowIso(),
                snapshot: snap,
            };
        }

        if (typeof entry === 'object') {
            const snap = { ...entry };
            if (!snap.entry_type) snap.entry_type = 'dept';
            return {
                timestamp: entry.timestamp || snap.dept_end_time || nowIso(),
                snapshot: snap,
            };
        }

        return null;
    }

    function dedupeHistory(entries) {
        const seen = new Set();
        const out = [];

        for (const e of entries) {
            const n = normalizeEntry(e);
            if (!n || !n.snapshot) continue;

            const s = n.snapshot;
            const key = [
                n.timestamp || '',
                s.department_id || '',
                s.dept_start_time || '',
                s.dept_end_time || '',
                s.entry_type || '',
                s.row_reason || '',
            ].join('|');

            if (seen.has(key)) continue;
            seen.add(key);
            out.push(n);
        }

        return out.slice(-MAX_HISTORY);
    }

    function loadData() {
        const raw = GM_getValue(DATA_KEY, null);
        if (raw && typeof raw === 'object' && (raw.live || raw.history || raw.active || raw.spin_stage != null)) {
            const out = defaultData();
            out.live = raw.live && typeof raw.live === 'object' ? { ...defaultState(), ...raw.live } : defaultState();
            out.active = raw.active && typeof raw.active === 'object' ? { ...raw.active } : null;
            out.history = Array.isArray(raw.history) ? dedupeHistory(raw.history) : [];
            out.spin_stage = Number.isFinite(raw.spin_stage) ? raw.spin_stage : 0;
            return out;
        }

        const out = defaultData();
        GM_setValue(DATA_KEY, out);
        return out;
    }

    function saveData() {
        if (data) GM_setValue(DATA_KEY, data);
    }

    function clearAllData() {
        data = defaultData();
        state = data.live;
        clusterDcAccumulator = 0;
        saveData();
    }

    function loadSession() {
        return data && data.active && data.active.dept_id ? data.active : null;
    }

    function saveSession(s) {
        data.active = s && typeof s === 'object' ? { ...s } : null;
        saveData();
    }

    function clearSession() {
        data.active = null;
        saveData();
    }

    function addHistoryEntry(entry) {
        const n = normalizeEntry(entry);
        if (!n) return;
        data.history.push(n);
        if (data.history.length > MAX_HISTORY) {
            data.history = data.history.slice(-MAX_HISTORY);
        }
        saveData();
    }

    function exportCSV(currentState, historyEntries, name) {
        if (!name) {
            const now = new Date();
            name = `Frogger_${now.toISOString().slice(2, 19).replace(/[-T:]/g, '')}.csv`;
        }

        const header = ['type', 'timestamp', ...NUM_FIELDS, ...STR_FIELDS];
        const rows = [header];

        const push = (type, timestamp, s) => {
            rows.push([
                type,
                timestamp,
                ...NUM_FIELDS.map((k) => s[k] ?? ''),
                ...STR_FIELDS.map((k) => s[k] ?? ''),
            ]);
        };

        push('current', nowIso(), currentState);
        historyEntries.forEach((e) => push('history', e.timestamp, e.snapshot));

        const csv = rows.map((r) => r.map(csvEscape).join('|')).join('\n');
        const blob = new Blob([csv], { type: 'text/csv' });
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = name;
        a.click();
    }

    function collectPairs(root, mode) {
        if (!root) return [];
        const out = [];

        if (mode === 'pairs') {
            const n = root.querySelectorAll('.grid > div');
            for (let i = 0; i < n.length; i += 2) {
                const a = n[i]?.innerText?.trim();
                const b = n[i + 1]?.innerText?.trim();
                if (a && b) out.push([a, b]);
            }
        }

        if (mode === 'cards') {
            root.querySelectorAll('article').forEach((c) => {
                const p = c.querySelectorAll('p');
                const a = p[0]?.innerText?.trim();
                const b = p[1]?.innerText?.trim();
                if (a && b) out.push([a, b]);
            });
        }

        if (mode === 'siblings') {
            root.querySelectorAll('span:first-child').forEach((el) => {
                const a = el.innerText.trim();
                const b = el.nextElementSibling?.innerText?.trim();
                if (a && b) out.push([a, b]);
            });
        }

        return out;
    }

    function extractSelectedDepartment() {
        const selectEl = document.querySelector('#grid-shadow-departments select');
        if (!selectEl) return '';
        const option = selectEl.selectedOptions[0];
        if (!option) return '';
        const match = option.textContent.match(/dc\+?\d+\w*/i);
        return match ? match[0] : '';
    }

    function extractSelectedDepartmentNumber() {
        const deptStr = extractSelectedDepartment();
        if (!deptStr) return null;
        const match = deptStr.match(/\d+/);
        return match ? parseInt(match[0], 10) : null;
    }

    function extractLabeled() {
        const RULES = {
            'Operators online': (v) => ({ operators: parseInt(v, 10) }),
            Difficulty: (v) => ({ difficulty: extractPercent(v) }),
            Rewards: (v) => ({ rewards: extractPercent(v) }),

            'D.O.O.M STANDING': (v) => {
                const m = v.match(/#(\d+).*?([\d.]+)%/);
                if (!m) return {};
                return {
                    async_doom_standing: +m[1],
                    async_doom_bonus: +m[2] / 100,
                    async_lastupdated: ts(),
                };
            },

            'MIN PROCESSING BONUS': (v) => ({ rc_min_processing_bonus: extractPercent(v), rc_lastupdated: ts() }),
            'MAX PROCESSING BONUS': (v) => ({ rc_max_processing_bonus: extractPercent(v), rc_lastupdated: ts() }),
            'TOTAL PROCESSING BONUS': (v) => ({ rc_total_processing_bonus: extractPercent(v), rc_lastupdated: ts() }),
            'RC POTENTIAL LIMIT': (v) => ({ rc_potential_limit: parseInt(v.replace(/\D/g, ''), 10), rc_lastupdated: ts() }),
            'DEEPCOIN YIELD': (v) => ({ rc_deepcoin_yield: extractPercent(v), rc_lastupdated: ts() }),

            'Min Processing Bonus': (v) => ({ rc_min_processing_bonus: extractPercent(v) }),
            'Max Processing Bonus': (v) => ({ rc_max_processing_bonus: extractPercent(v) }),
            'Total Processing Bonus': (v) => ({ rc_total_processing_bonus: extractPercent(v) }),
            'RC Potential Limit': (v) => ({ rc_potential_limit: parseInt(v.replace(/\D/g, ''), 10) }),
            'DeepCoin Yield': (v) => ({ rc_deepcoin_yield: extractPercent(v) }),
        };

        const pairs = [
            ...collectPairs($('[id^="co_op_scaling_badge"]'), 'pairs'),
            ...collectPairs($('.space-y-2'), 'cards'),
            ...collectPairs($('[id="recursive-upgrades"]'), 'cards'),
            ...collectPairs(document, 'siblings'),
        ];

        const out = {};
        for (const [k, v] of pairs) {
            const fn = RULES[k];
            if (!fn) continue;
            Object.assign(out, fn(v) || {});
        }

        const txt = getText([
            '[data-stat="department-efficiency-label"]',
            '[data-role="department-efficiency-label"]',
        ]);

        if (txt) {
            const m = txt.match(/^(.*?)\s*(#\d+)$/);
            if (m) {
                out.department_label = m[1].trim();
                out.department_id = m[2];
            }
        }

        return out;
    }

    function countTileWrappers() {
        return document.querySelectorAll('div[id^="tile_wrapper_"]').length;
    }


    const logThrottle = (() => {
        const store = new Map(); // key -> { value, time }

        return function(name, value, intervalMs = 60000) {
            const now = Date.now();
            const prev = store.get(name);

            const changed = !prev || prev.value !== value;
            const expired = !prev || (now - prev.time) >= intervalMs;

            if (changed || expired) {
                store.set(name, { value, time: now });
                return true; // allow logging
            }
            return false; // suppress
        };
    })();

    function extractStats() {
        const out = {};
        const schema = {
            pending_rc: 'rc-potential',
            balance_dc: 'dc-balance',
            balance_rc: 'rc-balance',
            damage_rating: 'damage_rating',
            crit_chance: 'crit_chance',
            chain_chance: 'chain_chance',
            min_damage: 'min_damage',
            max_damage: 'max_damage',
            dig_cooldown: 'dig_cooldown',
            pr_min: 'pr_min',
            pr_max: 'pr_max',
            async_dc_per_sec: 'gainDisplay',
            async_rc_potential_per_sec: 'gainDisplayRC',
        };

        for (const k in schema) {
            const t = getText([
                `[data-stat="${schema[k]}"]`,
                `[data-role="${schema[k]}"]`,
                `[data-idle-target="${schema[k]}"]`,
            ]);

            const val = (k.includes('chance') || k.includes('gain'))
            ? extractPercent(t)
            : extractNumber(t);

            if (DEBUG) {
                if (logThrottle(k, val)) {
                    console.log(`${k}   t- ${t}   val- ${val}`);
                }
            }
            if (val != null) out[k] = val;
        }

        const td = getText(['#tiles-defeated-badge strong:first-of-type']);
        if (td) out.tiles_defeated = parseInt(td.replace(/\D/g, ''), 10);

        out.cluster_tiles = countTileWrappers();
        Object.assign(out, extractLabeled());
        out.selected_department = extractSelectedDepartment();

        const clusterRoot = document.querySelector('#layer-complete-text');
        if (clusterRoot) {
            out.layer_complete_text = clusterRoot.innerText
                .replace(/\s+/g, ' ')
                .replace('Additional processing cycles queued.', '')
                .trim();

        }

        out.cluster_dc = clusterDcAccumulator;
        return out;
    }

    function applyAccumulation(prev, next) {
        if (prev.balance_rc !== next.balance_rc) {
            next.accumulated_dc = 0;
            return;
        }

        const prevDc = Number.isFinite(prev.balance_dc) ? prev.balance_dc : 0;
        const nextDc = Number.isFinite(next.balance_dc) ? next.balance_dc : 0;
        const prevAccum = Number.isFinite(prev.accumulated_dc) ? prev.accumulated_dc : 0;

        next.accumulated_dc = nextDc < prevDc ? prevAccum + (prevDc - nextDc) : prevAccum;
    }

    function buildDeptRecord(snapshot, opts = {}) {
        const session = opts.session || loadSession() || {
            dept_id: snapshot.department_id || '',
            dept_label: snapshot.department_label || '',
            start_ts: nowIso(),
            pending_rc_start: Number.isFinite(snapshot.pending_rc) ? snapshot.pending_rc : 0,
            tiles_start: Number.isFinite(snapshot.tiles_defeated) ? snapshot.tiles_defeated : 0,
            dc_start: Number.isFinite(snapshot.cluster_dc) ? snapshot.cluster_dc : 0,
            operators: Number.isFinite(snapshot.operators) ? snapshot.operators : 0,
            cluster_tiles: Number.isFinite(snapshot.cluster_tiles) ? snapshot.cluster_tiles : 0,
        };

        const completed = !!opts.completed;
        const startTs = session.start_ts || nowIso();
        const endTs = completed ? (opts.end_ts || nowIso()) : '';
        const startMs = new Date(startTs).getTime();
        const endMs = completed ? new Date(endTs).getTime() : Date.now();
        const durationMs = Number.isFinite(startMs) ? Math.max(0, endMs - startMs) : 0;
        const durationHr = durationMs / 3600000;

        const pendingRcStart = Number.isFinite(session.pending_rc_start) ? session.pending_rc_start : 0;
        const tilesStart = Number.isFinite(session.tiles_start) ? session.tiles_start : 0;
        const dcStart = Number.isFinite(session.dc_start) ? session.dc_start : 0;

        const pendingRcEnd = Number.isFinite(snapshot.pending_rc) ? snapshot.pending_rc : 0;
        const tilesEnd = Number.isFinite(snapshot.tiles_defeated) ? snapshot.tiles_defeated : 0;
        const dcEnd = Number.isFinite(snapshot.cluster_dc) ? snapshot.cluster_dc : 0;

        const rcDelta = pendingRcEnd - pendingRcStart;
        const blocksDefeated = Math.max(0, tilesEnd - tilesStart);
        const dcEarned = dcEnd - dcStart;

        const rcPerBlock = blocksDefeated ? rcDelta / blocksDefeated : 0;
        const dcPerBlock = blocksDefeated ? dcEarned / blocksDefeated : 0;
        const rcPerHr = durationHr ? rcDelta / durationHr : 0;
        const dcPerHr = durationHr ? dcEarned / durationHr : 0;

        return {
            ...clone(snapshot),

            entry_type: completed ? 'dept' : 'mid',

            pending_rc_start: pendingRcStart,
            pending_rc_end: pendingRcEnd,
            pending_rc_delta: rcDelta,

            tiles_start: tilesStart,
            tiles_end: tilesEnd,
            tiles_defeated_in_dept: blocksDefeated,

            dc_start: dcStart,
            dc_end: dcEnd,
            dc_earned: dcEarned,

            dept_start_time: startTs,
            dept_end_time: endTs,

            duration_ms: durationMs,
            duration_hr: durationHr,

            rc_delta: rcDelta,
            rc_per_block: rcPerBlock,
            dc_per_block: dcPerBlock,
            rc_per_hr: rcPerHr,
            dc_per_hr: dcPerHr,

            row_reason: opts.reason || snapshot.row_reason || '',
            layer_complete_text: completed ? (snapshot.layer_complete_text || '') : '',
            department_id: snapshot.department_id || session.dept_id || '',
            department_label: snapshot.department_label || session.dept_label || '',
            operators: Number.isFinite(snapshot.operators) ? snapshot.operators : (session.operators ?? 0),
            cluster_tiles: Number.isFinite(snapshot.cluster_tiles) ? snapshot.cluster_tiles : (session.cluster_tiles ?? 0),
        };
    }

    function startDeptSession(snapshot) {
        if (!snapshot || !snapshot.department_id) return null;

        const session = {
            dept_id: snapshot.department_id || '',
            dept_label: snapshot.department_label || '',
            start_ts: nowIso(),
            pending_rc_start: Number.isFinite(snapshot.pending_rc) ? snapshot.pending_rc : 0,
            tiles_start: Number.isFinite(snapshot.tiles_defeated) ? snapshot.tiles_defeated : 0,
            dc_start: Number.isFinite(snapshot.cluster_dc) ? snapshot.cluster_dc : 0,
            operators: Number.isFinite(snapshot.operators) ? snapshot.operators : 0,
            cluster_tiles: Number.isFinite(snapshot.cluster_tiles) ? snapshot.cluster_tiles : 0,
        };

        saveSession(session);
        return session;
    }

    function ensureDeptSession(snapshot) {
        const sess = loadSession();
        if (!snapshot || !snapshot.department_id) return null;

        if (!sess || sess.dept_id !== snapshot.department_id) {
            return startDeptSession(snapshot);
        }
        return sess;
    }

    function finalizeDepartment(prevSnapshot, reasonText, completed) {
        const session = loadSession() || startDeptSession(prevSnapshot);
        const record = buildDeptRecord(prevSnapshot, {
            session,
            completed,
            end_ts: completed ? nowIso() : '',
            reason: reasonText,
        });

        record.department_id = prevSnapshot.department_id || record.department_id || '';
        record.department_label = prevSnapshot.department_label || record.department_label || '';
        record.operators = Number.isFinite(prevSnapshot.operators) ? prevSnapshot.operators : (record.operators || 0);
        record.cluster_tiles = Number.isFinite(session?.cluster_tiles) ? session.cluster_tiles : (record.cluster_tiles || 0);
        record.row_reason = reasonText;

        if (!completed) {
            record.dept_end_time = '';
            record.layer_complete_text = '';
        }

        addHistoryEntry({
            timestamp: nowIso(),
            snapshot: record,
        });

        clearSession();
        return record;
    }

    function hasWatchedChange(prev, next) {
        if (!next.department_id) return false;

        let newreason = false;
        for (const k of WATCH_FIELDS) {
            const a = prev?.[k];
            const b = next?.[k];
            if (a !== b && isMeaningful(b)) {
                if (!newreason) {
                    next.row_reason = `${k} has changed. ${a} --> ${b}`;
                    console.log(next.row_reason);
                    newreason = true;
                }
            }
        }
        return newreason;
    }

    function completedDeptRecords() {
        return data.history
            .map((e) => e?.snapshot)
            .filter((s) => s && s.entry_type === 'dept' && Number(s.duration_ms) > 0 && Number(s.tiles_defeated_in_dept) > 0);
    }

    function computeAverage(records) {
        const out = {
            pending_rc_start: 0,
            pending_rc_end: 0,
            pending_rc_delta: 0,
            tiles_start: 0,
            tiles_end: 0,
            tiles_defeated_in_dept: 0,
            dc_start: 0,
            dc_end: 0,
            dc_earned: 0,
            duration_ms: 0,
            duration_hr: 0,
            rc_delta: 0,
            rc_per_block: 0,
            dc_per_block: 0,
            rc_per_hr: 0,
            dc_per_hr: 0,
        };

        if (!records.length) return out;

        for (const r of records) {
            for (const k in out) out[k] += Number(r[k] ?? 0);
        }

        for (const k in out) out[k] /= records.length;
        return out;
    }

    function metricsHeaderCells() {
        return ['RC/hr', 'DC/hr', 'RC/B', 'DC/B', 'Blk', 'Poss', 'Blk%', 'S/Clst'];
    }

    function metricsDataCells(obj, possibleBlocks = 0) {
        const secs = (obj.duration_hr ?? 0) * 3600;
        const pct = possibleBlocks > 0
        ? (obj.tiles_defeated_in_dept / possibleBlocks) * 100
        : 0;

        return [
            formatNum(obj.rc_per_hr, 2),
            formatNum(obj.dc_per_hr, 2),
            formatNum(obj.rc_per_block, 2),
            formatNum(obj.dc_per_block, 2),
            formatNum(obj.tiles_defeated_in_dept, 0),
            formatNum(possibleBlocks, 0),
            formatNum(pct, 1),
            formatNum(secs, 1),
        ];
    }

    function tableHtml(title, headers, rows) {
        const head = headers.map((h) => `<th>${escapeHtml(h)}</th>`).join('');
        const body = rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`).join('');

        return `
      <div class="frog-section">
        <div class="frog-title">${escapeHtml(title)}</div>
        <table class="frog-table">
          <thead><tr>${head}</tr></thead>
          <tbody>${body}</tbody>
        </table>
      </div>
    `;
    }

    function latestCategoryRecord(tiles) {
        const records = completedDeptRecords().filter((r) => Number(r.cluster_tiles || 0) === tiles);
        return records.length ? records[records.length - 1] : null;
    }

    function buildTooltip() {
        const lines = [];
        if ($("#flash-messages.hidden") !== null) {
            lines.push(' -=- -=- -=- -=- -=- -=- -=- -=- -=- -=- -=- -=- -=- ')
            lines.push('       Flash Notifications disabled in settings!     ')
            lines.push('   Frogger unable to acquire DC metrics without it!  ')
            lines.push(' -=- -=- -=- -=- -=- -=- -=- -=- -=- -=- -=- -=- -=- ')
        }
        if (data.spin_stage === 0) {
            lines.push('One spin exports the data. Two spins purges it.');
        } else if (data.spin_stage === 1) {
            const remaining = data.spin_deadline
            ? Math.max(0, Math.ceil((data.spin_deadline - Date.now()) / 1000))
            : 0;

            lines.push(`Data exported! Spin counter resets in ${remaining}s`);
        } else if (data.spin_stage === 2) {
            lines.push('Data purged.');
        }


        const deptNum = extractSelectedDepartmentNumber();
        const ref = data.history.length ? data.history[data.history.length - 1].snapshot : state;
        const completed = completedDeptRecords();
        const windows = [...new Set(
            SUMMARY_WINDOWS
            .map((n) => Math.min(n, completed.length))
            .filter((n) => n > 0)
        )];

        const prPct = ref.pr_max ? ((ref.damage_rating ?? 0) / ref.pr_max) * 100 : 0;
        const pct = new Intl.NumberFormat(undefined, {
            minimumFractionDigits: 1,
            maximumFractionDigits: 1
        }).format(prPct);

        lines.push(`Log:${data.history.length}/${MAX_HISTORY}    ${pct}% of DC+${deptNum ?? '?'}`);


        const styles = `
    <style>
      .frog-container {
        font-family: monospace;
        font-size: 13px;
        line-height: 1.15;
        color: #00ff9c;
      }

      .frog-section {
        margin: 6px 0 8px 0;
      }

      .frog-title {
        font-weight: 700;
        margin-bottom: 2px;
      }

      .frog-table {
        border-collapse: collapse;
        width: 100%;
      }

      .frog-table th,
      .frog-table td {
        padding: 0px 6px;
        white-space: nowrap;
        text-align: right;
      }

      .frog-table th:first-child,
      .frog-table td:first-child {
        text-align: left;
      }

      .frog-table thead tr {
        border-bottom: 1px solid rgba(0,255,156,.35);
      }
    </style>
  `;

        // ----- tile category table -----
        const categoryRows = [];

        for (const [tilesStr, label] of Object.entries(TILE_LABELS)) {
            const tiles = Number(tilesStr);
            const rec = latestCategoryRecord(tiles);
            if (!rec) continue;

            const avg = metricRow(computeAverage([rec]));
            const possible = tiles;

            categoryRows.push([
                label,
                ...metricsDataCells(avg, possible)
            ]);
        }

        const categoryTable = categoryRows.length
        ? tableHtml(
            'Latest tile categories',
            ['Category', ...metricsHeaderCells()],
            categoryRows
        )
        : '';

        // ----- rolling windows -----
        let windowTables = '';

        for (const n of windows) {
            const slice = completed.slice(-n);
            const avg = metricRow(computeAverage(slice));

            let possible = 0;
            let count = 0;

            for (const r of slice) {
                const tiles = Number(r.cluster_tiles || 0);
                if (tiles > 0) {
                    possible += tiles;
                    count++;
                }
            }

            possible = count ? (possible / count) : 0;

            windowTables += tableHtml(
                `Last ${slice.length} Depts`,
                metricsHeaderCells(),
                [metricsDataCells(avg, possible)]
            );
        }

        return `
    ${styles}
    <div class="frog-container">
      <div class="frog-meta">
        ${lines.map((l) => `<div>${escapeHtml(l)}</div>`).join('')}
      </div>
      ${categoryTable}
      ${windowTables}
    </div>
  `;
    }

    function ensureTooltip() {
        let t = document.getElementById('frog-tooltip');
        if (t) return t;

        t = document.createElement('div');
        t.id = 'frog-tooltip';
        Object.assign(t.style, {
            position: 'fixed',
            zIndex: 99999,
            display: 'none',
            fontFamily: 'monospace',
            fontSize: '11px',
            background: 'rgba(0,0,0,.92)',
            color: '#00ff9c',
            padding: '8px',
            borderRadius: '6px',
            pointerEvents: 'none',
            maxWidth: '92vw',
            maxHeight: '85vh',
            overflow: 'auto',
        });

        document.body.appendChild(t);
        return t;
    }

    function attachTooltip(btn) {
        const tip = ensureTooltip();
        let visible = false;
        let hideTimer = null;

        btn.addEventListener('mouseenter', () => {
            if (hideTimer) {
                clearTimeout(hideTimer);
                hideTimer = null;
            }
            visible = true;
            tip.style.display = 'block';
            tip.innerHTML = buildTooltip();
        });

        btn.addEventListener('mouseleave', () => {
            hideTimer = setTimeout(() => {
                visible = false;
                tip.style.display = 'none';
            }, FROGOVER_MS);
        });

        btn.addEventListener('mousemove', (e) => {
            tip.style.left = `${e.clientX + 14}px`;
            tip.style.top = `${e.clientY + 14}px`;
        });

        setInterval(() => {
            if (visible) tip.innerHTML = buildTooltip();
        }, FROGOVER_MS);
    }

    function flashMessage(txt) {
        console.log('Flash Message\n' + txt.replace(/<br\s*\/?>/gi, '\n'));
        const container = document.querySelector('div#flash-messages');
        if (!container) return;

        const wrapper = document.createElement('div');
        wrapper.innerHTML = `
<div class="card relative shadow-md bg-base-100 border border-base-300 border-l-4 rainbow-border" data-controller="flash" role="alert">
  <div class="card-body p-3">
    <button type="button" class="btn btn-ghost btn-xs absolute top-0 right-0 w-8 h-8 min-h-0 p-0 leading-none" aria-label="Dismiss notification" data-action="click->flash#dismissAll">
      <span class="text-xs">x</span>
    </button>
    <div class="text-xs leading-snug break-words pr-5">${txt}</div>
  </div>
</div>`.trim();

        container.prepend(wrapper.firstElementChild);
    }

    function resetSpinStage() {
        data.spin_stage = 0;

        if (data.spin_timer) {
            clearTimeout(data.spin_timer);
        }

        data.spin_timer = null;
        data.spin_deadline = null;

        saveData();
        flashMessage('Spin timed out: Counter reset.');
    }

    function performSpinAction() {
        // Cancel any existing timer
        if (data.spin_timer) {
            clearTimeout(data.spin_timer);
            data.spin_timer = null;
        }

        if (data.spin_stage === 0) {
            exportCSV(state, data.history);

            data.spin_stage = 1;

            // set deadline for countdown display
            data.spin_deadline = Date.now() + SPIN_TIMEOUT_MS;

            // start expiry timer
            data.spin_timer = setTimeout(() => {
                resetSpinStage();
            }, SPIN_TIMEOUT_MS);

            saveData();

            flashMessage(
                'Frog spin 1 complete: exported current data. One more full spin purges all saved Frogger data.'
            );
            return;
        }

        // second spin within window -> purge
        clearAllData();

        data.spin_stage = 0;
        data.spin_timer = null;
        data.spin_deadline = null;

        saveData();

        flashMessage('Frog spin 2 complete: all Frogger data purged.');
    }

    function ensureButton() {
        const anchor = document.querySelector('[data-tutorial-target="sidebarMsg"]');
        if (!anchor || document.getElementById('frogbtn')) return;

        const b = document.createElement('button');
        b.id = 'frogbtn';
        b.textContent = '🐸';
        b.style.marginLeft = '6px';
        b.style.transition = 'transform 0.15s ease-out';

        attachTooltip(b);

        let holdTimer = null;
        let startTime = 0;
        let animFrame = null;
        let holding = false;

        function resetRotation() {
            cancelAnimationFrame(animFrame);
            b.style.transition = 'transform 0.15s ease-out';
            b.style.transform = 'rotate(0deg)';
        }

        function cancelHold() {
            if (!holding) return;
            holding = false;
            clearTimeout(holdTimer);
            cancelAnimationFrame(animFrame);
            resetRotation();
        }

        function animate() {
            if (!holding) return;

            const elapsed = Date.now() - startTime;
            const pct = Math.min(1, elapsed / HOLD_MS);

            b.style.transition = 'none';
            b.style.transform = `rotate(${pct * 360}deg)`;

            if (pct < 1) {
                animFrame = requestAnimationFrame(animate);
            }
        }

        b.addEventListener('mousedown', (e) => {
            if (e.button !== 0) return;

            holding = true;
            startTime = Date.now();

            holdTimer = setTimeout(() => {
                holding = false;
                b.style.transform = 'rotate(360deg)';
                performSpinAction();
                setTimeout(resetRotation, 100);
            }, HOLD_MS);

            animate();
        });

        b.addEventListener('mouseup', cancelHold);
        b.addEventListener('mouseleave', cancelHold);
        b.addEventListener('mousemove', () => {
            if (holding) cancelHold();
        });

        document.addEventListener('mouseup', cancelHold);

        anchor.insertAdjacentElement('afterend', b);
    }

    function parseDCNumber(str) {
        // This is a brutal brute force attempt at bypassing localization shenanigans.
        // If we **KNOW** the number is always in ####.## format, we can strip everything
        // but the numbers, and treat the last two digits as the cents.

        let neg = false;

        // scan digits only
        let digits = '';

        for (let i = 0; i < str.length; i++) {
            const c = str[i];

            if (c === '-') {
                neg = true;
            } else if (c >= '0' && c <= '9') {
                digits += c;
            }
        }

        if (digits.length < 3) return null; // must have at least "0.00"

        const intPart = digits.slice(0, -2);
        const fracPart = digits.slice(-2);

        const value = Number(intPart + '.' + fracPart);

        return neg ? -value : value;
    }

    function watchFlashPayments() {
        const root = document.querySelector('#flash-messages');
        if (!root) {
            setTimeout(watchFlashPayments, 500);
            return;
        }

        const observer = new MutationObserver(() => {
            const cards = root.querySelectorAll('[role="alert"]:not(.frogger-ack)');
            for (const card of cards) {
                const textEl = card.querySelector('.card-body div');
                if (!textEl) continue;

                const txt = textEl.innerText.trim();
                const m = txt.match(/!\s*([^[]+)\s*\[/i);
                //const m = txt.match(/^You got paid!\s*(.+?)\s*\[DC\]/i); // Damnable localizations.
                //                console.log(`txt = "${txt}"  m = "${m}" `)
                if (!m) continue;

                const value = extractNumber(m[1]);
                //                console.log(`txt = "${txt}"  m = "${m[1]}"  value = "${value}"`)
                //console.log(Date.now() + ' ' + value)
                if (Number.isFinite(value)) {
                    clusterDcAccumulator = Math.round((clusterDcAccumulator + value) * 100) / 100;
                    state.cluster_dc = clusterDcAccumulator;
                    data.live = state;
                    saveData();
                }

                card.classList.add('frogger-ack');
            }
        });

        observer.observe(root, { childList: true, subtree: true });
    }

    function formatK(v, digits = 2, suffix = '') {
        if (!Number.isFinite(v)) return '';
        if (Math.abs(v) >= 1000) {
            return `${formatNum(v / 1000, digits)}k${suffix}`;
        }
        return `${formatNum(v, digits)}${suffix}`;
    }


    function showReady() {
        document.querySelector('div#flash-messages')?.classList.remove('stack');
        flashMessage('Frogger ready,');
    }

    function tick() {
        ensureButton();

        const prev = clone(state);
        const next = clone(state);
        Object.assign(next, extractStats());
        applyAccumulation(prev, next);

        if (
            prev.department_id &&
            next.department_id &&
            prev.department_id !== next.department_id
        ) {
            // End the previous department when the dept id rolls over.
            // Completion is forced true here so the rollover snapshot always closes cleanly.
            const completed = true;
            const reason = `department_id has changed. ${prev.department_id} --> ${next.department_id}`;

            console.log(prev.layer_complete_text)
            const record = finalizeDepartment(prev, reason, completed);

            if ((record.dc_earned > 0 || record.rc_delta > 0) && record.department_id) {
                const durationSec = Math.round(record.duration_hr * 3600); // convert hours → seconds
                const totalTiles = Number(record.cluster_tiles || 1);       // Total tiles in this dept
                const secondsPerBlock =
                      (record.cluster_tiles === 1 ? 1 : record.tiles_defeated_in_dept) > 0
                ? (record.duration_ms / 1000) /
                      (record.cluster_tiles === 1 ? 1 : record.tiles_defeated_in_dept)
                : 0;
                const defeated = Number(record.tiles_defeated_in_dept || 1); // Tiles defeated
                const pct = totalTiles > 0 ? (defeated / totalTiles) * 100 : 0; // Percent cleared
                const labelName = TILE_LABELS[record.cluster_tiles] || 'Unknown';
                //EndScreen
                let msg =  `${next.selected_department} ${record.department_id} (${labelName}-${formatNum(defeated,0)} dug, ${formatNum(pct,1)}%)<br>` +
                    `Time: ${durationSec}s - ${formatNum(secondsPerBlock,2)} sec/B`+
                    (record.operators > 1 ? ` - ${record.operators} ops` : '') + '<br>' +
                    `${formatNum(record.rc_delta)} RC ` +
                    `(${formatNum(record.rc_per_block)} RC/B, ${formatNum(record.rc_per_hr)} RC/hr)<br>`
                if (K_SQUISH) {
                    msg += `${formatK(record.dc_earned,2,' DC')}  ` +
                        `(${formatK(record.dc_per_block, 2, ' DC/B')}, ${formatK(record.dc_per_hr, 2, ' DC/hr')})`
                } else {
                    msg += `${formatNum(record.dc_earned,2)} DC ` +
                        `(${formatNum(record.dc_per_block, 2)} DC/B, ${formatNum(record.dc_per_hr, 2)}  DC/hr`
                }

                flashMessage(msg);
            }
            clusterDcAccumulator = 0;
            next.cluster_dc = 0;
            next.layer_complete_text = '';
            next.row_reason = reason;
            data.live = next;
            state = next;
            saveData();

            if (next.department_id) {
                startDeptSession(next);
            }
        } else if (hasWatchedChange(prev, next)) {
            const session = loadSession() || (next.department_id ? startDeptSession(next) : null);
            const record = buildDeptRecord(next, {
                session,
                completed: false,
                reason: next.row_reason || 'watched field changed',
            });

            record.entry_type = 'mid';
            record.dept_end_time = '';
            record.layer_complete_text = '';
            addHistoryEntry({
                timestamp: nowIso(),
                snapshot: record,
            });
        } else if (next.department_id) {
            ensureDeptSession(next);
        }

        data.live = next;
        state = next;
        saveData();
    }

    function preBuildFrogView() {
        let btn = document.getElementById('frogbtn')
        if (!btn) { return; }

        const tip = ensureTooltip();
        tip.style.display = 'none';
        tip.innerHTML = buildTooltip();

    }

    // ---------- CSS ----------
    {
        GM_addStyle(`
    .rainbow-border { position: relative; }
    .rainbow-border::before {
      content: "";
      position: absolute;
      inset: 0;
      padding: 3px;
      border-radius: var(--radius-box);
      background: linear-gradient(
        45deg,
        var(--base-color,#3498db),
        #ff0080,
        #ff8000,
        #ff0,
        #80ff00,
        #00ff80,
        #0080ff,
        #8000ff,
        var(--base-color,#3498db)
      );
      background-size: 400% 400%;
      animation: rainbow-shift 3s ease-in-out infinite;
      -webkit-mask:
        linear-gradient(#fff 0 0) content-box,
        linear-gradient(#fff 0 0);
      -webkit-mask-composite: xor;
      mask-composite: exclude;
      pointer-events: none;
    }
  `);
    }
    // ---------- bootstrap ----------
    data = loadData();

    if (data.spin_stage != 0) {
        data.spin_timer = setTimeout(() => {
            resetSpinStage();
        }, SPIN_TIMEOUT_MS);
    }
    state = data.live || defaultState();
    clusterDcAccumulator = Number.isFinite(state.cluster_dc) ? state.cluster_dc : 0;

    if (state.department_id && (!data.active || data.active.dept_id !== state.department_id)) {
        startDeptSession(state);
    }

    watchFlashPayments();
    setInterval(tick, TICK_MS);
    tick();
    preBuildFrogView()
    showReady();

})();