GeGe_GM / Kitten Clash Arena Helper

// ==UserScript==
// @name         Kitten Clash Arena Helper
// @namespace    https://openuserjs.org/users/GeGe_GM
// @version      0.4.10
// @description  Passive stats tracker + automatic leaderboard push to kittenclash-arena-helper.chez.gg. Uses the game's own /api/v1/leaderboard endpoint.
// @author       GeGe_GM
// @copyright    2026, GeGe_GM
// @license      MIT
// @icon         https://kittenclash-arena-helper.chez.gg/assets/img/favicon-192.png
// @homepageURL  https://kittenclash-arena-helper.chez.gg/
// @supportURL   https://openuserjs.org/scripts/GeGe_GM/Kitten_Clash_Arena_Helper/issues
// @updateURL    https://openuserjs.org/meta/GeGe_GM/Kitten_Clash_Arena_Helper.meta.js
// @downloadURL  https://openuserjs.org/install/GeGe_GM/Kitten_Clash_Arena_Helper.user.js
// @match        https://kittenclash.com/*
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @connect      kittenclash-arena-helper.chez.gg
// ==/UserScript==

(function () {
    'use strict';

    // ------------------------------------------------------------------
    // Constants
    // ------------------------------------------------------------------
    const STORAGE_KEY = 'kch-helper:v1:state';
    const LOG_TTL_MS = 24 * 60 * 60 * 1000;
    const MAX_LOGS = 500;
    const ROLLOVER_CHECK_MS = 60 * 1000;
    const REWARDS_PARSE_DELAY_MS = 200;
    const LEAGUE_POLL_MS = 1000;
    const LEADERBOARD_URL = '/api/v1/leaderboard';
    const SEASON_URL = '/api/v1/season';
    const LEADERBOARD_MAX_PAGES = 50;
    const PUSH_URL = 'https://kittenclash-arena-helper.chez.gg/api/push_ranks.php';

    const SEL = {
        fighterName: '.home-header .fighter-name',
        fighterLevel: '.home-header .fighter-level',
        resultCard: '.battle-overlay .result-card',
        resultHeading: '.battle-overlay .result-card h2',
        rewardsList: '.battle-overlay .result-card .rewards li',
        dungeonMarker: '.battle-overlay .result-card .dungeon-result-blessings',
        leagueHeader: '.league-header',
        podium: '.podium',
        podiumSpot: '.podium .podium-spot',
        rankList: '.rank-list',
        rankItem: '.rank-list li',
    };

    // ------------------------------------------------------------------
    // Utilities
    // ------------------------------------------------------------------
    const pad2 = (n) => String(n).padStart(2, '0');

    function parseLevel(text) {
        if (!text) return null;
        const m = String(text).match(/Niveau\s+(\d+)/i) || String(text).match(/Niv\.?\s*(\d+)/i);
        return m ? parseInt(m[1], 10) : null;
    }

    function todayStr() {
        const d = new Date();
        return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
    }

    function sleep(ms) {
        return new Promise((r) => setTimeout(r, ms));
    }

    async function waitFor(selector, { timeoutMs, intervalMs = 300 } = {}) {
        const deadline = Date.now() + timeoutMs;
        while (Date.now() < deadline) {
            const el = document.querySelector(selector);
            if (el) return el;
            await sleep(intervalMs);
        }
        return null;
    }

    // ------------------------------------------------------------------
    // Persistence
    // ------------------------------------------------------------------
    function emptyStats() {
        return { battles: 0, wins: 0, xp: 0, gold: 0, elo: 0 };
    }

    const defaultState = () => ({
        today: Object.assign({ date: todayStr() }, emptyStats()),
        ranks: { ts: 0, entries: [] },
        push: { enabled: false, lastTs: 0, lastStatus: '' },
        logs: [],
    });

    function sanitizeBucket(input) {
        const b = emptyStats();
        if (input && typeof input === 'object') {
            for (const k of Object.keys(b)) {
                const v = Number(input[k]);
                if (Number.isFinite(v)) b[k] = v;
            }
        }
        return b;
    }

    function sanitizeRanks(input) {
        const out = { ts: 0, entries: [] };
        if (!input || typeof input !== 'object') return out;
        out.ts = Number(input.ts) || 0;
        if (Array.isArray(input.entries)) {
            for (const e of input.entries) {
                if (!e || typeof e !== 'object') continue;
                const rank = Number(e.rank);
                if (!Number.isFinite(rank) || rank <= 0) continue;
                const name = String(e.name || '');
                const level = e.level == null ? null : Number(e.level);
                // Accept `rating` (v0.3.5+) with fallback to legacy `elo` field.
                const ratingRaw = e.rating != null ? e.rating : e.elo;
                const rating = ratingRaw == null ? null : Number(ratingRaw);
                const wins = e.wins == null ? null : Number(e.wins);
                const fighterId = e.fighterId ? String(e.fighterId) : null;
                out.entries.push({
                    rank,
                    name,
                    level: Number.isFinite(level) ? level : null,
                    rating: Number.isFinite(rating) ? rating : null,
                    wins: Number.isFinite(wins) ? wins : null,
                    fighterId,
                });
            }
        }
        out.entries.sort((a, b) => a.rank - b.rank);
        return out;
    }

    function sanitizePush(input) {
        const out = { enabled: false, lastTs: 0, lastStatus: '', lastSuccessDay: '' };
        if (!input || typeof input !== 'object') return out;
        out.enabled = input.enabled === true;
        const ts = Number(input.lastTs);
        if (Number.isFinite(ts) && ts > 0) out.lastTs = ts;
        if (typeof input.lastStatus === 'string') out.lastStatus = input.lastStatus.slice(0, 200);
        if (typeof input.lastSuccessDay === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(input.lastSuccessDay)) {
            out.lastSuccessDay = input.lastSuccessDay;
        }
        return out;
    }

    function loadState() {
        try {
            const raw = localStorage.getItem(STORAGE_KEY);
            if (!raw) return defaultState();
            const parsed = JSON.parse(raw);
            const s = defaultState();
            if (parsed && parsed.today && typeof parsed.today.date === 'string') {
                s.today = Object.assign({ date: parsed.today.date || todayStr() }, sanitizeBucket(parsed.today));
            }
            s.ranks = sanitizeRanks(parsed && parsed.ranks);
            s.push = sanitizePush(parsed && parsed.push);
            s.logs = pruneLogs(Array.isArray(parsed && parsed.logs) ? parsed.logs : []);
            return s;
        } catch (_) {
            return defaultState();
        }
    }

    function saveState() {
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
        } catch (_) { /* quota / disabled - ignore */ }
    }

    function pruneLogs(logs) {
        const cutoff = Date.now() - LOG_TTL_MS;
        const kept = logs.filter((l) => l && typeof l.ts === 'number' && l.ts >= cutoff);
        return kept.length > MAX_LOGS ? kept.slice(kept.length - MAX_LOGS) : kept;
    }

    function rolloverIfNeeded() {
        const t = todayStr();
        if (state.today.date === t) return false;
        const previousDate = state.today.date;
        state.today = Object.assign({ date: t }, emptyStats());
        saveState();
        renderStats();
        logInfo(`Day rollover: previous=${previousDate || '(none)'} today=${t}`);
        return true;
    }

    const state = loadState();

    // ------------------------------------------------------------------
    // Logger
    // ------------------------------------------------------------------
    function log(level, msg) {
        const entry = { ts: Date.now(), level, msg: String(msg) };
        state.logs.push(entry);
        state.logs = pruneLogs(state.logs);
        saveState();
        appendLogRow(entry);
        try { console.log(`[KCHH][${level}]`, msg); } catch (_) {}
    }
    const logInfo = (m) => log('INFO', m);
    const logWarn = (m) => log('WARN', m);
    const logErr  = (m) => log('ERR', m);

    const debugOnceSeen = new Set();
    function debugOnce(key, msg) {
        if (debugOnceSeen.has(key)) return;
        debugOnceSeen.add(key);
        log('DEBUG', msg);
    }

    // ------------------------------------------------------------------
    // Widget
    // ------------------------------------------------------------------
    let ui = null;

    function injectStyles() {
        const css = `
        .kchh-panel {
            position: fixed; top: 12px; left: 12px; z-index: 999999;
            width: 320px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
            font-size: 13px; color: #f5f5f5; background: rgba(20,22,28,0.94);
            border: 1px solid #333; border-radius: 8px;
            box-shadow: 0 6px 20px rgba(0,0,0,0.45);
            user-select: none;
        }
        .kchh-header {
            padding: 8px 10px; border-bottom: 1px solid #333;
            display: flex; justify-content: space-between; align-items: center;
        }
        .kchh-title { font-weight: 600; color: #ff9a3d; }
        .kchh-site-link { display: block; padding: 2px 10px 6px; font-size: 11px; }
        .kchh-site-link a { color: #7aa2f7; text-decoration: underline; }
        .kchh-site-link a:hover { color: #a3c0ff; }
        .kchh-fighter { color: #ddd; font-size: 12px; }
        .kchh-body { padding: 6px 10px 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 3px 8px; }
        .kchh-body .kchh-label { color: #a0a0a0; }
        .kchh-body .kchh-value { color: #fff; text-align: right; font-variant-numeric: tabular-nums; }
        .kchh-section {
            grid-column: 1 / -1; color: #ff9a3d; font-weight: 600; font-size: 11px;
            text-transform: uppercase; letter-spacing: 0.04em; padding: 6px 0 2px;
            border-bottom: 1px solid #2a2d36; margin-bottom: 2px;
        }
        .kchh-section .kchh-section-date { color: #7a7a7a; float: right; font-weight: 400; font-size: 11px; }
        .kchh-buttons { padding: 8px 10px; display: flex; gap: 6px; border-top: 1px solid #333; }
        .kchh-btn {
            flex: 1; padding: 6px 8px; border-radius: 4px; border: 1px solid #333;
            background: #2a2d36; color: #fff; cursor: pointer; font-size: 12px; font-family: inherit;
        }
        .kchh-btn:hover { background: #363a45; }
        .kchh-btn.kchh-reset { background: #6a1b9a; border-color: #4a148c; }
        .kchh-btn.kchh-reset:hover { background: #7b1fa2; }
        .kchh-status { font-size: 12px; color: #cfcfcf; padding: 6px 10px 10px; border-top: 1px solid #333; }
        .kchh-log-window {
            display: none;
            position: fixed; top: 12px; right: 12px; z-index: 999999;
            width: 460px; max-height: calc(100vh - 24px);
            font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
            font-size: 13px; color: #f5f5f5; background: rgba(20,22,28,0.94);
            border: 1px solid #333; border-radius: 8px;
            box-shadow: 0 6px 20px rgba(0,0,0,0.45);
            flex-direction: column;
        }
        .kchh-log-window.kchh-open { display: flex; }
        .kchh-log-header {
            padding: 8px 10px; border-bottom: 1px solid #333;
            display: flex; justify-content: space-between; align-items: center;
        }
        .kchh-log-title { font-weight: 600; color: #ff9a3d; }
        .kchh-log-close {
            background: transparent; border: 1px solid #333; color: #ccc;
            border-radius: 4px; padding: 2px 8px; cursor: pointer; font-size: 12px;
        }
        .kchh-log-close:hover { background: #363a45; }
        .kchh-logs {
            display: block; flex: 1 1 auto; overflow-y: auto;
            padding: 6px 8px;
            font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
            font-size: 12px; line-height: 1.35;
        }
        .kchh-log-row { white-space: pre-wrap; word-break: break-word; }
        .kchh-log-row.INFO  { color: #d0d0d0; }
        .kchh-log-row.WARN  { color: #ffb74d; }
        .kchh-log-row.ERR   { color: #ef5350; }
        .kchh-log-row.DEBUG { color: #7aa2f7; }
        .kchh-log-ts { color: #6b6b6b; margin-right: 4px; }
        .kchh-ranks-window {
            display: none;
            position: fixed; top: 12px; right: 12px; z-index: 999999;
            width: 460px; max-height: calc(100vh - 24px);
            font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
            font-size: 13px; color: #f5f5f5; background: rgba(20,22,28,0.94);
            border: 1px solid #333; border-radius: 8px;
            box-shadow: 0 6px 20px rgba(0,0,0,0.45);
            flex-direction: column;
        }
        .kchh-ranks-window.kchh-open { display: flex; }
        .kchh-ranks-body { padding: 8px 10px; overflow-y: auto; flex: 1 1 auto; }
        .kchh-ranks-summary { color: #a0a0a0; font-size: 12px; margin-bottom: 8px; }
        .kchh-ranks-table { width: 100%; border-collapse: collapse; font-size: 12px; }
        .kchh-ranks-table th {
            text-align: right; color: #a0a0a0; font-weight: normal;
            padding: 4px 6px; border-bottom: 1px solid #333;
        }
        .kchh-ranks-table th.kchh-ranks-name, .kchh-ranks-table td.kchh-ranks-name { text-align: left; }
        .kchh-ranks-table td {
            padding: 3px 6px; border-bottom: 1px solid #222;
            font-variant-numeric: tabular-nums; text-align: right;
        }
        .kchh-ranks-table tr.kchh-podium td { color: #ffd166; }
        .kchh-ranks-empty { color: #888; text-align: center; padding: 20px; }
        .kchh-push-line { padding: 4px 10px; border-top: 1px solid #333; font-size: 11px; color: #a0a0a0; font-variant-numeric: tabular-nums; word-break: break-word; }
        .kchh-push-line.kchh-err { color: #ef5350; }
        .kchh-push-line.kchh-ok  { color: #66bb6a; }
        .kchh-btn.kchh-update { background: #1565c0; border-color: #0d47a1; }
        .kchh-btn.kchh-update:hover:not(:disabled) { background: #1976d2; }
        .kchh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
        `;
        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);
    }

    function buildWidget() {
        const panel = document.createElement('div');
        panel.className = 'kchh-panel';
        panel.innerHTML = `
            <div class="kchh-header">
                <span class="kchh-title">Kitten Clash Arena Helper</span>
                <span class="kchh-fighter" data-role="fighter">-</span>
            </div>
            <div class="kchh-site-link"><a href="https://kittenclash-arena-helper.chez.gg/" target="_blank" rel="noopener">https://kittenclash-arena-helper.chez.gg/</a></div>
            <div class="kchh-body">
                <div class="kchh-section">Today<span class="kchh-section-date" data-role="today-date"></span></div>
                <span class="kchh-label">Battles</span><span class="kchh-value" data-role="today-battles">0</span>
                <span class="kchh-label">Wins</span><span class="kchh-value" data-role="today-wins">0</span>
                <span class="kchh-label">Success rate</span><span class="kchh-value" data-role="today-rate">-</span>
                <span class="kchh-label">XP</span><span class="kchh-value" data-role="today-xp">0</span>
                <span class="kchh-label">Gold 💰</span><span class="kchh-value" data-role="today-gold">0</span>
                <span class="kchh-label">ELO ⭐</span><span class="kchh-value" data-role="today-elo">0</span>
            </div>
            <div class="kchh-buttons">
                <button class="kchh-btn kchh-update" data-role="update" title="Fetch the leaderboard from the game API now">↻ Update</button>
                <button class="kchh-btn" data-role="logs">Logs</button>
                <button class="kchh-btn" data-role="ranks">Ranks</button>
                <button class="kchh-btn kchh-reset" data-role="reset">Reset</button>
            </div>
            <div class="kchh-status" data-role="status">Watching...</div>
            <div class="kchh-push-line" data-role="push-line">Push: (never)</div>
        `;
        document.body.appendChild(panel);

        const logWindow = document.createElement('div');
        logWindow.className = 'kchh-log-window';
        logWindow.innerHTML = `
            <div class="kchh-log-header">
                <span class="kchh-log-title">Kitten Clash Arena Helper - Logs</span>
                <button class="kchh-log-close" data-role="log-close">Close</button>
            </div>
            <div class="kchh-logs" data-role="log-panel"></div>
        `;
        document.body.appendChild(logWindow);

        const ranksWindow = document.createElement('div');
        ranksWindow.className = 'kchh-ranks-window';
        ranksWindow.innerHTML = `
            <div class="kchh-log-header">
                <span class="kchh-log-title">Kitten Clash Arena Helper - Ranks</span>
                <button class="kchh-log-close" data-role="ranks-close">Close</button>
            </div>
            <div class="kchh-ranks-body" data-role="ranks-body"></div>
        `;
        document.body.appendChild(ranksWindow);

        ui = {
            panel,
            fighter: panel.querySelector('[data-role="fighter"]'),
            status: panel.querySelector('[data-role="status"]'),
            resetBtn: panel.querySelector('[data-role="reset"]'),
            logsBtn: panel.querySelector('[data-role="logs"]'),
            todayDate: panel.querySelector('[data-role="today-date"]'),
            buckets: {
                today: {
                    battles: panel.querySelector('[data-role="today-battles"]'),
                    wins: panel.querySelector('[data-role="today-wins"]'),
                    rate: panel.querySelector('[data-role="today-rate"]'),
                    xp: panel.querySelector('[data-role="today-xp"]'),
                    gold: panel.querySelector('[data-role="today-gold"]'),
                    elo: panel.querySelector('[data-role="today-elo"]'),
                },
            },
            logWindow,
            logPanel: logWindow.querySelector('[data-role="log-panel"]'),
            logCloseBtn: logWindow.querySelector('[data-role="log-close"]'),
            ranksBtn: panel.querySelector('[data-role="ranks"]'),
            ranksWindow,
            ranksBody: ranksWindow.querySelector('[data-role="ranks-body"]'),
            ranksCloseBtn: ranksWindow.querySelector('[data-role="ranks-close"]'),
            updateBtn: panel.querySelector('[data-role="update"]'),
            pushLine: panel.querySelector('[data-role="push-line"]'),
        };

        ui.resetBtn.addEventListener('click', onReset);
        ui.updateBtn.addEventListener('click', () => triggerCapture('manual'));
        ui.logsBtn.addEventListener('click', () => {
            ui.ranksWindow.classList.remove('kchh-open');
            ui.logWindow.classList.toggle('kchh-open');
            if (ui.logWindow.classList.contains('kchh-open')) {
                ui.logPanel.scrollTop = ui.logPanel.scrollHeight;
            }
        });
        ui.logCloseBtn.addEventListener('click', () => {
            ui.logWindow.classList.remove('kchh-open');
        });
        ui.ranksBtn.addEventListener('click', () => {
            ui.logWindow.classList.remove('kchh-open');
            const opened = ui.ranksWindow.classList.toggle('kchh-open');
            if (opened) renderRanks();
        });
        ui.ranksCloseBtn.addEventListener('click', () => {
            ui.ranksWindow.classList.remove('kchh-open');
        });
    }

    function fmtTs(ts) {
        const d = new Date(ts);
        return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
    }

    function escapeHtml(s) {
        return String(s)
            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    }

    function appendLogRow(entry) {
        if (!ui) return;
        const row = document.createElement('div');
        row.className = `kchh-log-row ${entry.level}`;
        row.innerHTML = `<span class="kchh-log-ts">${fmtTs(entry.ts)}</span>[${entry.level}] ${escapeHtml(entry.msg)}`;
        ui.logPanel.appendChild(row);
        while (ui.logPanel.childElementCount > MAX_LOGS) {
            ui.logPanel.removeChild(ui.logPanel.firstChild);
        }
        if (ui.logWindow.classList.contains('kchh-open')) {
            ui.logPanel.scrollTop = ui.logPanel.scrollHeight;
        }
    }

    function restoreLogPanel() {
        if (!ui) return;
        ui.logPanel.innerHTML = '';
        for (const entry of state.logs) appendLogRow(entry);
    }

    function fmtSigned(n) {
        const v = Number(n) || 0;
        return v > 0 ? `+${v}` : String(v);
    }

    function renderBucket(bucket, refs) {
        refs.battles.textContent = String(bucket.battles);
        refs.wins.textContent = String(bucket.wins);
        refs.rate.textContent = bucket.battles > 0
            ? `${(bucket.wins / bucket.battles * 100).toFixed(1)}%`
            : '-';
        refs.xp.textContent = fmtSigned(bucket.xp);
        refs.gold.textContent = fmtSigned(bucket.gold);
        refs.elo.textContent = fmtSigned(bucket.elo);
    }

    function renderStats() {
        if (!ui) return;
        ui.todayDate.textContent = state.today.date || '';
        renderBucket(state.today, ui.buckets.today);
    }

    function fmtDateTime(ts) {
        if (!ts) return '(never)';
        const d = new Date(ts);
        return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
    }

    function renderRanks() {
        if (!ui || !ui.ranksBody) return;
        const { ts, entries } = state.ranks;
        let html = '';
        if (!entries.length) {
            html += '<div class="kchh-ranks-empty">No ranks captured yet. Open the league page or click Update.</div>';
            ui.ranksBody.innerHTML = html;
            return;
        }
        html += `<div class="kchh-ranks-summary">Snapshot: ${fmtDateTime(ts)} · ${entries.length} entries</div>`;
        html += '<table class="kchh-ranks-table"><thead><tr>';
        html += '<th>#</th><th class="kchh-ranks-name">Name</th><th>Niv</th><th>ELO</th>';
        html += '</tr></thead><tbody>';
        for (const e of entries) {
            const podium = e.rank >= 1 && e.rank <= 3 ? ' class="kchh-podium"' : '';
            const rating = e.rating != null ? e.rating : e.elo;
            html += `<tr${podium}>`;
            html += `<td>#${e.rank}</td>`;
            html += `<td class="kchh-ranks-name">${escapeHtml(e.name || '-')}</td>`;
            html += `<td>${e.level != null ? e.level : '-'}</td>`;
            html += `<td>${rating != null ? rating : '-'}</td>`;
            html += '</tr>';
        }
        html += '</tbody></table>';
        ui.ranksBody.innerHTML = html;
    }

    // GM_xmlhttpRequest bypasses the page CSP that would otherwise block fetch() to a third-party host.
    function gmRequest(opts) {
        const gm = (typeof GM_xmlhttpRequest === 'function') ? GM_xmlhttpRequest
                 : (typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest.bind(GM)
                 : null;
        if (!gm) return Promise.reject(new Error('GM_xmlhttpRequest unavailable'));
        return new Promise((resolve, reject) => {
            gm(Object.assign({}, opts, {
                onload:  (r) => resolve(r),
                onerror: (r) => reject(new Error((r && r.error) || 'network')),
                ontimeout: () => reject(new Error('timeout')),
            }));
        });
    }

    // Best-effort season fetch; returns null on any failure so the ladder push still goes through.
    async function fetchSeason() {
        try {
            const res = await fetch(SEASON_URL, {
                credentials: 'same-origin',
                headers: { 'Accept': 'application/json' },
            });
            if (!res.ok) return null;
            const body = await res.json();
            const s = body && body.season;
            if (!s || !s.id || !s.startsAt || !s.endsAt) return null;
            return {
                id: Number(s.id),
                name: String(s.name || ''),
                startsAt: String(s.startsAt),
                endsAt: String(s.endsAt),
            };
        } catch (_) {
            return null;
        }
    }

    async function pushRanks(payload) {
        const req = (payload && Array.isArray(payload.entries)) ? payload : { entries: payload };
        try {
            const res = await gmRequest({
                method: 'POST',
                url: PUSH_URL,
                headers: { 'Content-Type': 'application/json' },
                data: JSON.stringify(req),
                timeout: 15000,
                responseType: 'json',
            });
            let body = res.response;
            if (body == null && res.responseText) {
                try { body = JSON.parse(res.responseText); } catch (_) { body = null; }
            }
            if (res.status < 200 || res.status >= 300 || !body || body.ok !== true) {
                const msg = body && body.error ? body.error : `HTTP ${res.status}`;
                state.push.lastTs = Date.now();
                state.push.lastStatus = `ERR ${msg}`;
                saveState();
                logErr(`Push failed: ${msg}`);
            } else {
                const pi = Number(body.players_inserted) || 0;
                const pu = Number(body.players_updated)  || 0;
                const ri = Number(body.ratings_inserted) || 0;
                const ru = Number(body.ratings_updated)  || 0;
                const sk = Number(body.skipped)          || 0;
                state.push.lastTs = Date.now();
                state.push.lastStatus = `players +${pi}/~${pu}, ratings +${ri}/~${ru}, skipped ${sk}`;
                state.push.lastSuccessDay = todayStr();
                saveState();
                logInfo(`Push OK: ${state.push.lastStatus}`);
            }
        } catch (ex) {
            state.push.lastTs = Date.now();
            state.push.lastStatus = `ERR ${ex && ex.message ? ex.message : 'network'}`;
            saveState();
            logErr(`Push exception: ${state.push.lastStatus}`);
        }
        updatePushLine();
        if (ui && ui.ranksWindow.classList.contains('kchh-open')) renderRanks();
    }

    function renderFighter() {
        if (!ui) return;
        const name = fighter.name || '-';
        const lvl = fighter.level != null ? ` · Niv ${fighter.level}` : '';
        ui.fighter.textContent = `${name}${lvl}`;
    }

    function setStatus(msg) {
        if (!ui) return;
        ui.status.textContent = msg;
    }

    function updatePushLine() {
        if (!ui || !ui.pushLine) return;
        const p = state.push;
        let cls = 'kchh-push-line';
        let text;
        if (!p.lastTs) {
            text = 'Push: (waiting for first capture)';
        } else {
            const stamp = fmtDateTime(p.lastTs);
            const status = p.lastStatus || 'OK';
            text = `Last push: ${stamp} - ${status}`;
            cls += status.startsWith('ERR') ? ' kchh-err' : ' kchh-ok';
        }
        ui.pushLine.textContent = text;
        ui.pushLine.className = cls;
    }

    function updateCaptureButton() {
        if (!ui || !ui.updateBtn) return;
        ui.updateBtn.disabled = capturing;
        ui.updateBtn.textContent = capturing ? '↻ ...' : '↻ Update';
    }

    // ------------------------------------------------------------------
    // Fighter info
    // ------------------------------------------------------------------
    const fighter = { name: null, level: null };

    function refreshFighterInfo() {
        const nameEl = document.querySelector(SEL.fighterName);
        const lvlEl = document.querySelector(SEL.fighterLevel);
        if (!nameEl || !lvlEl) {
            debugOnce('fighter-missing', 'Fighter selectors not found on refresh; keeping previous values.');
            return;
        }
        const name = nameEl.textContent.trim();
        const level = parseLevel(lvlEl.textContent.trim());
        if (name) fighter.name = name;
        if (level != null) fighter.level = level;
        renderFighter();
    }

    // ------------------------------------------------------------------
    // Battle detection (passive)
    // ------------------------------------------------------------------
    function classifyOutcome(text) {
        if (/victoire/i.test(text)) return 'WIN';
        if (/d[eé]faite/i.test(text)) return 'LOSS';
        return 'UNKNOWN';
    }

    function parseRewards() {
        const items = document.querySelectorAll(SEL.rewardsList);
        if (!items.length) return null;
        const out = { xp: 0, gold: 0, elo: 0 };
        items.forEach((li) => {
            const text = (li.textContent || '').trim();
            const m = text.match(/-?\d+/);
            if (!m) return;
            const n = parseInt(m[0], 10);
            if (/xp/i.test(text)) out.xp += n;
            else if (text.includes('💰')) out.gold += n;
            else if (text.includes('⭐')) out.elo += n;
        });
        return out;
    }

    function isDungeonResult(cardEl) {
        // Dungeon Victoire always includes a blessing choice list inside the result card.
        return !!(cardEl && cardEl.querySelector('.dungeon-result-blessings'));
    }

    // WeakSet dedups the same heading element (React reuses references while the card is mounted).
    const processedHeadings = new WeakSet();
    let observer = null;

    function startBattleObserver() {
        if (observer) return;
        // Mark any already-visible result card so we don't count a battle finished pre-load.
        const existingH2 = document.querySelector(SEL.resultHeading);
        if (existingH2) processedHeadings.add(existingH2);

        observer = new MutationObserver(() => {
            scanForResult();
        });
        observer.observe(document.body, { childList: true, subtree: true });
        scanForResult();
    }

    function scanForResult() {
        const h2 = document.querySelector(SEL.resultHeading);
        if (!h2 || processedHeadings.has(h2)) return;
        processedHeadings.add(h2);
        setTimeout(() => handleBattleResult(h2), REWARDS_PARSE_DELAY_MS);
    }

    // ------------------------------------------------------------------
    // League ranks capture (game API + explicit triggers)
    // ------------------------------------------------------------------

    // Same-origin fetch: kca_session cookie is attached automatically.
    async function fetchLeaderboardEntries() {
        const all = [];
        let offset = 0;
        for (let page = 0; page < LEADERBOARD_MAX_PAGES; page++) {
            const url = `${LEADERBOARD_URL}?offset=${offset}`;
            const res = await fetch(url, {
                credentials: 'same-origin',
                headers: { 'Accept': 'application/json' },
            });
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            const body = await res.json();
            const items = Array.isArray(body && body.top) ? body.top : [];
            for (const it of items) {
                if (!it || typeof it !== 'object') continue;
                const rank = Number(it.rank);
                const name = String(it.name || '').trim();
                const fighterId = it.fighterId ? String(it.fighterId) : '';
                if (!Number.isFinite(rank) || !name || !fighterId) continue;
                all.push({
                    rank,
                    fighterId,
                    name,
                    level: it.level == null ? 0 : Number(it.level),
                    rating: it.rating == null ? 0 : Number(it.rating),
                    wins: it.wins == null ? 0 : Number(it.wins),
                    appearanceSeed: it.appearanceSeed ? String(it.appearanceSeed) : null,
                    skinId: it.skinId == null ? null : String(it.skinId),
                    weaponId: it.weaponId == null ? null : String(it.weaponId),
                    equipped: (it.equipped && typeof it.equipped === 'object') ? it.equipped : null,
                });
            }
            // Optional: remember our own fighter for future features.
            if (body && body.me && body.me.fighterId && !state.myFighterId) {
                state.myFighterId = String(body.me.fighterId);
                saveState();
            }
            const nextOffset = Number(body && body.nextOffset);
            if (!items.length || !Number.isFinite(nextOffset) || nextOffset <= offset) break;
            offset = nextOffset;
        }
        all.sort((a, b) => a.rank - b.rank);
        return all;
    }

    let capturing = false;

    async function triggerCapture(source) {
        if (capturing) {
            logInfo(`Capture already in progress (skipped: ${source})`);
            return;
        }
        capturing = true;
        updateCaptureButton();
        const prevStatus = ui ? ui.status.textContent : '';
        setStatus(`Fetching leaderboard (${source})...`);
        try {
            const [entries, season] = await Promise.all([fetchLeaderboardEntries(), fetchSeason()]);
            finalizeCapture(entries, source, season);
        } catch (ex) {
            logErr(`Leaderboard fetch failed (${source}): ${ex && ex.message ? ex.message : ex}`);
            state.push.lastTs = Date.now();
            state.push.lastStatus = `ERR fetch ${ex && ex.message ? ex.message : ex}`;
            saveState();
            updatePushLine();
        } finally {
            capturing = false;
            updateCaptureButton();
            setStatus(prevStatus || 'Watching for battles...');
        }
    }

    function finalizeCapture(entries, source, season) {
        if (!entries || !entries.length) {
            logWarn(`Capture returned no entries (source=${source})`);
            return;
        }
        const changed = JSON.stringify(state.ranks.entries) !== JSON.stringify(entries);
        state.ranks = { ts: Date.now(), entries };
        saveState();
        logInfo(`Ranks ${changed ? 'captured' : 'unchanged'} (${source}): ${entries.length} entries${season ? ' + season ' + season.id : ''}`);
        if (ui && ui.ranksWindow.classList.contains('kchh-open')) renderRanks();
        pushRanks({ entries, season: season || undefined });
    }

    // 1 Hz poll: fires triggerCapture once when .league-header goes from absent to present.
    function watchLeagueEntry() {
        let wasOpen = !!document.querySelector(SEL.leagueHeader);
        setInterval(() => {
            const open = !!document.querySelector(SEL.leagueHeader);
            if (open && !wasOpen) triggerCapture('league-enter');
            wasOpen = open;
        }, LEAGUE_POLL_MS);
    }

    function handleBattleResult(h2) {
        // Element may have unmounted while we waited for rewards to render.
        if (!document.body.contains(h2)) return;
        const cardEl = h2.closest('.result-card') || document.querySelector(SEL.resultCard);
        if (isDungeonResult(cardEl)) {
            debugOnce('dungeon-skip-hint', 'Ignoring dungeon Victoire result cards; only PvP battles are tracked.');
            return;
        }

        const rawText = (h2.textContent || '').trim();
        const outcome = classifyOutcome(rawText);
        if (outcome === 'UNKNOWN') {
            logWarn(`Unknown result heading: "${rawText}" - skipping`);
            return;
        }

        rolloverIfNeeded();

        const rewards = parseRewards() || { xp: 0, gold: 0, elo: 0 };
        const bucket = state.today;
        bucket.battles += 1;
        if (outcome === 'WIN') bucket.wins += 1;
        bucket.xp += rewards.xp;
        bucket.gold += rewards.gold;
        bucket.elo += rewards.elo;
        saveState();
        renderStats();
        setStatus(`Last: ${outcome} · ${fmtSigned(rewards.xp)} XP · ${fmtSigned(rewards.gold)} 💰 · ${fmtSigned(rewards.elo)} ⭐`);
        logInfo(`PvP result: ${outcome} (${rawText}) rewards ${fmtSigned(rewards.xp)} XP, ${fmtSigned(rewards.gold)} 💰, ${fmtSigned(rewards.elo)} ⭐`);

        // Level may have changed after the battle; refresh with a small delay so the header repaints first.
        setTimeout(refreshFighterInfo, 1000);
    }

    // ------------------------------------------------------------------
    // Reset
    // ------------------------------------------------------------------
    function onReset() {
        if (!confirm('Reset today stats?')) return;
        state.today = Object.assign({ date: todayStr() }, emptyStats());
        saveState();
        renderStats();
        setStatus('Stats reset');
        logInfo('Stats reset');
    }

    // ------------------------------------------------------------------
    // Boot
    // ------------------------------------------------------------------
    function boot() {
        injectStyles();
        buildWidget();
        restoreLogPanel();
        rolloverIfNeeded();
        renderStats();
        renderFighter();
        updatePushLine();
        updateCaptureButton();
        setStatus('Watching for battles...');
        logInfo('Helper loaded');
        setInterval(rolloverIfNeeded, ROLLOVER_CHECK_MS);
        waitForInitialFighterInfo().then(() => startBattleObserver());
        watchLeagueEntry();
        // Fire one capture per Paris day when today has never been pushed.
        if (state.push.lastSuccessDay !== todayStr()) {
            triggerCapture('boot-new-day');
        }
    }

    async function waitForInitialFighterInfo() {
        const el = await waitFor(SEL.fighterName, { timeoutMs: 30000, intervalMs: 300 });
        if (el) {
            refreshFighterInfo();
        } else {
            logWarn('Fighter selectors not found within 30s of boot');
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', boot, { once: true });
    } else {
        boot();
    }
})();