Commit 34f9459c authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: rated games now actually change ratings

No completed match in production has ever had white_rating_after or
rating_change_white written, and 272 of the 290 profiles were still sitting on
the default 1200. The complete_match database function does not touch the rating
columns, and game.php had calculateElo() and getRatingColumn() helpers that
nothing ever called.

Elo is now computed in applyRatings(), called from finaliseMatch() after its
conditional write has succeeded — so a game can never be rated twice. It writes
both ratings before and after and the deltas onto the match, updates the correct
per-time-control column on each profile along with the game/win/loss/draw and
streak counters, and records a rating_history row per player.

This matters beyond the leaderboard: Swiss seeding orders players by rating, so
with every rating pinned at 1200 the top-half/bottom-half split in round 1 was
arbitrary.

Bot games and aborted games are never rated, and a match with the same player on
both sides is refused outright.

Covered by e2e sections 18 and 19.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 16a760f7
...@@ -89,7 +89,7 @@ if (is_array($running) && !isset($running['error'])) { ...@@ -89,7 +89,7 @@ if (is_array($running) && !isset($running['error'])) {
'id' => 'eq.' . $matchId, 'id' => 'eq.' . $matchId,
'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,' 'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,'
. 'white_time_remaining_ms,black_time_remaining_ms,started_at,updated_at,' . 'white_time_remaining_ms,black_time_remaining_ms,started_at,updated_at,'
. 'tournament_id,tournament_round,metadata', . 'tournament_id,tournament_round,metadata,time_control,is_rated,match_type,bot_id',
'limit' => 1, 'limit' => 1,
]); ]);
$match = (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null; $match = (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null;
......
...@@ -167,6 +167,8 @@ function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = nu ...@@ -167,6 +167,8 @@ function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = nu
'p_reason' => $derived['reason'], 'p_reason' => $derived['reason'],
]); ]);
applyRatings($sdb, $match, $derived);
mpLog($matchId, $match['game_key'] ?? 'chess', $actorId ?? ($match['white_player_id'] ?? ''), 'match_completed', [ mpLog($matchId, $match['game_key'] ?? 'chess', $actorId ?? ($match['white_player_id'] ?? ''), 'match_completed', [
'result' => $derived['result'], 'result' => $derived['result'],
'reason' => $derived['reason'], 'reason' => $derived['reason'],
...@@ -258,7 +260,7 @@ function handleGameMove($db, string $userId, array $input): void { ...@@ -258,7 +260,7 @@ function handleGameMove($db, string $userId, array $input): void {
'id' => 'eq.' . $matchId, 'id' => 'eq.' . $matchId,
'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,' 'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,'
. 'white_time_remaining_ms,black_time_remaining_ms,increment_ms,updated_at,started_at,' . 'white_time_remaining_ms,black_time_remaining_ms,increment_ms,updated_at,started_at,'
. 'tournament_id,tournament_round,metadata', . 'tournament_id,tournament_round,metadata,time_control,is_rated,match_type,bot_id',
'limit' => 1, 'limit' => 1,
]); ]);
$match = (is_array($match) && !empty($match) && !isset($match['error'])) ? $match[0] : null; $match = (is_array($match) && !empty($match) && !isset($match['error'])) ? $match[0] : null;
...@@ -405,7 +407,8 @@ function loadMatchForPlayer($sdb, string $matchId, string $userId, bool $mustBeO ...@@ -405,7 +407,8 @@ function loadMatchForPlayer($sdb, string $matchId, string $userId, bool $mustBeO
'id' => 'eq.' . $matchId, 'id' => 'eq.' . $matchId,
'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,' 'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,'
. 'white_time_remaining_ms,black_time_remaining_ms,increment_ms,started_at,updated_at,' . 'white_time_remaining_ms,black_time_remaining_ms,increment_ms,started_at,updated_at,'
. 'tournament_id,tournament_round,metadata,time_control,game_state,result', . 'tournament_id,tournament_round,metadata,time_control,game_state,result,'
. 'is_rated,match_type,bot_id',
'limit' => 1, 'limit' => 1,
]); ]);
$match = (is_array($matches) && !empty($matches) && !isset($matches['error'])) ? $matches[0] : null; $match = (is_array($matches) && !empty($matches) && !isset($matches['error'])) ? $matches[0] : null;
...@@ -591,13 +594,104 @@ function normaliseEndReason(?string $reason, ?string $legacyResult): string { ...@@ -591,13 +594,104 @@ function normaliseEndReason(?string $reason, ?string $legacyResult): string {
return 'unknown'; return 'unknown';
} }
function calculateElo(int $playerRating, int $opponentRating, float $score): int { /**
$k = 32; * Apply Elo for a finished rated game.
if ($playerRating > 2400) $k = 16; *
elseif ($playerRating > 2000) $k = 24; * The complete_match database function does not touch the rating columns — no
* completed match in production has ever had white_rating_after or
* rating_change_white written, and 272 of 290 players were still sitting on the
* default 1200. Ratings are the basis of Swiss seeding, so they are computed
* here where the behaviour is visible and testable.
*
* Called only from finaliseMatch(), after its conditional write has succeeded,
* so a game can never be rated twice.
*/
function applyRatings($sdb, array $match, array $derived): void {
if (empty($match['is_rated'])) return;
if (($match['match_type'] ?? '') === 'bot' || !empty($match['bot_id'])) return;
$whiteId = $match['white_player_id'] ?? null;
$blackId = $match['black_player_id'] ?? null;
if (!$whiteId || !$blackId || $whiteId === $blackId) return;
$timeControl = $match['time_control'] ?? 'rapid_10_0';
$column = getRatingColumn($timeControl);
$type = getTimeControlType($timeControl);
$profiles = $sdb->get('profiles', [
'id' => 'in.(' . $whiteId . ',' . $blackId . ')',
'select' => "id,{$column},games_played,total_games_played,total_wins,total_losses,total_draws,win_streak,best_win_streak",
]);
if (!is_array($profiles) || isset($profiles['error']) || count($profiles) < 2) return;
$byId = [];
foreach ($profiles as $p) $byId[$p['id']] = $p;
if (!isset($byId[$whiteId], $byId[$blackId])) return;
$whiteBefore = (int)($byId[$whiteId][$column] ?? 1200);
$blackBefore = (int)($byId[$blackId][$column] ?? 1200);
// 1 / 0.5 / 0 from White's point of view.
$whiteScore = $derived['winner'] === 'white' ? 1.0 : ($derived['winner'] === 'black' ? 0.0 : 0.5);
if ($derived['result'] === 'aborted') return; // an aborted game is not rated
$whiteAfter = calculateElo($whiteBefore, $blackBefore, $whiteScore);
$blackAfter = calculateElo($blackBefore, $whiteBefore, 1.0 - $whiteScore);
$sdb->update('matches', [
'white_rating_before' => $whiteBefore,
'black_rating_before' => $blackBefore,
'white_rating_after' => $whiteAfter,
'black_rating_after' => $blackAfter,
'rating_change_white' => $whiteAfter - $whiteBefore,
'rating_change_black' => $blackAfter - $blackBefore,
], ['id' => 'eq.' . $match['id']]);
foreach ([[$whiteId, $whiteBefore, $whiteAfter, $whiteScore, $blackId, $blackBefore],
[$blackId, $blackBefore, $blackAfter, 1.0 - $whiteScore, $whiteId, $whiteBefore]] as
[$pid, $before, $after, $score, $oppId, $oppRating]) {
$prof = $byId[$pid];
$won = $score === 1.0;
$lost = $score === 0.0;
$sdb->update('profiles', [
$column => $after,
'games_played' => (int)($prof['games_played'] ?? 0) + 1,
'total_games_played' => (int)($prof['total_games_played'] ?? 0) + 1,
'total_wins' => (int)($prof['total_wins'] ?? 0) + ($won ? 1 : 0),
'total_losses' => (int)($prof['total_losses'] ?? 0) + ($lost ? 1 : 0),
'total_draws' => (int)($prof['total_draws'] ?? 0) + (!$won && !$lost ? 1 : 0),
'win_streak' => $won ? (int)($prof['win_streak'] ?? 0) + 1 : 0,
'best_win_streak' => max((int)($prof['best_win_streak'] ?? 0), $won ? (int)($prof['win_streak'] ?? 0) + 1 : 0),
], ['id' => 'eq.' . $pid]);
$sdb->insert('rating_history', [
'player_id' => $pid,
'game_key' => $match['game_key'] ?? 'chess',
'time_control_type' => $type,
'rating_before' => $before,
'rating_after' => $after,
'rating_change' => $after - $before,
'match_id' => $match['id'],
'opponent_id' => $oppId,
'opponent_rating' => $oppRating,
'result' => $won ? 'win' : ($lost ? 'loss' : 'draw'),
'k_factor' => eloKFactor($before),
]);
}
}
/** FIDE-style K: smaller as a player's rating rises. */
function eloKFactor(int $rating): int {
if ($rating > 2400) return 16;
if ($rating > 2000) return 24;
return 32;
}
function calculateElo(int $playerRating, int $opponentRating, float $score): int {
$k = eloKFactor($playerRating);
$expected = 1.0 / (1.0 + pow(10, ($opponentRating - $playerRating) / 400.0)); $expected = 1.0 / (1.0 + pow(10, ($opponentRating - $playerRating) / 400.0));
$newRating = round($playerRating + $k * ($score - $expected)); $newRating = (int)round($playerRating + $k * ($score - $expected));
return max(100, $newRating); return max(100, $newRating);
} }
......
...@@ -10,6 +10,11 @@ CREATE TABLE profiles ( ...@@ -10,6 +10,11 @@ CREATE TABLE profiles (
display_name text, username text, avatar_url text, display_name text, username text, avatar_url text,
elo_rapid int DEFAULT 1200, elo_blitz int DEFAULT 1200, elo_rapid int DEFAULT 1200, elo_blitz int DEFAULT 1200,
elo_bullet int DEFAULT 1200, elo_classical int DEFAULT 1200, elo_bullet int DEFAULT 1200, elo_classical int DEFAULT 1200,
games_played int DEFAULT 0, total_games_played int DEFAULT 0,
total_wins int DEFAULT 0, total_losses int DEFAULT 0, total_draws int DEFAULT 0,
win_streak int DEFAULT 0, best_win_streak int DEFAULT 0,
xp int DEFAULT 0, level int DEFAULT 1,
is_banned bool DEFAULT false, ban_expires_at timestamptz, is_admin bool DEFAULT false,
created_at timestamptz DEFAULT now() created_at timestamptz DEFAULT now()
); );
...@@ -56,6 +61,7 @@ CREATE TABLE matches ( ...@@ -56,6 +61,7 @@ CREATE TABLE matches (
starting_fen text, current_fen text, pgn text, starting_fen text, current_fen text, pgn text,
moves jsonb DEFAULT '[]'::jsonb, move_count int DEFAULT 0, moves jsonb DEFAULT '[]'::jsonb, move_count int DEFAULT 0,
game_state jsonb DEFAULT '{}'::jsonb, game_state jsonb DEFAULT '{}'::jsonb,
white_rating_before int, black_rating_before int,
white_rating_after int, black_rating_after int, white_rating_after int, black_rating_after int,
rating_change_white int, rating_change_black int, rating_change_white int, rating_change_black int,
bot_id text, is_rated bool DEFAULT false, bot_id text, is_rated bool DEFAULT false,
...@@ -64,6 +70,14 @@ CREATE TABLE matches ( ...@@ -64,6 +70,14 @@ CREATE TABLE matches (
metadata jsonb DEFAULT '{}'::jsonb metadata jsonb DEFAULT '{}'::jsonb
); );
CREATE TABLE rating_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
player_id uuid, game_key text, time_control_type text,
rating_before int, rating_after int, rating_change int,
match_id uuid, opponent_id uuid, opponent_rating int,
result text, k_factor int, created_at timestamptz DEFAULT now()
);
CREATE TABLE mp_log ( CREATE TABLE mp_log (
id bigserial PRIMARY KEY, ts timestamptz DEFAULT now(), id bigserial PRIMARY KEY, ts timestamptz DEFAULT now(),
match_id uuid, game_key text, player_id uuid, event text, payload jsonb match_id uuid, game_key text, player_id uuid, event text, payload jsonb
......
...@@ -67,14 +67,6 @@ sql(`CREATE TABLE IF NOT EXISTS admin_users (id uuid PRIMARY KEY)`); ...@@ -67,14 +67,6 @@ sql(`CREATE TABLE IF NOT EXISTS admin_users (id uuid PRIMARY KEY)`);
sql(`CREATE TABLE IF NOT EXISTS achievements (id text PRIMARY KEY, "condition" jsonb, coins_reward int, xp_reward int)`); sql(`CREATE TABLE IF NOT EXISTS achievements (id text PRIMARY KEY, "condition" jsonb, coins_reward int, xp_reward int)`);
sql(`CREATE TABLE IF NOT EXISTS player_achievements (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), player_id uuid, achievement_id text, progress int, completed bool, completed_at timestamptz)`); sql(`CREATE TABLE IF NOT EXISTS player_achievements (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), player_id uuid, achievement_id text, progress int, completed bool, completed_at timestamptz)`);
sql(`DELETE FROM matchmaking_queue;`); sql(`DELETE FROM matchmaking_queue;`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS is_banned bool DEFAULT false`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS ban_expires_at timestamptz`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS is_admin bool DEFAULT false`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS games_played int DEFAULT 0`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS total_wins int DEFAULT 0`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS win_streak int DEFAULT 0`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS xp int DEFAULT 0`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS level int DEFAULT 1`);
P.forEach((id, i) => sql( P.forEach((id, i) => sql(
`INSERT INTO profiles (id, display_name, username, elo_rapid, elo_blitz, is_admin) VALUES ('${id}','Player ${i + 1}','p${i + 1}',${1900 - i * 60},${1850 - i * 50}, ${i === 0})` `INSERT INTO profiles (id, display_name, username, elo_rapid, elo_blitz, is_admin) VALUES ('${id}','Player ${i + 1}','p${i + 1}',${1900 - i * 60},${1850 - i * 50}, ${i === 0})`
)); ));
...@@ -434,6 +426,52 @@ r = await call('matchmaking.php', { action: 'status', game_key: 'chess' }, P[2]) ...@@ -434,6 +426,52 @@ r = await call('matchmaking.php', { action: 'status', game_key: 'chess' }, P[2])
check('a live claim is still delivered', r.data.match_id === liveM.match_id, JSON.stringify(r.data)); check('a live claim is still delivered', r.data.match_id === liveM.match_id, JSON.stringify(r.data));
check('and it comes with the opposite colour', r.data.color !== liveM.color, `${r.data.color} / ${liveM.color}`); check('and it comes with the opposite colour', r.data.color !== liveM.color, `${r.data.color} / ${liveM.color}`);
console.log('\n=== 18. rated games actually move ratings ===');
sql(`DELETE FROM matchmaking_queue; DELETE FROM rating_history;`);
sql(`UPDATE profiles SET elo_blitz = 1600 WHERE id='${P[0]}'`);
sql(`UPDATE profiles SET elo_blitz = 1400 WHERE id='${P[1]}'`);
// Snapshot the rapid rating: a blitz game must not touch it.
const rapidBefore = Number(sql(`SELECT elo_rapid FROM profiles WHERE id='${P[0]}'`));
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_3' }, P[0]);
const rm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_3' }, P[1])).data;
const rInfo = (await call('game.php', { action: 'get', match_id: rm.match_id }, P[0])).data;
const rWhite = rInfo.my_color === 'w' ? P[0] : P[1];
const rBlack = rWhite === P[0] ? P[1] : P[0];
// The lower-rated player resigns, so the favourite wins and gains a little.
await call('game.php', { action: 'resign', match_id: rm.match_id }, P[1]);
const rRow = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT white_rating_before, black_rating_before, white_rating_after, black_rating_after, rating_change_white, rating_change_black FROM matches WHERE id='${rm.match_id}') t`));
check('the match records both ratings before', rRow.white_rating_before !== null && rRow.black_rating_before !== null, JSON.stringify(rRow));
check('the match records both ratings after', rRow.white_rating_after !== null && rRow.black_rating_after !== null, JSON.stringify(rRow));
check('the rating changes are equal and opposite in sign',
Math.sign(rRow.rating_change_white) === -Math.sign(rRow.rating_change_black), JSON.stringify(rRow));
const winnerElo = Number(sql(`SELECT elo_blitz FROM profiles WHERE id='${P[0]}'`));
const loserElo = Number(sql(`SELECT elo_blitz FROM profiles WHERE id='${P[1]}'`));
check('the winner gained rating', winnerElo > 1600, `${winnerElo}`);
check('the loser lost rating', loserElo < 1400, `${loserElo}`);
check('the favourite gains less than 16 for beating a weaker player', winnerElo - 1600 < 16, `+${winnerElo - 1600}`);
check('a blitz game moves only the blitz rating',
Number(sql(`SELECT elo_rapid FROM profiles WHERE id='${P[0]}'`)) === rapidBefore,
`elo_rapid changed from ${rapidBefore}`);
const rh = Number(sql(`SELECT count(*) FROM rating_history WHERE match_id='${rm.match_id}'`));
check('rating history written for both players', rh === 2, `${rh} rows`);
const wl = sql(`SELECT total_wins FROM profiles WHERE id='${P[0]}'`);
check('the win is counted on the profile', Number(wl) >= 1, wl);
console.log('\n=== 19. bot games are never rated ===');
const botRes = await call('game.php', { action: 'start', game_key: 'chess', mode: 'bot', bot_id: 'amina', time_control: 'blitz_5_0' }, P[2]);
const botMatch = botRes.data.id;
check('a bot match is created', !!botMatch, JSON.stringify(botRes.data).slice(0, 150));
const beforeElo = Number(sql(`SELECT elo_blitz FROM profiles WHERE id='${P[2]}'`));
sql(`UPDATE matches SET is_rated = true WHERE id='${botMatch}'`); // even if flagged rated
await call('game.php', { action: 'resign', match_id: botMatch }, P[2]);
check('a bot game does not change rating',
Number(sql(`SELECT elo_blitz FROM profiles WHERE id='${P[2]}'`)) === beforeElo, 'rating moved');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
console.log(''); console.log('');
if (failures.length) { if (failures.length) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment