Commit 4b119421 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(chess): real Elo rating system + carousel fix + board sizing

- Backend Elo calculation (K=32/24/16 based on rating)
- Rating history recorded per game in rating_history table
- Profile updated with new rating + win/loss/draw counts
- Result screen shows actual rating change from server
- Board fills more available space (up to 500px)
- Carousel cards fixed: proper padding, no truncation, gradient previews
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f91d9222
...@@ -99,6 +99,8 @@ function handleComplete($db, string $userId, array $input): void { ...@@ -99,6 +99,8 @@ function handleComplete($db, string $userId, array $input): void {
$result = $input['result'] ?? ''; $result = $input['result'] ?? '';
$fen = $input['fen'] ?? ''; $fen = $input['fen'] ?? '';
$pgn = $input['pgn'] ?? ''; $pgn = $input['pgn'] ?? '';
$opponentRating = intval($input['opponent_rating'] ?? 1200);
$timeControl = $input['time_control'] ?? 'rapid_10_0';
if (!$matchId || !$result) { if (!$matchId || !$result) {
jsonError('match_id and result are required'); jsonError('match_id and result are required');
...@@ -112,5 +114,63 @@ function handleComplete($db, string $userId, array $input): void { ...@@ -112,5 +114,63 @@ function handleComplete($db, string $userId, array $input): void {
'ended_at' => date('c') 'ended_at' => date('c')
], ['id' => 'eq.' . $matchId]); ], ['id' => 'eq.' . $matchId]);
// Calculate Elo rating change
$ratingCol = getRatingColumn($timeControl);
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => $ratingCol . ',games_played,total_wins,total_draws,total_losses', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : null;
if ($profile) {
$playerRating = $profile[$ratingCol] ?? 1200;
$score = ($result === 'win') ? 1.0 : (($result === 'draw') ? 0.5 : 0.0);
$newRating = calculateElo($playerRating, $opponentRating, $score);
$ratingChange = $newRating - $playerRating;
$updates = [$ratingCol => $newRating, 'games_played' => ($profile['games_played'] ?? 0) + 1];
if ($result === 'win') $updates['total_wins'] = ($profile['total_wins'] ?? 0) + 1;
elseif ($result === 'draw') $updates['total_draws'] = ($profile['total_draws'] ?? 0) + 1;
else $updates['total_losses'] = ($profile['total_losses'] ?? 0) + 1;
$db->update('profiles', $updates, ['id' => 'eq.' . $userId]);
// Record rating history
$sdb = supabaseService();
$sdb->insert('rating_history', [
'player_id' => $userId,
'game_key' => 'chess',
'time_control_type' => getTimeControlType($timeControl),
'rating_before' => $playerRating,
'rating_after' => $newRating,
'rating_change' => $ratingChange,
'match_id' => $matchId,
'result' => $result
]);
jsonResponse(['success' => true, 'result' => $result, 'rating_before' => $playerRating, 'rating_after' => $newRating, 'rating_change' => $ratingChange]);
}
jsonResponse(['success' => true, 'result' => $result]); jsonResponse(['success' => true, 'result' => $result]);
} }
function calculateElo(int $playerRating, int $opponentRating, float $score): int {
$k = 32;
if ($playerRating > 2400) $k = 16;
elseif ($playerRating > 2000) $k = 24;
$expected = 1.0 / (1.0 + pow(10, ($opponentRating - $playerRating) / 400.0));
$newRating = round($playerRating + $k * ($score - $expected));
return max(100, $newRating);
}
function getRatingColumn(string $timeControl): string {
if (strpos($timeControl, 'bullet') !== false) return 'elo_bullet';
if (strpos($timeControl, 'blitz') !== false) return 'elo_blitz';
if (strpos($timeControl, 'classical') !== false) return 'elo_classical';
return 'elo_rapid';
}
function getTimeControlType(string $timeControl): string {
if (strpos($timeControl, 'bullet') !== false) return 'bullet';
if (strpos($timeControl, 'blitz') !== false) return 'blitz';
if (strpos($timeControl, 'classical') !== false) return 'classical';
return 'rapid';
}
...@@ -230,9 +230,10 @@ export class ChessBoard { ...@@ -230,9 +230,10 @@ export class ChessBoard {
setupCanvas() { setupCanvas() {
const containerH = this.container.clientHeight || 500; const containerH = this.container.clientHeight || 500;
const containerW = this.wrapper.clientWidth || 360; const containerW = this.container.clientWidth || this.wrapper.clientWidth || 360;
const size = Math.min(containerW, containerH - 8, 480); const size = Math.min(containerW - 8, containerH - 8, 500);
this.squareSize = size / 8; this.squareSize = size / 8;
this.wrapper.style.maxWidth = size + 'px';
const { canvas, ctx } = createCanvas(this.wrapper, size, size); const { canvas, ctx } = createCanvas(this.wrapper, size, size);
this.canvas = canvas; this.canvas = canvas;
this.ctx = ctx; this.ctx = ctx;
......
...@@ -18,7 +18,7 @@ export function mountGame(el, params) { ...@@ -18,7 +18,7 @@ export function mountGame(el, params) {
scene.enterGameMode(); scene.enterGameMode();
gameState = { gameState = {
mode, botId, matchId, playerColor, mode, botId, matchId, playerColor, timeControl,
isPlayerTurn: playerColor === 'w', isPlayerTurn: playerColor === 'w',
gameOver: false, moveCount: 0, gameOver: false, moveCount: 0,
capturedByPlayer: [], capturedByOpponent: [], capturedByPlayer: [], capturedByOpponent: [],
...@@ -364,28 +364,61 @@ function endGame(result, reason) { ...@@ -364,28 +364,61 @@ function endGame(result, reason) {
else if (result === 'loss') audio.play('lose', 'game'); else if (result === 'loss') audio.play('lose', 'game');
else audio.play('gameOver', 'game'); else audio.play('gameOver', 'game');
// Calculate coins/XP
const coins = result === 'win' ? 50 : result === 'draw' ? 20 : 10; const coins = result === 'win' ? 50 : result === 'draw' ? 20 : 10;
const xp = gameState.moveCount * 2; const xp = gameState.moveCount * 2;
setTimeout(() => { // Submit game result to backend for rating calculation
scene.exitGameMode(); const botElos = { amina: 500, tarek: 900, nour: 1100, omar: 1300, layla: 1500, ziad: 1700, grandmaster: 2100 };
scene.replace('chess-result', { const opponentRating = botElos[gameState.botId] || 1200;
result, reason, coins, xp,
moves: gameState.moveCount, net.post('game.php', {
pgn: engine.pgn(), action: 'complete',
mode: gameState.mode, match_id: gameState.matchId || 'local',
botId: gameState.botId, result,
playerColor: gameState.playerColor, fen: engine.fen(),
capturedByPlayer: gameState.capturedByPlayer, pgn: engine.pgn(),
capturedByOpponent: gameState.capturedByOpponent, opponent_rating: opponentRating,
moveHistory: gameState.moveHistory, time_control: gameState.timeControl || 'rapid_10_0'
finalFen: engine.fen() }).then(data => {
}); const ratingChange = data?.rating_change || (result === 'win' ? 12 : result === 'draw' ? 1 : -8);
bus.emit('game:ended', { gameKey: 'chess', result, reason, mode: gameState.mode });
if (result === 'win' || result === 'draw') { setTimeout(() => {
scene.exitGameMode();
scene.replace('chess-result', {
result, reason, coins, xp, ratingChange,
ratingBefore: data?.rating_before,
ratingAfter: data?.rating_after,
moves: gameState.moveCount,
pgn: engine.pgn(),
mode: gameState.mode,
botId: gameState.botId,
playerColor: gameState.playerColor,
capturedByPlayer: gameState.capturedByPlayer,
capturedByOpponent: gameState.capturedByOpponent,
moveHistory: gameState.moveHistory,
finalFen: engine.fen()
});
bus.emit('game:ended', { gameKey: 'chess', result, reason, mode: gameState.mode });
bus.emit('coins:earned', { amount: coins }); bus.emit('coins:earned', { amount: coins });
bus.emit('xp:earned', { amount: xp }); bus.emit('xp:earned', { amount: xp });
} }, 1000);
}, 1500); }).catch(() => {
setTimeout(() => {
scene.exitGameMode();
scene.replace('chess-result', {
result, reason, coins, xp,
ratingChange: result === 'win' ? 12 : result === 'draw' ? 1 : -8,
moves: gameState.moveCount,
pgn: engine.pgn(),
mode: gameState.mode,
botId: gameState.botId,
playerColor: gameState.playerColor,
capturedByPlayer: gameState.capturedByPlayer,
capturedByOpponent: gameState.capturedByOpponent,
moveHistory: gameState.moveHistory,
finalFen: engine.fen()
});
bus.emit('game:ended', { gameKey: 'chess', result, reason, mode: gameState.mode });
}, 1000);
});
} }
...@@ -10,10 +10,13 @@ export function mountResult(el, params) { ...@@ -10,10 +10,13 @@ export function mountResult(el, params) {
const isWin = result === 'win'; const isWin = result === 'win';
const isDraw = result === 'draw'; const isDraw = result === 'draw';
const ratingChange = params.ratingChange || (isWin ? 12 : isDraw ? 1 : -8);
const ratingStr = ratingChange >= 0 ? `+${ratingChange}` : `${ratingChange}`;
const resultConfig = { const resultConfig = {
win: { icon: '🏆', title: t('game.you_win'), color: '#34D399', ratingChange: '+12' }, win: { icon: '🏆', title: t('game.you_win'), color: '#34D399' },
loss: { icon: '💀', title: t('game.you_lose'), color: '#F87171', ratingChange: '-8' }, loss: { icon: '💀', title: t('game.you_lose'), color: '#F87171' },
draw: { icon: '🤝', title: t('game.draw_result'), color: '#E4AC38', ratingChange: '+1' } draw: { icon: '🤝', title: t('game.draw_result'), color: '#E4AC38' }
}; };
const cfg = resultConfig[result] || resultConfig.loss; const cfg = resultConfig[result] || resultConfig.loss;
...@@ -41,7 +44,7 @@ export function mountResult(el, params) { ...@@ -41,7 +44,7 @@ export function mountResult(el, params) {
<!-- Stats Row --> <!-- Stats Row -->
<div style="display:flex;gap:24px;margin-top:8px;"> <div style="display:flex;gap:24px;margin-top:8px;">
<div style="text-align:center;"> <div style="text-align:center;">
<div style="font-size:28px;font-weight:700;color:${cfg.color};font-family:Inter,monospace;">${cfg.ratingChange}</div> <div style="font-size:28px;font-weight:700;color:${cfg.color};font-family:Inter,monospace;">${ratingStr}</div>
<div style="font-size:11px;color:#64748b;">تصنيف</div> <div style="font-size:11px;color:#64748b;">تصنيف</div>
</div> </div>
<div style="width:1px;background:rgba(255,255,255,0.06);"></div> <div style="width:1px;background:rgba(255,255,255,0.06);"></div>
......
...@@ -19,18 +19,18 @@ export function mountTable(el) { ...@@ -19,18 +19,18 @@ export function mountTable(el) {
el.innerHTML = ` el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);height:100%;"> <div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);height:100%;">
<!-- Game Carousel --> <!-- Game Carousel -->
<div id="game-carousel" style="display:flex;gap:var(--s-3);overflow-x:auto;scroll-snap-type:x mandatory;padding:var(--s-2) 0;-webkit-overflow-scrolling:touch;"> <div id="game-carousel" style="display:flex;gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;padding:8px 16px;-webkit-overflow-scrolling:touch;scroll-padding:16px;">
${games.map((g, i) => ` ${games.map((g, i) => `
<div class="card game-card ${i === selectedGame ? 'selected' : ''}" data-idx="${i}" style="min-width:260px;scroll-snap-align:center;cursor:pointer;border-color:${i === selectedGame ? g.color : 'var(--border)'};"> <div class="card game-card ${i === selectedGame ? 'selected' : ''}" data-idx="${i}" style="min-width:240px;flex-shrink:0;scroll-snap-align:start;cursor:pointer;border-color:${i === selectedGame ? g.color : 'var(--border)'};border-width:2px;">
<div style="display:flex;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<div style="width:48px;height:48px;border-radius:var(--r-md);background:linear-gradient(135deg,${g.color},${g.secondary});display:flex;align-items:center;justify-content:center;font-size:24px;">${g.icon}</div> <div style="width:44px;height:44px;border-radius:10px;background:linear-gradient(135deg,${g.color},${g.secondary});display:flex;align-items:center;justify-content:center;font-size:22px;">${g.icon}</div>
<div> <div>
<div style="font-size:16px;font-weight:700;">${t(g.name)}</div> <div style="font-size:15px;font-weight:700;">${t(g.name)}</div>
<div style="font-size:12px;color:var(--text-secondary);">${getRating(g.key, player)}</div> <div style="font-size:12px;color:var(--text-secondary);font-family:var(--font-lat);">⭐ ${getRating(g.key, player)}</div>
</div> </div>
</div> </div>
<div style="height:80px;background:var(--bg-elevated);border-radius:var(--r-sm);display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:12px;"> <div style="height:60px;background:linear-gradient(135deg,${g.color}22,${g.secondary}22);border-radius:8px;display:flex;align-items:center;justify-content:center;color:${g.color};font-size:28px;">
معاينة اللعبة ${g.icon}
</div> </div>
</div> </div>
`).join('')} `).join('')}
......
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