Commit c37f0571 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: realtime chess multiplayer — RLS bypass + live polling + opponent move sync

ROOT CAUSE: Supabase RLS on matchmaking_queue only allowed players to
see their OWN rows. Player A couldn't find Player B in the queue.

FIX: All matchmaking operations now use service_role key (bypasses RLS):
- handleQueue: searches ALL waiting players, not just own
- handleStatus: reads match details with service key
- handleDequeue: cleans up with service key
- Random color assignment (50/50 white/black)

Chess game live mode:
- NEW: sendLiveMove() — sends current FEN + move_count to server after player moves
- NEW: startLivePolling() — polls match row every 2s for opponent's move
- NEW: When opponent's move_count increases, loads new FEN and updates board
- NEW: Detects opponent resign/completion via status field
- If playing as black in live mode, waits for white's first move
- stopLivePolling() on game end

Flow:
1. Player A queues → inserted as 'waiting'
2. Player B queues → finds Player A, creates match, marks A as 'matched'
3. Player B gets match_id immediately (they're white or black randomly)
4. Player A polls status → finds 'matched' → gets match_id + color
5. Both enter chess-game with mode:'live' + matchId
6. Each player's moves update the match row
7. Opponent detects new move_count via polling
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c21445cb
...@@ -34,76 +34,95 @@ function handleQueue($db, string $userId, array $input): void { ...@@ -34,76 +34,95 @@ function handleQueue($db, string $userId, array $input): void {
$gameKey = $input['game_key'] ?? 'chess'; $gameKey = $input['game_key'] ?? 'chess';
$timeControl = $input['time_control'] ?? 'rapid_10_0'; $timeControl = $input['time_control'] ?? 'rapid_10_0';
$profile = $db->getOne('profiles', ['id' => 'eq.' . $userId, 'select' => 'elo_rapid,elo_blitz,elo_bullet']); // Use service key to bypass RLS for matchmaking operations
$sdb = supabaseService();
// Get player rating
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'elo_rapid,elo_blitz,elo_bullet', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : [];
$rating = $profile['elo_rapid'] ?? $profile['elo_blitz'] ?? 1200; $rating = $profile['elo_rapid'] ?? $profile['elo_blitz'] ?? 1200;
$existing = $db->get('matchmaking_queue', [ // Check if already in queue
$existing = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId, 'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting', 'status' => 'eq.waiting',
'select' => 'id' 'select' => 'id'
]); ]);
if (!empty($existing) && !isset($existing['error'])) { if (!empty($existing) && !isset($existing['error'])) {
jsonError('Already in queue'); // Already waiting — just check for opponent again
} else {
// Insert into queue first
$sdb->insert('matchmaking_queue', [
'player_id' => $userId,
'game_key' => $gameKey,
'time_control' => $timeControl,
'rating' => $rating,
'status' => 'waiting'
]);
} }
// Check for available opponent // Search for available opponent (service key bypasses RLS — can see ALL waiting players)
$opponents = $db->get('matchmaking_queue', [ $opponents = $sdb->get('matchmaking_queue', [
'game_key' => 'eq.' . $gameKey, 'game_key' => 'eq.' . $gameKey,
'time_control' => 'eq.' . $timeControl, 'time_control' => 'eq.' . $timeControl,
'status' => 'eq.waiting', 'status' => 'eq.waiting',
'player_id' => 'neq.' . $userId, 'player_id' => 'neq.' . $userId,
'select' => 'id,player_id,rating', 'select' => 'id,player_id,rating',
'order' => 'created_at.asc', 'order' => 'queued_at.asc',
'limit' => 1 'limit' => 1
]); ]);
if (!empty($opponents) && !isset($opponents['error']) && isset($opponents[0])) { if (!empty($opponents) && !isset($opponents['error']) && isset($opponents[0])) {
$opponent = $opponents[0]; $opponent = $opponents[0];
// Randomly assign colors
$isWhite = rand(0, 1) === 0;
$whiteId = $isWhite ? $userId : $opponent['player_id'];
$blackId = $isWhite ? $opponent['player_id'] : $userId;
// Create match
$matchData = [ $matchData = [
'game_key' => $gameKey, 'game_key' => $gameKey,
'white_player_id' => $userId, 'white_player_id' => $whiteId,
'black_player_id' => $opponent['player_id'], 'black_player_id' => $blackId,
'status' => 'in_progress', 'status' => 'in_progress',
'time_control' => $timeControl, 'time_control' => $timeControl,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', 'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]' 'moves' => '[]',
'move_count' => 0,
'game_state' => '{}'
]; ];
$match = $db->insert('matches', $matchData); $match = $sdb->insert('matches', $matchData);
$matchId = $match[0]['id'] ?? $match['id'] ?? null; $matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) { if ($matchId) {
$db->update('matchmaking_queue', [ // Mark OPPONENT as matched
$sdb->update('matchmaking_queue', [
'status' => 'matched', 'status' => 'matched',
'matched_with' => $userId, 'matched_with' => $userId,
'match_id' => $matchId 'match_id' => $matchId
], ['id' => 'eq.' . $opponent['id']]); ], ['id' => 'eq.' . $opponent['id']]);
jsonResponse(['match_id' => $matchId, 'color' => 'w', 'opponent_id' => $opponent['player_id']]); // Remove self from queue
$sdb->delete('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting'
]);
$myColor = $isWhite ? 'w' : 'b';
jsonResponse(['match_id' => $matchId, 'color' => $myColor, 'opponent_id' => $opponent['player_id']]);
} }
} }
$queueEntry = $db->insert('matchmaking_queue', [ jsonResponse(['queued' => true]);
'player_id' => $userId,
'game_key' => $gameKey,
'time_control' => $timeControl,
'rating' => $rating,
'status' => 'waiting'
]);
jsonResponse(['queued' => true, 'id' => $queueEntry[0]['id'] ?? null]);
} }
function handleDequeue($db, string $userId, array $input): void {
$db->delete('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting'
]);
jsonResponse(['success' => true]);
}
function handleStatus($db, string $userId, array $input): void { function handleStatus($db, string $userId, array $input): void {
$entry = $db->get('matchmaking_queue', [ $sdb = supabaseService();
$entry = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId, 'player_id' => 'eq.' . $userId,
'status' => 'eq.matched', 'status' => 'eq.matched',
'select' => 'match_id,matched_with', 'select' => 'match_id,matched_with',
...@@ -111,9 +130,28 @@ function handleStatus($db, string $userId, array $input): void { ...@@ -111,9 +130,28 @@ function handleStatus($db, string $userId, array $input): void {
]); ]);
if (!empty($entry) && !isset($entry['error']) && isset($entry[0]['match_id'])) { if (!empty($entry) && !isset($entry['error']) && isset($entry[0]['match_id'])) {
$db->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]); // Found match! Get match details to know our color
jsonResponse(['match_id' => $entry[0]['match_id'], 'color' => 'b']); $matchId = $entry[0]['match_id'];
$match = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'white_player_id,black_player_id', 'limit' => 1]);
$color = 'b';
if (!empty($match) && !isset($match['error'])) {
$color = ($match[0]['white_player_id'] === $userId) ? 'w' : 'b';
}
// Clean up queue
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
jsonResponse(['match_id' => $matchId, 'color' => $color]);
} }
jsonResponse(['waiting' => true]); jsonResponse(['waiting' => true]);
} }
function handleDequeue($db, string $userId, array $input): void {
$sdb = supabaseService();
$sdb->delete('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting'
]);
jsonResponse(['success' => true]);
}
...@@ -149,6 +149,16 @@ export function mountGame(el, params) { ...@@ -149,6 +149,16 @@ export function mountGame(el, params) {
requestBotMove(el); requestBotMove(el);
} }
// Live mode: start polling for opponent moves
if (mode === 'live' && matchId) {
startLivePolling(el);
if (playerColor === 'b') {
// Waiting for white's first move
gameState.isPlayerTurn = false;
clock.start('w');
}
}
// Emote system // Emote system
const emoteContainer = el.querySelector('#board-container'); const emoteContainer = el.querySelector('#board-container');
emoteSystem.create(emoteContainer, (emote) => { emoteSystem.create(emoteContainer, (emote) => {
...@@ -272,6 +282,9 @@ function executeMove(el, from, to, promotion) { ...@@ -272,6 +282,9 @@ function executeMove(el, from, to, promotion) {
if (gameState.mode === 'bot') { if (gameState.mode === 'bot') {
requestBotMove(el); requestBotMove(el);
} else if (gameState.mode === 'live') {
// Send move to server, then poll for opponent's response
sendLiveMove(el);
} }
} }
...@@ -402,8 +415,76 @@ function getEndReason() { ...@@ -402,8 +415,76 @@ function getEndReason() {
return 'draw'; return 'draw';
} }
// ===== LIVE MULTIPLAYER =====
let livePoller = null;
let lastKnownMoveCount = 0;
async function sendLiveMove(el) {
if (!gameState.matchId) return;
gameState.moveCount;
lastKnownMoveCount = gameState.moveCount;
try {
await net.post('game.php', {
action: 'move',
match_id: gameState.matchId,
fen: engine.fen(),
move: JSON.stringify(gameState.moveHistory),
move_count: gameState.moveCount
});
} catch (e) {}
}
function startLivePolling(el) {
lastKnownMoveCount = 0;
livePoller = setInterval(async () => {
if (gameState.gameOver) { clearInterval(livePoller); return; }
if (gameState.isPlayerTurn) return; // Only poll when waiting for opponent
try {
const data = await net.post('game.php', { action: 'get', match_id: gameState.matchId });
if (!data || data.error) return;
// Check if new move arrived
if (data.move_count > lastKnownMoveCount) {
lastKnownMoveCount = data.move_count;
// Apply opponent's move by loading the new FEN
const newFen = data.current_fen;
if (newFen && newFen !== engine.fen()) {
// Find what move was made by comparing positions
engine.load(newFen);
board.setPosition(newFen);
audio.play('move', 'game');
clock.switch();
gameState.isPlayerTurn = true;
gameState.moveCount = data.move_count;
if (engine.isGameOver()) {
endGame(engine.getResult(gameState.playerColor), getEndReason());
}
}
}
// Check if game ended (opponent resigned, etc.)
if (data.status === 'completed' && !gameState.gameOver) {
const result = data.result;
if (result) {
const isWin = (result === 'white_wins' && gameState.playerColor === 'w') ||
(result === 'black_wins' && gameState.playerColor === 'b');
endGame(isWin ? 'win' : 'loss', 'resign');
}
}
} catch (e) {}
}, 2000);
}
function stopLivePolling() {
if (livePoller) { clearInterval(livePoller); livePoller = null; }
}
// ===== END LIVE MULTIPLAYER =====
function endGame(result, reason) { function endGame(result, reason) {
if (gameState.gameOver) return; if (gameState.gameOver) return;
stopLivePolling();
gameState.gameOver = true; gameState.gameOver = true;
clock.stop(); clock.stop();
board.interactive = false; board.interactive = false;
......
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