DeepCo Frogger

Dept-focused logging and enhanced Frogview

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();