Commit 1c145579 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: bulletproof matchmaking queue across all games

- Add dequeue handler to backgammon-match.php (was missing)
- Add 90-second stale entry cleanup to ludo/domino/backgammon queues
- Add atomic opponent claiming (conditional update) to prevent race conditions
- Fix client queue.js to route dequeue to correct game endpoint
- Register unmountQueue for proper cleanup when exiting search screen
- Remove json_encode on jsonb fields in backgammon (prevent double-encoding)

Fixes: self-matching, ghost queue entries, and race condition where two
players simultaneously claim the same opponent.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 6517bd2d
......@@ -17,6 +17,7 @@ $action = $input['action'] ?? '';
switch ($action) {
case 'queue': handleQueue($userId, $input); break;
case 'dequeue': handleBgDequeue($userId); break;
case 'status': handleStatus($userId); break;
case 'move': handleMove($userId, $input); break;
case 'get': handleGet($userId, $input); break;
......@@ -27,20 +28,34 @@ switch ($action) {
default: jsonError('Invalid action');
}
function handleBgDequeue(string $userId): void {
$sdb = supabaseService();
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
jsonResponse(['success' => true]);
}
function handleQueue(string $userId, array $input): void {
$sdb = supabaseService();
$variant = $input['variant'] ?? 'sheshbesh';
$matchLength = intval($input['match_length'] ?? 3);
// Clean old queue entries
// STEP 1: Remove ALL existing entries for this player (prevents ghost/self-match)
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
// Search for opponent
// STEP 2: Clean stale entries from other players (older than 90 seconds)
$staleTime = gmdate('c', time() - 90);
$sdb->delete('backgammon_queue', [
'match_id' => 'is.null',
'created_at' => 'lt.' . $staleTime
]);
// STEP 3: Search for available opponent
$searchUrl = SUPABASE_REST . '/backgammon_queue'
. '?user_id=neq.' . $userId
. '&match_id=is.null'
. '&variant=eq.' . $variant
. '&select=id,user_id'
. '&order=created_at.asc'
. '&limit=1';
$ch = curl_init($searchUrl);
......@@ -48,7 +63,8 @@ function handleQueue(string $userId, array $input): void {
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
'Content-Type: application/json',
'Prefer: return=representation'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
......@@ -57,28 +73,47 @@ function handleQueue(string $userId, array $input): void {
if (!empty($opponents) && isset($opponents[0])) {
$opponent = $opponents[0];
$players = [$opponent['user_id'], $userId];
// Randomly assign colors
// STEP 4: Atomically claim opponent — set match_id to a placeholder to prevent races
$claimResult = $sdb->update('backgammon_queue', [
'match_id' => '00000000-0000-0000-0000-000000000000'
], [
'id' => 'eq.' . $opponent['id'],
'match_id' => 'is.null'
]);
if (empty($claimResult) || isset($claimResult['error'])) {
// Opponent was grabbed by someone else — add self to queue
$sdb->insert('backgammon_queue', [
'user_id' => $userId,
'variant' => $variant,
'match_length' => $matchLength
]);
jsonResponse(['queued' => true]);
return;
}
// STEP 5: Create match
$players = [$opponent['user_id'], $userId];
$whiteIdx = rand(0, 1);
$colors = [$whiteIdx === 0 ? 'white' : 'black', $whiteIdx === 1 ? 'white' : 'black'];
$matchData = [
'status' => 'in_progress',
'players' => json_encode($players),
'colors' => json_encode($colors),
'players' => $players,
'colors' => $colors,
'variant' => $variant,
'match_length' => $matchLength,
'scores' => json_encode([0, 0]),
'scores' => [0, 0],
'current_turn' => $whiteIdx,
'game_state' => json_encode([
'game_state' => [
'variant' => $variant,
'match_length' => $matchLength,
'game_number' => 1,
'cube_value' => 1,
'cube_owner' => null,
'crawford' => false
]),
],
'host_id' => $opponent['user_id'],
'created_by' => $userId
];
......
......@@ -23,11 +23,18 @@ switch ($action) {
case 'get': handleGet($userId, $input); break;
case 'resign': handleResign($userId, $input); break;
case 'complete': handleComplete($userId, $input); break;
case 'dequeue': handleDequeue($userId); break;
case 'heartbeat': handleDominoHeartbeat($userId, $input); break;
case 'leave': handleDominoLeave($userId, $input); break;
default: jsonError('Invalid action');
}
function handleDequeue(string $userId): void {
$sdb = supabaseService();
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId]);
jsonResponse(['success' => true]);
}
function handleStart(string $userId, array $input): void {
$sdb = supabaseService();
$mode = $input['mode'] ?? 'bot';
......@@ -69,12 +76,22 @@ function handleQueue(string $userId, array $input): void {
return;
}
// STEP 1: Remove ALL existing entries for this player (prevents ghost/self-match)
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId]);
// STEP 2: Clean stale entries from other players (older than 90 seconds)
$staleTime = gmdate('c', time() - 90);
$sdb->delete('domino_queue', [
'match_id' => 'is.null',
'created_at' => 'lt.' . $staleTime
]);
// STEP 3: Search for available opponent
$searchUrl = SUPABASE_REST . '/domino_queue'
. '?user_id=neq.' . $userId
. '&match_id=is.null'
. '&select=id,user_id'
. '&order=created_at.asc'
. '&limit=1';
$ch = curl_init($searchUrl);
......@@ -91,6 +108,23 @@ function handleQueue(string $userId, array $input): void {
if (!empty($opponents) && isset($opponents[0])) {
$opponent = $opponents[0];
// STEP 4: Atomically claim opponent — prevents race conditions
$claimResult = $sdb->update('domino_queue', [
'match_id' => '00000000-0000-0000-0000-000000000000'
], [
'id' => 'eq.' . $opponent['id'],
'match_id' => 'is.null'
]);
if (empty($claimResult) || isset($claimResult['error'])) {
// Opponent was grabbed by someone else — add self to queue
$sdb->insert('domino_queue', ['user_id' => $userId]);
jsonResponse(['queued' => true]);
return;
}
// STEP 5: Create match
$players = [$opponent['user_id'], $userId];
$matchData = [
......@@ -118,9 +152,11 @@ function handleQueue(string $userId, array $input): void {
'players' => $players,
'color' => 'player2'
]);
return;
}
}
// No opponent found — add to queue
$sdb->insert('domino_queue', ['user_id' => $userId]);
jsonResponse(['queued' => true]);
}
......
......@@ -17,6 +17,7 @@ $action = $input['action'] ?? '';
switch ($action) {
case 'queue': handleLudoQueue($userId, $input); break;
case 'dequeue': handleLudoDequeue($userId); break;
case 'status': handleLudoStatus($userId); break;
case 'move': handleLudoMove($userId, $input); break;
case 'get': handleLudoGet($userId, $input); break;
......@@ -26,21 +27,35 @@ switch ($action) {
default: jsonError('Invalid action');
}
function handleLudoDequeue(string $userId): void {
$sdb = supabaseService();
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]);
jsonResponse(['success' => true]);
}
function handleLudoQueue(string $userId, array $input): void {
$sdb = supabaseService();
$requestedPlayers = intval($input['player_count'] ?? 4);
if ($requestedPlayers < 2) $requestedPlayers = 2;
if ($requestedPlayers > 4) $requestedPlayers = 4;
// Clean old waiting entries for this player
// STEP 1: Remove ALL existing entries for this player (prevents ghost/self-match)
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]);
// Check for waiting opponents (need at least 1 to start)
// STEP 2: Clean stale entries from other players (older than 90 seconds)
$staleTime = gmdate('c', time() - 90);
$sdb->delete('ludo_queue', [
'match_id' => 'is.null',
'created_at' => 'lt.' . $staleTime
]);
// STEP 3: Search for waiting opponents
$maxOpponents = $requestedPlayers - 1;
$searchUrl = SUPABASE_REST . '/ludo_queue'
. '?user_id=neq.' . $userId
. '&match_id=is.null'
. '&select=id,user_id'
. '&order=created_at.asc'
. '&limit=' . $maxOpponents;
$ch = curl_init($searchUrl);
......@@ -56,16 +71,36 @@ function handleLudoQueue(string $userId, array $input): void {
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
// Found opponents — build player list, fill remaining with bots
$humanPlayers = [$opponents[0]['user_id'], $userId];
if (isset($opponents[1]) && $requestedPlayers >= 3) $humanPlayers[] = $opponents[1]['user_id'];
if (isset($opponents[2]) && $requestedPlayers >= 4) $humanPlayers[] = $opponents[2]['user_id'];
// STEP 4: Atomically claim opponents — prevents race conditions
$claimedOpponents = [];
foreach ($opponents as $opp) {
$claimResult = $sdb->update('ludo_queue', [
'match_id' => '00000000-0000-0000-0000-000000000000'
], [
'id' => 'eq.' . $opp['id'],
'match_id' => 'is.null'
]);
if (!empty($claimResult) && !isset($claimResult['error'])) {
$claimedOpponents[] = $opp;
}
}
if (empty($claimedOpponents)) {
// All opponents were grabbed by others — add self to queue
$sdb->insert('ludo_queue', ['user_id' => $userId]);
jsonResponse(['queued' => true]);
return;
}
// STEP 5: Build player list from successfully claimed opponents
$humanPlayers = [$claimedOpponents[0]['user_id'], $userId];
if (isset($claimedOpponents[1]) && $requestedPlayers >= 3) $humanPlayers[] = $claimedOpponents[1]['user_id'];
if (isset($claimedOpponents[2]) && $requestedPlayers >= 4) $humanPlayers[] = $claimedOpponents[2]['user_id'];
$players = $humanPlayers;
$botCount = $requestedPlayers - count($players);
for ($i = 1; $i <= $botCount; $i++) { $players[] = 'bot_' . $i; }
// Build positions array matching player count
$positions = [];
for ($i = 0; $i < $requestedPlayers; $i++) {
$positions[] = ['pos' => [-1,-1,-1,-1]];
......@@ -82,15 +117,14 @@ function handleLudoQueue(string $userId, array $input): void {
'moves' => [],
'winners' => [],
'game_state' => ['turn_count' => 0],
'host_id' => $opponents[0]['user_id']
'host_id' => $claimedOpponents[0]['user_id']
];
$match = $sdb->insert('ludo_matches', $matchData);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
// Mark ALL opponents' queue entries with match_id
foreach ($opponents as $opp) {
foreach ($claimedOpponents as $opp) {
$sdb->update('ludo_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opp['id']]);
}
......@@ -100,6 +134,7 @@ function handleLudoQueue(string $userId, array $input): void {
'player_count' => $requestedPlayers,
'players' => $players
]);
return;
}
}
......
......@@ -34,41 +34,31 @@ function handleQueue($db, string $userId, array $input): void {
$gameKey = $input['game_key'] ?? 'chess';
$timeControl = $input['time_control'] ?? 'rapid_10_0';
// Anti-abuse: check if player can queue (cooldown after repeated abandons)
$sdb = supabaseService();
// Anti-abuse: check if player can queue
$canQueue = supabaseRpc('can_player_queue', ['p_player_id' => $userId]);
if (is_array($canQueue) && isset($canQueue['allowed']) && !$canQueue['allowed']) {
jsonResponse(['error' => 'cooldown', 'seconds_remaining' => $canQueue['seconds_remaining'] ?? 60]);
return;
}
// Use service key to bypass RLS for matchmaking operations
$sdb = supabaseService();
// STEP 1: Remove ALL stale/existing entries for this player (prevents ghost entries)
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
// STEP 2: Clean stale entries from other players (older than 90 seconds)
$staleTime = gmdate('c', time() - 90);
$sdb->delete('matchmaking_queue', [
'status' => 'eq.waiting',
'queued_at' => 'lt.' . $staleTime
]);
// Get player rating
// STEP 3: 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;
// Check if already in queue
$existing = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting',
'select' => 'id'
]);
if (!empty($existing) && !isset($existing['error'])) {
// 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'
]);
}
// Get block list for this player (both directions)
// STEP 4: Get block list
$blockedIds = [];
$blocks = $sdb->get('player_blocks', [
'or' => "(player_id.eq.{$userId},blocked_id.eq.{$userId})",
......@@ -81,7 +71,7 @@ function handleQueue($db, string $userId, array $input): void {
}
}
// Search for available opponent (service key bypasses RLS — can see ALL waiting players)
// STEP 5: Search for available opponent
$excludeIds = array_merge([$userId], $blockedIds);
$searchUrl = SUPABASE_REST . '/matchmaking_queue'
. '?game_key=eq.' . urlencode($gameKey)
......@@ -107,18 +97,41 @@ function handleQueue($db, string $userId, array $input): void {
if (!empty($opponents) && !isset($opponents['error']) && isset($opponents[0])) {
$opponent = $opponents[0];
// Randomly assign colors
// STEP 6: Atomically claim opponent — mark them as matched FIRST
// Use a conditional update: only succeeds if still 'waiting'
$claimResult = $sdb->update('matchmaking_queue', [
'status' => 'matched',
'matched_with' => $userId
], [
'id' => 'eq.' . $opponent['id'],
'status' => 'eq.waiting'
]);
// If claim failed (empty result = no rows matched), opponent was already taken
if (empty($claimResult) || isset($claimResult['error'])) {
// Opponent was grabbed by someone else — add self to queue and wait
$sdb->insert('matchmaking_queue', [
'player_id' => $userId,
'game_key' => $gameKey,
'time_control' => $timeControl,
'rating' => $rating,
'status' => 'waiting'
]);
jsonResponse(['queued' => true]);
return;
}
// STEP 7: Create match
$isWhite = rand(0, 1) === 0;
$whiteId = $isWhite ? $userId : $opponent['player_id'];
$blackId = $isWhite ? $opponent['player_id'] : $userId;
// Create match
$initialTime = 600000; // default 10 min
$initialTime = 600000;
if (strpos($timeControl, 'bullet') !== false) $initialTime = 60000;
elseif (strpos($timeControl, 'blitz') !== false) $initialTime = 300000;
elseif (strpos($timeControl, 'classical') !== false) $initialTime = 3600000;
$matchData = [
$match = $sdb->insert('matches', [
'game_key' => $gameKey,
'match_type' => 'rated',
'white_player_id' => $whiteId,
......@@ -129,35 +142,34 @@ function handleQueue($db, string $userId, array $input): void {
'white_time_remaining_ms' => $initialTime,
'black_time_remaining_ms' => $initialTime,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]',
'moves' => [],
'move_count' => 0,
'game_state' => '{}',
'game_state' => (object)[],
'is_rated' => true,
'started_at' => date('c')
];
$match = $sdb->insert('matches', $matchData);
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
// Mark OPPONENT as matched
// Update opponent's queue entry with match_id
$sdb->update('matchmaking_queue', [
'status' => 'matched',
'matched_with' => $userId,
'match_id' => $matchId
], ['id' => 'eq.' . $opponent['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']]);
return;
}
}
// No opponent found — add self to queue
$sdb->insert('matchmaking_queue', [
'player_id' => $userId,
'game_key' => $gameKey,
'time_control' => $timeControl,
'rating' => $rating,
'status' => 'waiting'
]);
jsonResponse(['queued' => true]);
}
......@@ -173,7 +185,6 @@ function handleStatus($db, string $userId, array $input): void {
]);
if (!empty($entry) && !isset($entry['error']) && isset($entry[0]['match_id'])) {
// Found match! Get match details to know our color
$matchId = $entry[0]['match_id'];
$match = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'white_player_id,black_player_id', 'limit' => 1]);
$color = 'b';
......@@ -181,10 +192,11 @@ function handleStatus($db, string $userId, array $input): void {
$color = ($match[0]['white_player_id'] === $userId) ? 'w' : 'b';
}
// Clean up queue
// Clean up queue entry
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
jsonResponse(['match_id' => $matchId, 'color' => $color]);
return;
}
jsonResponse(['waiting' => true]);
......@@ -192,9 +204,7 @@ function handleStatus($db, string $userId, array $input): void {
function handleDequeue($db, string $userId, array $input): void {
$sdb = supabaseService();
$sdb->delete('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting'
]);
// Remove from chess matchmaking queue
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
jsonResponse(['success' => true]);
}
......@@ -2,13 +2,13 @@ import * as scene from '../../core/scene.js';
import { mountTable } from './scenes/table.js';
import { mountBotSelect } from './scenes/bot-select.js';
import { mountTimeSelect } from './scenes/time-select.js';
import { mountQueue } from './scenes/queue.js';
import { mountQueue, unmountQueue } from './scenes/queue.js';
import { mountLobby } from './scenes/lobby.js';
import { mountChallenge } from './scenes/challenge.js';
scene.register('play-table', mountTable);
scene.register('play-bot-select', mountBotSelect);
scene.register('play-time-select', mountTimeSelect);
scene.register('play-queue', mountQueue);
scene.register('play-queue', mountQueue, unmountQueue);
scene.register('game-lobby', mountLobby);
scene.register('challenge-friend', mountChallenge);
......@@ -6,8 +6,17 @@ import { t } from '../../../core/i18n.js';
let unsub = null;
let timer = null;
let activeParams = null;
export function unmountQueue() {
if (activeParams) {
leaveQueue(activeParams);
activeParams = null;
}
}
export function mountQueue(el, params) {
activeParams = params;
let seconds = 0;
el.innerHTML = `
......@@ -40,6 +49,7 @@ export function mountQueue(el, params) {
el.querySelector('#cancel-btn').addEventListener('click', () => {
audio.play('click');
leaveQueue(params);
activeParams = null;
scene.pop();
});
}
......@@ -80,6 +90,7 @@ function pollForMatch(params) {
function onMatchFound(data, params) {
cleanup();
activeParams = null;
audio.play('notification');
const gameScene = params.game === 'chess' ? 'chess-game' : params.game + '-game';
scene.replace(gameScene, {
......@@ -95,8 +106,9 @@ function onMatchFound(data, params) {
async function leaveQueue(params) {
cleanup();
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : params.game === 'backgammon' ? 'backgammon-match.php' : 'matchmaking.php';
try {
await net.post('matchmaking.php', { action: 'dequeue', game_key: params.game });
await net.post(endpoint, { action: 'dequeue', game_key: params.game });
} catch (e) {}
}
......
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