Skip to content

Instantly share code, notes, and snippets.

@strayge
Last active July 25, 2026 06:31
Show Gist options
  • Select an option

  • Save strayge/67aa73e55e1454b22c57a150aec16680 to your computer and use it in GitHub Desktop.

Select an option

Save strayge/67aa73e55e1454b22c57a150aec16680 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name RGG Chess Stats
// @namespace http://tampermonkey.net/
// @version 2026-07-25
// @description Extra stats for each team in RGG Chess
// @author strayge
// @match https://rgg.land/checkers/season/s5
// @icon https://www.google.com/s2/favicons?sz=64&domain=rgg.land
// @grant none
// @updateURL https://gist.github.com/strayge/67aa73e55e1454b22c57a150aec16680/raw/rgg_chess_stats.user.js
// @downloadURL https://gist.github.com/strayge/67aa73e55e1454b22c57a150aec16680/raw/rgg_chess_stats.user.js
// ==/UserScript==
(function() {
'use strict';
const LOG = 'RGG Chess Stats:';
// A hypothetical game used to project "what would closing a game give me".
// Difficulty 1/1 contributes nothing to k2, so this measures the plain
// completion + playtime gain without assuming a hard game.
const PROJECTED_GAME = {
status: 'completed',
playTime: 4 * 3600,
difficulty: { max: 1, selected: 1 },
};
// Dropping is projected as an immediate drop: it adds a game to G without
// adding any playtime, which dilutes both k1 and k2.
const PROJECTED_DROP = {
status: 'dropped',
playTime: 0,
difficulty: { max: 1, selected: 1 },
};
// Only finished games feed the score. Games that are still running or were
// rerolled carry a difficulty value that would otherwise corrupt k2.
const COUNTED_STATUSES = new Set(['completed', 'dropped']);
// ---------------------------------------------------------------- payload
// The season data lives in the Next.js RSC flight payload. Reading it beats
// scraping the DOM: the board is a <canvas> and per-player stats are never
// rendered as text, so neither is reachable any other way.
//
// self.__next_f cannot be used - Next replaces its push() with a consumer,
// leaving the array empty - but the inline script tags survive hydration.
function readFlightPayload() {
let raw = '';
for (const script of document.querySelectorAll('script')) {
const text = script.textContent;
if (!text.includes('self.__next_f.push')) continue;
const match = text.match(/self\.__next_f\.push\(\[1,(.*)\]\)/s);
if (!match) continue;
try {
raw += JSON.parse(match[1]);
} catch (e) {
// Chunks that are not string pushes are irrelevant here.
}
}
return raw;
}
// Pull the array that follows "key": out of the payload. The payload is not
// valid JSON as a whole, so the value is located by scanning for balanced
// brackets while skipping over string literals.
function extractArray(raw, key) {
const needle = `"${key}":[`;
const start = raw.indexOf(needle);
if (start < 0) return null;
const from = start + needle.length - 1;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = from; i < raw.length; i++) {
const char = raw[i];
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = !inString;
} else if (!inString) {
if (char === '[') {
depth++;
} else if (char === ']') {
depth--;
if (depth === 0) {
try {
return JSON.parse(raw.slice(from, i + 1));
} catch (e) {
console.log(LOG, 'failed to parse', key, e);
return null;
}
}
}
}
}
return null;
}
function parseSeason() {
const raw = readFlightPayload();
if (!raw) {
console.log(LOG, 'flight payload not found');
return null;
}
const season = {
teams: extractArray(raw, 'teams'),
players: extractArray(raw, 'players'),
pieces: extractArray(raw, 'pieces'),
games: extractArray(raw, 'games'),
};
for (const [key, value] of Object.entries(season)) {
if (!value) {
console.log(LOG, 'missing', key, 'in payload');
return null;
}
}
return season;
}
// ---------------------------------------------------------------- scoring
// S = 28*P/(P+PL)*Gc + k1*Pw*Gc/(Gd+1) + k2*T/60 + N - F
//
// Rounding happens once, on the final number, so every intermediate value
// is kept as a float.
function roundHalfUp(value) {
return Math.floor(value + 0.5);
}
// Difficulty contribution of a single game, normalised to 0..1. A game with
// a single difficulty level cannot say anything about effort, hence 0.
function gameEta(game) {
const difficulty = game.difficulty || {};
const levels = (difficulty.max || 0) - 1;
if (levels <= 0) return 0;
return ((difficulty.selected || 0) - 1) / levels;
}
function countedGames(games) {
return games.filter(game => COUNTED_STATUSES.has(game.status));
}
// Minutes per finished game across the whole season, the x2 baseline that
// k1 compares an entity against.
function seasonAverage(games) {
const counted = countedGames(games);
if (!counted.length) return 0;
const minutes = counted.reduce((sum, game) => sum + game.playTime, 0) / 60;
return minutes / counted.length;
}
function calculateScore(params) {
const { games, seasonAvg, onBoard, lost, captured, bonusPoints, penalties = 0 } = params;
const counted = countedGames(games);
const gamesTotal = counted.length;
const completed = counted.filter(game => game.status === 'completed').length;
const dropped = counted.filter(game => game.status === 'dropped').length;
const minutes = counted.reduce((sum, game) => sum + game.playTime, 0) / 60;
// k1 rewards spending more time per game than the season average, but is
// capped so a single marathon cannot run away with the score.
const ownAverage = gamesTotal ? minutes / gamesTotal : 0;
const k1 = seasonAvg ? Math.min(ownAverage / seasonAvg, 2) : 0;
const etaSum = counted.reduce((sum, game) => sum + gameEta(game), 0);
const k2 = gamesTotal ? 1 + etaSum / gamesTotal : 1;
const checkers = onBoard + lost;
let score = checkers ? (28 * onBoard / checkers) * completed : 0;
score += k1 * captured * completed / (dropped + 1);
score += k2 * minutes / 60;
score += bonusPoints - penalties;
return score;
}
// ------------------------------------------------------------- team stats
function buildTeamStats(season) {
const { teams, players, pieces, games } = season;
const teamOfUser = {};
players.forEach(player => {
teamOfUser[player.user._id] = player.teamCheckersId;
});
const onBoardByTeam = {};
pieces.forEach(piece => {
if (!piece.isOnBoard) return;
onBoardByTeam[piece.teamCheckersId] = (onBoardByTeam[piece.teamCheckersId] || 0) + 1;
});
const seasonAvg = seasonAverage(games);
return teams.map(team => {
const teamId = team.checkersId;
const stats = team.stats;
const teamGames = games.filter(game => teamOfUser[game.player] === teamId);
const onBoard = onBoardByTeam[teamId] || 0;
const base = {
games: teamGames,
seasonAvg: seasonAvg,
onBoard: onBoard,
lost: stats.lost,
captured: stats.captured,
bonusPoints: stats.bonusPointSum,
};
// Projections are expressed as the change to the final, rounded score,
// so they line up with the number shown on the scoreboard.
const current = roundHalfUp(calculateScore(base));
// Adding a game to the season shifts x2 for everyone, so the season
// baseline is perturbed alongside the team's own games.
const withGame = games.concat([PROJECTED_GAME]);
const completedGain = roundHalfUp(calculateScore(Object.assign({}, base, {
games: teamGames.concat([PROJECTED_GAME]),
seasonAvg: seasonAverage(withGame),
}))) - current;
const withDrop = games.concat([PROJECTED_DROP]);
const dropCost = roundHalfUp(calculateScore(Object.assign({}, base, {
games: teamGames.concat([PROJECTED_DROP]),
seasonAvg: seasonAverage(withDrop),
}))) - current;
const lostCost = roundHalfUp(calculateScore(Object.assign({}, base, {
onBoard: Math.max(onBoard - 1, 0),
lost: stats.lost + 1,
}))) - current;
const captureGain = roundHalfUp(calculateScore(Object.assign({}, base, {
captured: stats.captured + 1,
}))) - current;
// Penalties (F) are not present in the payload. As long as the
// recomputed score matches the site, assuming zero is safe; a
// mismatch means the formula or the payload shape has moved.
if (current !== team.score) {
console.log(LOG, 'score mismatch for', team.title, 'site:', team.score, 'calculated:', current);
}
return {
title: team.title,
score: team.score,
onBoard: onBoard,
lost: stats.lost,
captured: stats.captured,
completed: stats.completed,
dropped: stats.dropped,
completedGain: completedGain,
dropCost: dropCost,
lostCost: lostCost,
captureGain: captureGain,
};
});
}
// ----------------------------------------------------------------- render
const ICONS = {
// Current facts are drawn inside a square frame...
onBoard: '<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8" cy="8" r="3" fill="currentColor"/>',
lost: '<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><path d="M10 6L6 10M6 6L10 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5"/>',
captured: '<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5"/><path d="M8 4V12M4 8H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
completed: '<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><path d="M11 6L7 10L5 8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
dropped: '<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><path d="M10 6L6 10M6 6L10 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
// ...projections are drawn without one.
completedGain: '<path d="M13.5 4L6 11.5L2.5 8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
dropCost: '<path d="M13.5 2.5L2.5 13.5M2.5 2.5L13.5 13.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
lostCost: '<circle cx="8" cy="8" r="3.5" stroke="currentColor" stroke-width="2"/><path d="M2.5 2.5L13.5 13.5M13.5 2.5L2.5 13.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
captureGain: '<circle cx="8" cy="8" r="4" stroke="currentColor" stroke-width="2"/><path d="M8 2.5V13.5M2.5 8H13.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
};
const FIELDS = [
{ key: 'onBoard', color: '#a78bfa', title: 'Шашек на доске' },
{ key: 'lost', color: '#f87171', title: 'Потеряно шашек' },
{ key: 'captured', color: '#72bd9b', title: 'Забрано шашек' },
{ key: 'completed', color: '#4ade80', title: 'Пройдено игр' },
{ key: 'dropped', color: '#f87171', title: 'Дропнуто игр', gap: true },
{ key: 'completedGain', color: '#4ade80', title: 'Закрытая игра на 4 часа даст', signed: true },
{ key: 'dropCost', color: '#fb923c', title: 'Дроп отнимет', signed: true },
{ key: 'lostCost', color: '#fb923c', title: 'Потерянная шашка отнимет', signed: true },
{ key: 'captureGain', color: '#4ade80', title: 'Съеденная шашка даст', signed: true },
];
const STATS_CLASS = 'rgg-team-stats';
// Shared by the chips and by the hidden probe that measures them, so slot
// widths are taken under exactly the font the values render in.
const VALUE_FONT = 'font-size: 0.9rem; font-variant-numeric: tabular-nums;';
function formatValue(value, signed) {
if (!signed) return String(value);
return value > 0 ? `+${value}` : String(value);
}
// Width of each value slot, taken from the widest value that column actually
// holds. Sizing per column means a column of "+3"/"+1" reserves no room for
// a sign it will never show, while a column holding "-6" and "0" reserves
// exactly one.
//
// The widths are measured rather than counted in characters: a minus is
// narrower than a digit (5px against 7px in the site font), so counting
// characters would leave a visible gap next to the icon.
function measureValueWidths(allStats) {
const probe = document.createElement('span');
probe.style.cssText = `position: absolute; visibility: hidden; white-space: pre; ${VALUE_FONT}`;
document.body.appendChild(probe);
const widths = {};
FIELDS.forEach(field => {
let widest = 0;
allStats.forEach(stats => {
probe.textContent = formatValue(stats[field.key], field.signed);
widest = Math.max(widest, probe.getBoundingClientRect().width);
});
widths[field.key] = Math.ceil(widest);
});
probe.remove();
return widths;
}
function buildStatsElement(stats, widths) {
const element = document.createElement('div');
element.className = STATS_CLASS;
// margin-left pushes the block to the right end of the score line. When
// the line runs out of room these chips wrap rather than squeezing the
// team score, which is why they stay compact.
// tabular-nums keeps digits the same width, so equal-width value slots
// line the columns up across teams.
element.style.cssText = `display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; margin-left: auto; line-height: 1; ${VALUE_FONT}`;
element.innerHTML = FIELDS.map(field => {
const cellStyle = [
'display: inline-flex',
'align-items: center',
'gap: 2px',
'white-space: nowrap',
`color: ${field.color}`,
field.gap ? 'margin-right: 5px' : '',
].filter(Boolean).join('; ');
// Right alignment inside a fixed slot is what lines the columns up:
// an unsigned "0" sits under the digit of a "-1", with the minus
// hanging into the reserved space.
const valueStyle = `min-width: ${widths[field.key]}px; text-align: right;`;
return `<span style="${cellStyle}" title="${field.title}">` +
`<svg width="12" height="12" viewBox="0 0 16 16" fill="none" style="display: block; flex: none;">${ICONS[field.key]}</svg>` +
`<span style="${valueStyle}">${formatValue(stats[field.key], field.signed)}</span>` +
'</span>';
}).join('');
return element;
}
function findTeamRows() {
const heading = [...document.querySelectorAll('h6')]
.find(element => element.textContent.includes('Команды'));
if (!heading || !heading.parentElement) return [];
const list = [...heading.parentElement.children].find(child => child !== heading);
if (!list) return [];
return [...list.children];
}
function render(teamStats) {
const rows = findTeamRows();
if (!rows.length) return false;
const byTitle = {};
teamStats.forEach(stats => {
byTitle[stats.title] = stats;
});
// Slot widths come from every team at once, so each column is sized the
// same in all rows.
const widths = measureValueWidths(teamStats);
let rendered = 0;
rows.forEach(row => {
const nameContainer = row.querySelector('.inline');
if (!nameContainer) return;
const stats = byTitle[nameContainer.textContent.trim()];
if (!stats) return;
const existing = row.querySelector(`.${STATS_CLASS}`);
if (existing) existing.remove();
// Share the line that already shows the team score, rather than
// adding a row of its own.
const scoreLine = [...row.children].find(child => child.textContent.includes('очк'));
if (scoreLine) {
// Keep "146 очков" on one line; the injected chips give way first.
scoreLine.style.whiteSpace = 'nowrap';
}
(scoreLine || row).appendChild(buildStatsElement(stats, widths));
rendered++;
});
return rendered > 0;
}
// ------------------------------------------------------------------- init
function init() {
const season = parseSeason();
if (!season) return;
const teamStats = buildTeamStats(season);
console.log(LOG, 'parsed team data');
console.table(teamStats);
let observer = null;
// React re-renders the team list (the page also hits a hydration
// mismatch on load), which drops injected nodes. Reattach on every
// mutation, with the observer detached during the write so it does not
// react to its own output.
const paint = () => {
if (observer) observer.disconnect();
const ok = render(teamStats);
if (observer) observer.observe(document.body, { childList: true, subtree: true });
return ok;
};
observer = new MutationObserver(paint);
paint();
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment