Commit 9b2a459c authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: draw offer race condition + add multiplayer logging

Root cause: draw offers stored in game_state were lost when a
concurrent move write did read→merge→write (non-atomic). Now uses
PostgreSQL jsonb || operator via merge_game_state() RPC for atomic
merges.

Also adds:
- mp_log table for server-side multiplayer event logging
- Frontend sessionStorage logging (sessionStorage.mp_log) for
  debugging draw offers, moves, and poll events
- Logging on match_created, move, resign, draw_accepted, game_state_merge
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 2f9d7d84
...@@ -120,30 +120,44 @@ function handleGameMove($db, string $userId, array $input): void { ...@@ -120,30 +120,44 @@ function handleGameMove($db, string $userId, array $input): void {
if (isset($input['white_time_remaining_ms'])) $update['white_time_remaining_ms'] = intval($input['white_time_remaining_ms']); if (isset($input['white_time_remaining_ms'])) $update['white_time_remaining_ms'] = intval($input['white_time_remaining_ms']);
if (isset($input['black_time_remaining_ms'])) $update['black_time_remaining_ms'] = intval($input['black_time_remaining_ms']); if (isset($input['black_time_remaining_ms'])) $update['black_time_remaining_ms'] = intval($input['black_time_remaining_ms']);
// Merge game_state instead of overwriting — preserves emotes, draw offers, etc. // game_state: use atomic merge via DB function (prevents race conditions with draw offers, emotes)
$gameStatePatch = null;
if (!empty($input['game_state'])) { if (!empty($input['game_state'])) {
$newState = is_string($input['game_state']) ? (json_decode($input['game_state'], true) ?: []) : $input['game_state']; $gameStatePatch = is_string($input['game_state']) ? (json_decode($input['game_state'], true) ?: []) : $input['game_state'];
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
$existing = [];
if (is_array($matches) && !empty($matches) && !isset($matches['error'])) {
$raw = $matches[0]['game_state'] ?? null;
if ($raw) {
if (is_string($raw)) {
$existing = json_decode($raw, true) ?: [];
} elseif (is_array($raw) && !array_is_list($raw)) {
$existing = $raw;
} else {
$existing = [];
}
} }
// If we only have game_state (no board columns), use atomic merge only
$hasBoardUpdate = isset($update['current_fen']) || isset($update['moves']) || isset($update['move_count']);
if ($gameStatePatch && !$hasBoardUpdate) {
// Pure game_state update (draw offer, emote, etc.) — use atomic merge
supabaseRpc('merge_game_state', [
'p_table' => 'matches',
'p_match_id' => $matchId,
'p_patch' => $gameStatePatch
]);
mpLog($matchId, 'chess', $userId, 'game_state_merge', $gameStatePatch);
jsonResponse(['success' => true]);
return;
} }
$merged = array_merge($existing, $newState);
$update['game_state'] = $merged; if ($gameStatePatch && $hasBoardUpdate) {
// Move + game_state: atomic merge game_state first, then update board columns
supabaseRpc('merge_game_state', [
'p_table' => 'matches',
'p_match_id' => $matchId,
'p_patch' => $gameStatePatch
]);
} }
$result = $sdb->update('matches', $update, ['id' => 'eq.' . $matchId]); $result = $sdb->update('matches', $update, ['id' => 'eq.' . $matchId]);
if (isset($result['error'])) jsonError($result['error']); if (isset($result['error'])) jsonError($result['error']);
mpLog($matchId, 'chess', $userId, 'move', [
'move_count' => $input['move_count'] ?? null,
'fen_short' => substr($input['fen'] ?? '', 0, 30)
]);
jsonResponse(['success' => true]); jsonResponse(['success' => true]);
} }
...@@ -167,6 +181,7 @@ function handleResign($db, string $userId, array $input): void { ...@@ -167,6 +181,7 @@ function handleResign($db, string $userId, array $input): void {
'updated_at' => date('c') 'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]); ], ['id' => 'eq.' . $matchId]);
mpLog($matchId, 'chess', $userId, 'resign', ['result' => $result]);
jsonResponse(['result' => $result]); jsonResponse(['result' => $result]);
} }
...@@ -188,6 +203,7 @@ function handleDraw($db, string $userId, array $input): void { ...@@ -188,6 +203,7 @@ function handleDraw($db, string $userId, array $input): void {
'game_state' => ['draw_accepted' => true] 'game_state' => ['draw_accepted' => true]
], ['id' => 'eq.' . $matchId]); ], ['id' => 'eq.' . $matchId]);
mpLog($matchId, 'chess', $userId, 'draw_accepted', []);
jsonResponse(['result' => 'draw']); jsonResponse(['result' => 'draw']);
} }
...@@ -444,3 +460,13 @@ function handleFindActiveMatch(string $userId, array $input): void { ...@@ -444,3 +460,13 @@ function handleFindActiveMatch(string $userId, array $input): void {
jsonResponse(['match_id' => null]); jsonResponse(['match_id' => null]);
} }
function mpLog(string $matchId, string $gameKey, string $playerId, string $event, $payload = []): void {
supabaseRpc('log_mp_event', [
'p_match_id' => $matchId,
'p_game_key' => $gameKey,
'p_player_id' => $playerId,
'p_event' => $event,
'p_payload' => json_encode(is_array($payload) ? $payload : [])
]);
}
...@@ -156,6 +156,10 @@ function handleQueue($db, string $userId, array $input): void { ...@@ -156,6 +156,10 @@ function handleQueue($db, string $userId, array $input): void {
'match_id' => $matchId 'match_id' => $matchId
], ['id' => 'eq.' . $opponent['id']]); ], ['id' => 'eq.' . $opponent['id']]);
mpLog($matchId, $gameKey, $userId, 'match_created', [
'white' => $whiteId, 'black' => $blackId, 'time_control' => $timeControl
]);
$myColor = $isWhite ? 'w' : 'b'; $myColor = $isWhite ? 'w' : 'b';
jsonResponse(['match_id' => $matchId, 'color' => $myColor, 'opponent_id' => $opponent['player_id']]); jsonResponse(['match_id' => $matchId, 'color' => $myColor, 'opponent_id' => $opponent['player_id']]);
return; return;
...@@ -208,3 +212,13 @@ function handleDequeue($db, string $userId, array $input): void { ...@@ -208,3 +212,13 @@ function handleDequeue($db, string $userId, array $input): void {
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]); $sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
jsonResponse(['success' => true]); jsonResponse(['success' => true]);
} }
function mpLog(string $matchId, string $gameKey, string $playerId, string $event, $payload = []): void {
supabaseRpc('log_mp_event', [
'p_match_id' => $matchId,
'p_game_key' => $gameKey,
'p_player_id' => $playerId,
'p_event' => $event,
'p_payload' => is_array($payload) ? $payload : []
]);
}
...@@ -243,10 +243,12 @@ export function mountGame(el, params) { ...@@ -243,10 +243,12 @@ export function mountGame(el, params) {
} }
} else if (gameState.mode === 'live' && gameState.matchId) { } else if (gameState.mode === 'live' && gameState.matchId) {
const userId = store.get('auth.userId'); const userId = store.get('auth.userId');
const drawT = Date.now();
mpLogFront('draw_offer_sent', { to: 'opponent', draw_offer_t: drawT });
net.post('game.php', { net.post('game.php', {
action: 'move', action: 'move',
match_id: gameState.matchId, match_id: gameState.matchId,
game_state: JSON.stringify({ draw_offer: userId, draw_offer_t: Date.now() }) game_state: JSON.stringify({ draw_offer: userId, draw_offer_t: drawT })
}); });
const msg = document.createElement('div'); const msg = document.createElement('div');
msg.textContent = 'تم إرسال عرض التعادل...'; msg.textContent = 'تم إرسال عرض التعادل...';
...@@ -585,10 +587,20 @@ function getEndReason() { ...@@ -585,10 +587,20 @@ function getEndReason() {
// ===== LIVE MULTIPLAYER ===== // ===== LIVE MULTIPLAYER =====
let lastKnownMoveCount = 0; let lastKnownMoveCount = 0;
function mpLogFront(event, data) {
try {
const logs = JSON.parse(sessionStorage.getItem('mp_log') || '[]');
logs.push({ t: Date.now(), e: event, ...data });
if (logs.length > 100) logs.splice(0, logs.length - 100);
sessionStorage.setItem('mp_log', JSON.stringify(logs));
} catch(e) {}
}
async function sendLiveMove(el) { async function sendLiveMove(el) {
if (!gameState.matchId) return; if (!gameState.matchId) return;
lastKnownMoveCount = gameState.moveCount; lastKnownMoveCount = gameState.moveCount;
const lastMove = gameState.moveHistory[gameState.moveHistory.length - 1]; const lastMove = gameState.moveHistory[gameState.moveHistory.length - 1];
mpLogFront('move_sent', { mc: gameState.moveCount, san: lastMove?.san });
try { try {
await net.post('game.php', { await net.post('game.php', {
action: 'move', action: 'move',
...@@ -602,7 +614,9 @@ async function sendLiveMove(el) { ...@@ -602,7 +614,9 @@ async function sendLiveMove(el) {
last_move: lastMove ? { from: lastMove.from, to: lastMove.to, san: lastMove.san, promotion: lastMove.promotion || null } : null last_move: lastMove ? { from: lastMove.from, to: lastMove.to, san: lastMove.san, promotion: lastMove.promotion || null } : null
}) })
}); });
} catch (e) {} } catch (e) {
mpLogFront('move_send_error', { error: e.message });
}
} }
function handleLivePollData(el, data) { function handleLivePollData(el, data) {
...@@ -626,6 +640,9 @@ function handleLivePollData(el, data) { ...@@ -626,6 +640,9 @@ function handleLivePollData(el, data) {
checkDrawOffer(el, data.game_state, myId); checkDrawOffer(el, data.game_state, myId);
checkDrawResponse(el, data.game_state, myId); checkDrawResponse(el, data.game_state, myId);
// Multiplayer event logging (stored in sessionStorage for debugging)
mpLogFront('poll', { mc: data.move_count, gs_keys: data.game_state ? Object.keys(typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state) : [] });
// New move arrived — only process when waiting for opponent // New move arrived — only process when waiting for opponent
if (!gameState.isPlayerTurn && data.move_count > lastKnownMoveCount) { if (!gameState.isPlayerTurn && data.move_count > lastKnownMoveCount) {
lastKnownMoveCount = data.move_count; lastKnownMoveCount = data.move_count;
...@@ -797,10 +814,15 @@ let lastDrawOfferHandled = 0; ...@@ -797,10 +814,15 @@ let lastDrawOfferHandled = 0;
function checkDrawOffer(el, rawGameState, myId) { function checkDrawOffer(el, rawGameState, myId) {
if (!rawGameState || gameState.gameOver) return; if (!rawGameState || gameState.gameOver) return;
const gs = typeof rawGameState === 'string' ? JSON.parse(rawGameState) : rawGameState; const gs = typeof rawGameState === 'string' ? JSON.parse(rawGameState) : rawGameState;
if (!gs || !gs.draw_offer || gs.draw_offer === myId) return; if (!gs || !gs.draw_offer) return;
if (gs.draw_offer === myId) {
mpLogFront('draw_offer_skip_own', { draw_offer: gs.draw_offer });
return;
}
const offerTime = gs.draw_offer_t || 0; const offerTime = gs.draw_offer_t || 0;
if (offerTime <= lastDrawOfferHandled) return; if (offerTime <= lastDrawOfferHandled) return;
lastDrawOfferHandled = offerTime; lastDrawOfferHandled = offerTime;
mpLogFront('draw_offer_received', { from: gs.draw_offer, draw_offer_t: offerTime });
// Show accept/deny UI // Show accept/deny UI
const existing = el.querySelector('#draw-offer-dialog'); const existing = el.querySelector('#draw-offer-dialog');
......
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