Last active
July 25, 2026 06:31
-
-
Save strayge/67aa73e55e1454b22c57a150aec16680 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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