Commit c227bce1 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(matchmaking): chess and domino players who queue together are matched

Chess: a status poll that landed between the claim and the match id write
deleted the claim, so the waiting player searched forever while the other
sat alone. A claim in progress is now left alone for 3 minutes, dequeue
reports it, a failed match insert releases it, and a waiting player's
status poll claims an older waiting row. Domino gets the same status-side
match. Raced 4/4 (chess) and 3/3 (domino) with simultaneous queue calls.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 42dd98c4
...@@ -161,33 +161,10 @@ function handleQueue(string $userId, array $input): void { ...@@ -161,33 +161,10 @@ function handleQueue(string $userId, array $input): void {
} }
// STEP 5: Create match // STEP 5: Create match
$players = [$opponent['user_id'], $userId]; $created = dominoCreateMatch($sdb, $opponent['user_id'], $userId);
if ($created) {
$matchData = [ $sdb->update('domino_queue', ['match_id' => $created['match_id']], ['id' => 'eq.' . $opponent['id']]);
'status' => 'in_progress', jsonResponse($created);
'players' => $players,
'current_turn' => 0,
'board' => [],
'hands' => (object)[],
'boneyard' => [],
'moves' => [],
'scores' => ['0' => 0, '1' => 0],
'game_state' => ['move_count' => 0, 'round' => 1, 'mode' => 'live'],
'host_id' => $opponent['user_id']
];
$match = $sdb->insert('domino_matches', $matchData);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
$sdb->update('domino_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opponent['id']]);
jsonResponse([
'match_id' => $matchId,
'player_index' => 1,
'players' => $players,
'color' => 'player2'
]);
return; return;
} }
...@@ -216,7 +193,7 @@ function handleStatus(string $userId): void { ...@@ -216,7 +193,7 @@ function handleStatus(string $userId): void {
$entry = $sdb->get('domino_queue', [ $entry = $sdb->get('domino_queue', [
'user_id' => 'eq.' . $userId, 'user_id' => 'eq.' . $userId,
'select' => 'id,match_id', 'select' => 'id,match_id,created_at',
'limit' => 1 'limit' => 1
]); ]);
...@@ -241,9 +218,76 @@ function handleStatus(string $userId): void { ...@@ -241,9 +218,76 @@ function handleStatus(string $userId): void {
]); ]);
} }
// Still unclaimed: maybe someone queued at the same moment we did, and the
// two searches each found an empty queue.
if (!empty($entry) && !isset($entry['error']) && empty($entry[0]['match_id'])) {
$found = dominoMatchFromStatus($sdb, $userId, $entry[0]);
if ($found) jsonResponse($found);
}
jsonResponse(['waiting' => true]); jsonResponse(['waiting' => true]);
} }
/**
* Create a live match: the host (the earlier queuer) sits at index 0.
* Returns the response for the player who made it (index 1), or null.
*/
function dominoCreateMatch($sdb, string $hostId, string $userId): ?array {
$players = [$hostId, $userId];
$match = $sdb->insert('domino_matches', [
'status' => 'in_progress',
'players' => $players,
'current_turn' => 0,
'board' => [],
'hands' => (object)[],
'boneyard' => [],
'moves' => [],
'scores' => ['0' => 0, '1' => 0],
'game_state' => ['move_count' => 0, 'round' => 1, 'mode' => 'live'],
'host_id' => $hostId
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if (!$matchId) return null;
return ['match_id' => $matchId, 'player_index' => 1, 'players' => $players, 'color' => 'player2'];
}
/**
* Two players who queue at the same moment can both search an empty queue and
* both insert themselves; nobody searches again and each waits for the other
* forever. So a waiting player's status poll looks for someone who has been
* waiting LONGER. Only the later of the two does this, and only towards older
* rows, so two pollers never claim each other; our own row is locked first so
* a third player cannot claim us while we build the match.
*/
function dominoMatchFromStatus($sdb, string $userId, array $mine): ?array {
if (empty($mine['created_at'])) return null;
$older = $sdb->get('domino_queue', [
'user_id' => 'neq.' . $userId,
'match_id' => 'is.null',
'and' => '(created_at.lt."' . $mine['created_at'] . '",created_at.gt."' . gmdate('c', time() - 90) . '")',
'select' => 'id,user_id',
'order' => 'created_at.asc',
'limit' => 1,
]);
if (!is_array($older) || isset($older['error']) || empty($older)) return null;
$opp = $older[0];
$release = function (string $id) use ($sdb) {
$sdb->update('domino_queue', ['match_id' => null], ['id' => 'eq.' . $id, 'match_id' => 'eq.' . DOMINO_CLAIM_PLACEHOLDER]);
};
$lock = $sdb->update('domino_queue', ['match_id' => DOMINO_CLAIM_PLACEHOLDER], ['id' => 'eq.' . $mine['id'], 'match_id' => 'is.null']);
if (empty($lock) || isset($lock['error'])) return null;
$claim = $sdb->update('domino_queue', ['match_id' => DOMINO_CLAIM_PLACEHOLDER], ['id' => 'eq.' . $opp['id'], 'match_id' => 'is.null']);
if (empty($claim) || isset($claim['error'])) { $release($mine['id']); return null; }
$created = dominoCreateMatch($sdb, $opp['user_id'], $userId);
if (!$created) { $release($opp['id']); $release($mine['id']); return null; }
$sdb->update('domino_queue', ['match_id' => $created['match_id']], ['id' => 'eq.' . $opp['id']]);
$sdb->delete('domino_queue', ['id' => 'eq.' . $mine['id']]);
return $created;
}
function handleMove(string $userId, array $input): void { function handleMove(string $userId, array $input): void {
$matchId = $input['match_id'] ?? ''; $matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required'); if (!$matchId) jsonError('match_id required');
......
...@@ -67,6 +67,20 @@ function handleQueue($db, string $userId, array $input): void { ...@@ -67,6 +67,20 @@ function handleQueue($db, string $userId, array $input): void {
handleStatus($db, $userId, $input); handleStatus($db, $userId, $input);
return; return;
} }
// A claim on us that is still being built: stay in it, the status poll
// hands the match over in a moment. Queueing again would let a second
// opponent match us too.
$pending = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.matched',
'match_id' => 'is.null',
'queued_at' => 'gt.' . gmdate('c', time() - 180),
'select' => 'id',
'limit' => 1,
]);
if (is_array($pending) && !isset($pending['error']) && !empty($pending)) {
jsonResponse(['queued' => true]);
}
// STEP 1: Drop our own waiting rows. A *live* claim is protected by the // STEP 1: Drop our own waiting rows. A *live* claim is protected by the
// check above; anything else, including a claim whose match is long dead, // check above; anything else, including a claim whose match is long dead,
...@@ -144,51 +158,20 @@ function handleQueue($db, string $userId, array $input): void { ...@@ -144,51 +158,20 @@ function handleQueue($db, string $userId, array $input): void {
} }
// STEP 7: Create match // STEP 7: Create match
$isWhite = rand(0, 1) === 0; $created = createChessMatch($sdb, $userId, $opponent['player_id'], $gameKey, $timeControl);
$whiteId = $isWhite ? $userId : $opponent['player_id']; if ($created) {
$blackId = $isWhite ? $opponent['player_id'] : $userId;
// Derive the clock from the time control itself rather than bucketing
// by prefix, which gave every blitz variant a flat 5 minutes and ignored
// increments entirely.
$clock = chessTimeControlMs($timeControl);
$initialTime = $clock['initial'];
$match = $sdb->insert('matches', [
'game_key' => $gameKey,
'match_type' => 'rated',
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'status' => 'in_progress',
'time_control' => $timeControl,
'initial_time_ms' => $initialTime,
'increment_ms' => $clock['increment'],
'white_time_remaining_ms' => $initialTime,
'black_time_remaining_ms' => $initialTime,
'starting_fen' => CHESS_START_FEN,
'current_fen' => CHESS_START_FEN,
'moves' => [],
'move_count' => 0,
'game_state' => (object)[],
'is_rated' => true,
'started_at' => date('c')
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
// Update opponent's queue entry with match_id // Update opponent's queue entry with match_id
$sdb->update('matchmaking_queue', [ $sdb->update('matchmaking_queue', [
'match_id' => $matchId 'match_id' => $created['match_id']
], ['id' => 'eq.' . $opponent['id']]); ], ['id' => 'eq.' . $opponent['id']]);
mpLog($matchId, $gameKey, $userId, 'match_created', [ jsonResponse(['match_id' => $created['match_id'], 'color' => $created['color'], 'opponent_id' => $opponent['player_id']]);
'white' => $whiteId, 'black' => $blackId, 'time_control' => $timeControl
]);
$myColor = $isWhite ? 'w' : 'b';
jsonResponse(['match_id' => $matchId, 'color' => $myColor, 'opponent_id' => $opponent['player_id']]);
return; return;
} }
// The match could not be created. Release the claim, or the opponent
// waits on a claim that will never become a match.
releaseClaim($sdb, $opponent['id']);
} }
// No opponent found — add self to queue // No opponent found — add self to queue
...@@ -207,6 +190,9 @@ function handleStatus($db, string $userId, array $input): void { ...@@ -207,6 +190,9 @@ function handleStatus($db, string $userId, array $input): void {
$sdb = supabaseService(); $sdb = supabaseService();
$claim = liveClaimedMatch($sdb, $userId); $claim = liveClaimedMatch($sdb, $userId);
if (!$claim) {
$claim = matchFromStatus($sdb, $userId);
}
if (!$claim) { if (!$claim) {
jsonResponse(['waiting' => true]); jsonResponse(['waiting' => true]);
} }
...@@ -249,7 +235,14 @@ function liveClaimedMatch($sdb, string $userId): ?array { ...@@ -249,7 +235,14 @@ function liveClaimedMatch($sdb, string $userId): ?array {
foreach ($rows as $row) { foreach ($rows as $row) {
$stale = true; $stale = true;
if (!empty($row['match_id'])) { // A claim in progress: the opponent has marked the row but has not
// written the match id yet. Deleting it here (a status poll lands in
// that window easily) threw the hand-off away: the opponent got the
// match and this player searched forever. Leave it; the next poll
// will see the match id. Only a claim that never completed is stale.
if (empty($row['match_id'])) {
if (strtotime($row['queued_at'] ?? '') > time() - 180) continue;
} else {
$m = $sdb->get('matches', [ $m = $sdb->get('matches', [
'id' => 'eq.' . $row['match_id'], 'id' => 'eq.' . $row['match_id'],
'select' => 'id,white_player_id,black_player_id,status', 'select' => 'id,white_player_id,black_player_id,status',
...@@ -279,14 +272,20 @@ function handleDequeue($db, string $userId, array $input): void { ...@@ -279,14 +272,20 @@ function handleDequeue($db, string $userId, array $input): void {
// A row that has already been matched must survive: deleting it here (which // A row that has already been matched must survive: deleting it here (which
// scene teardown does on every navigation) would destroy the hand-off and // scene teardown does on every navigation) would destroy the hand-off and
// strand the opponent in a game by themselves. Report the match instead. // strand the opponent in a game by themselves. Report the match instead.
// A claim still being built (no match id yet) counts too: the opponent is
// creating the match with this player in it right now.
$claimed = $sdb->get('matchmaking_queue', [ $claimed = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId, 'player_id' => 'eq.' . $userId,
'status' => 'eq.matched', 'status' => 'eq.matched',
'select' => 'match_id', 'select' => 'match_id,queued_at',
'order' => 'queued_at.desc',
'limit' => 1, 'limit' => 1,
]); ]);
if (is_array($claimed) && !empty($claimed) && !isset($claimed['error']) && !empty($claimed[0]['match_id'])) { if (is_array($claimed) && !empty($claimed) && !isset($claimed['error'])) {
jsonResponse(['success' => false, 'matched' => true, 'match_id' => $claimed[0]['match_id']]); $c = $claimed[0];
if (!empty($c['match_id']) || strtotime($c['queued_at'] ?? '') > time() - 180) {
jsonResponse(['success' => false, 'matched' => true, 'match_id' => $c['match_id'] ?: null]);
}
} }
$sdb->delete('matchmaking_queue', [ $sdb->delete('matchmaking_queue', [
...@@ -296,6 +295,132 @@ function handleDequeue($db, string $userId, array $input): void { ...@@ -296,6 +295,132 @@ function handleDequeue($db, string $userId, array $input): void {
jsonResponse(['success' => true]); jsonResponse(['success' => true]);
} }
/**
* Create a rated chess match between us and the opponent, colours drawn at
* random. Returns ['match_id' => .., 'color' => 'w'|'b'] or null.
*/
function createChessMatch($sdb, string $userId, string $opponentId, string $gameKey, string $timeControl): ?array {
$isWhite = rand(0, 1) === 0;
$whiteId = $isWhite ? $userId : $opponentId;
$blackId = $isWhite ? $opponentId : $userId;
// Derive the clock from the time control itself rather than bucketing
// by prefix, which gave every blitz variant a flat 5 minutes and ignored
// increments entirely.
$clock = chessTimeControlMs($timeControl);
$initialTime = $clock['initial'];
$match = $sdb->insert('matches', [
'game_key' => $gameKey,
'match_type' => 'rated',
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'status' => 'in_progress',
'time_control' => $timeControl,
'initial_time_ms' => $initialTime,
'increment_ms' => $clock['increment'],
'white_time_remaining_ms' => $initialTime,
'black_time_remaining_ms' => $initialTime,
'starting_fen' => CHESS_START_FEN,
'current_fen' => CHESS_START_FEN,
'moves' => [],
'move_count' => 0,
'game_state' => (object)[],
'is_rated' => true,
'started_at' => date('c')
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if (!$matchId) return null;
mpLog($matchId, $gameKey, $userId, 'match_created', [
'white' => $whiteId, 'black' => $blackId, 'time_control' => $timeControl
]);
return ['match_id' => $matchId, 'color' => $isWhite ? 'w' : 'b'];
}
/** Put a claimed row back in the queue (only while no match id was written). */
function releaseClaim($sdb, string $queueId): void {
$sdb->update('matchmaking_queue', ['status' => 'waiting', 'matched_with' => null], [
'id' => 'eq.' . $queueId,
'status' => 'eq.matched',
'match_id' => 'is.null',
]);
}
/**
* Two players who queue at the same moment can both search an empty queue and
* both insert themselves, and then nobody ever searches again: each waits for
* the other forever. So a waiting player's status poll also looks for someone
* who has been waiting LONGER for the same game. Only the later of the two
* does this, and only towards older rows, so two pollers never claim each
* other. Our own row is locked first so a third player cannot claim us while
* we are building the match.
*
* @return array{queue_id: string, match: array}|null shaped like liveClaimedMatch()
*/
function matchFromStatus($sdb, string $userId): ?array {
$mine = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting',
'select' => 'id,game_key,time_control,queued_at',
'order' => 'queued_at.desc',
'limit' => 1,
]);
if (!is_array($mine) || isset($mine['error']) || empty($mine)) return null;
$row = $mine[0];
$exclude = [$userId];
$blocks = $sdb->get('player_blocks', [
'or' => "(player_id.eq.{$userId},blocked_id.eq.{$userId})",
'type' => 'eq.block',
'select' => 'player_id,blocked_id'
]);
if (is_array($blocks) && !isset($blocks['error'])) {
foreach ($blocks as $b) $exclude[] = ($b['player_id'] === $userId) ? $b['blocked_id'] : $b['player_id'];
}
$older = $sdb->get('matchmaking_queue', [
'game_key' => 'eq.' . $row['game_key'],
'time_control' => 'eq.' . $row['time_control'],
'status' => 'eq.waiting',
'player_id' => 'not.in.(' . implode(',', $exclude) . ')',
'and' => '(queued_at.lt."' . $row['queued_at'] . '",queued_at.gt."' . gmdate('c', time() - 90) . '")',
'select' => 'id,player_id',
'order' => 'queued_at.asc',
'limit' => 1,
]);
if (!is_array($older) || isset($older['error']) || empty($older)) return null;
$opponent = $older[0];
// Lock our own row.
$lock = $sdb->update('matchmaking_queue', ['status' => 'matched', 'matched_with' => $userId], [
'id' => 'eq.' . $row['id'], 'status' => 'eq.waiting',
]);
if (empty($lock) || isset($lock['error'])) return null; // someone claimed us just now
$claim = $sdb->update('matchmaking_queue', ['status' => 'matched', 'matched_with' => $userId], [
'id' => 'eq.' . $opponent['id'], 'status' => 'eq.waiting',
]);
if (empty($claim) || isset($claim['error'])) { releaseClaim($sdb, $row['id']); return null; }
$created = createChessMatch($sdb, $userId, $opponent['player_id'], $row['game_key'], $row['time_control']);
if (!$created) { releaseClaim($sdb, $opponent['id']); releaseClaim($sdb, $row['id']); return null; }
$sdb->update('matchmaking_queue', ['match_id' => $created['match_id']], ['id' => 'eq.' . $opponent['id']]);
$sdb->update('matchmaking_queue', ['match_id' => $created['match_id']], ['id' => 'eq.' . $row['id']]);
$isWhite = $created['color'] === 'w';
return [
'queue_id' => $row['id'],
'match' => [
'id' => $created['match_id'],
'white_player_id' => $isWhite ? $userId : $opponent['player_id'],
'black_player_id' => $isWhite ? $opponent['player_id'] : $userId,
'status' => 'in_progress',
],
];
}
function mpLog(string $matchId, string $gameKey, string $playerId, string $event, $payload = []): void { function mpLog(string $matchId, string $gameKey, string $playerId, string $event, $payload = []): void {
supabaseRpc('log_mp_event', [ supabaseRpc('log_mp_event', [
'p_match_id' => $matchId, 'p_match_id' => $matchId,
......
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