Dept-focused logging and enhanced Frogview
// ==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, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
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();
})();