NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
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();
}
})();