Commit 16a760f7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: a stale queue claim must not drop a player into a dead match

Production carries seven matchmaking_queue rows marked `matched` from May and
June, each pointing at a game that finished months ago. The previous commit
started honouring `matched` rows so the hand-off survives a re-queue — which,
without this, would have sent those players straight into a three-month-old
finished match.

liveClaimedMatch() now verifies the referenced match is real, still playable,
and actually belongs to the caller before acting on it, and deletes the row on
the spot when it is not. Self-healing, so the existing rows need no manual
cleanup. handleStatus also deletes only the row it consumed rather than every
row for that player, and returns opponent_id alongside the colour.

Covered by e2e section 17: a stale claim is refused and cleared, queueing past
one works normally, and a genuinely live claim is still delivered with the
correct opposite colour.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 7bbe3ea6
...@@ -55,29 +55,25 @@ function handleQueue($db, string $userId, array $input): void { ...@@ -55,29 +55,25 @@ function handleQueue($db, string $userId, array $input): void {
return; return;
} }
// STEP 0: If we have already been claimed by an opponent, hand that match // STEP 0: If an opponent has already claimed us, hand that match back
// back instead of re-queueing. // instead of re-queueing.
// //
// The next step used to delete *all* of this player's queue rows // The next step used to delete *all* of this player's queue rows
// unconditionally, including one already marked `matched` and carrying a // unconditionally, including one already marked `matched` and carrying a
// match_id. The waiting player then never learned about their own match and // match_id. The waiting player then never learned about their own match and
// their opponent sat alone in a game — "I didn't get connected to the person // their opponent sat alone in a game — "I didn't get connected to the person
// in front of me". The 60s retry in queue.js hits this path every time. // in front of me". The 60s retry in queue.js hits this path every time.
$claimed = $sdb->get('matchmaking_queue', [ if (liveClaimedMatch($sdb, $userId)) {
'player_id' => 'eq.' . $userId,
'status' => 'eq.matched',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($claimed) && !empty($claimed) && !isset($claimed['error']) && !empty($claimed[0]['match_id'])) {
handleStatus($db, $userId, $input); handleStatus($db, $userId, $input);
return; return;
} }
// STEP 1: Drop only our own *waiting* rows, never a claimed one. // 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,
// is cleared here.
$sdb->delete('matchmaking_queue', [ $sdb->delete('matchmaking_queue', [
'player_id' => 'eq.' . $userId, 'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting', 'status' => 'neq.matched',
]); ]);
// STEP 2: Clean stale entries from other players (older than 90 seconds) // STEP 2: Clean stale entries from other players (older than 90 seconds)
...@@ -210,29 +206,71 @@ function handleQueue($db, string $userId, array $input): void { ...@@ -210,29 +206,71 @@ function handleQueue($db, string $userId, array $input): void {
function handleStatus($db, string $userId, array $input): void { function handleStatus($db, string $userId, array $input): void {
$sdb = supabaseService(); $sdb = supabaseService();
$entry = $sdb->get('matchmaking_queue', [ $claim = liveClaimedMatch($sdb, $userId);
if (!$claim) {
jsonResponse(['waiting' => true]);
}
$match = $claim['match'];
$color = ($match['white_player_id'] === $userId) ? 'w' : 'b';
// The hand-off has been delivered, so the queue row has done its job.
$sdb->delete('matchmaking_queue', ['id' => 'eq.' . $claim['queue_id']]);
jsonResponse([
'match_id' => $match['id'],
'color' => $color,
'opponent_id' => $color === 'w' ? $match['black_player_id'] : $match['white_player_id'],
]);
}
/**
* A queue claim worth acting on: one that points at a match which is genuinely
* still playable.
*
* Production carries queue rows marked `matched` that are months old and point
* at long-finished games. Honouring one of those would drop a player straight
* into a dead match, so the referenced match is checked and a stale row is
* cleared on the spot rather than left to mislead the next request.
*
* @return array{queue_id: string, match: array}|null
*/
function liveClaimedMatch($sdb, string $userId): ?array {
$rows = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId, 'player_id' => 'eq.' . $userId,
'status' => 'eq.matched', 'status' => 'eq.matched',
'select' => 'match_id,matched_with', 'select' => 'id,match_id,queued_at',
'limit' => 1 'order' => 'queued_at.desc',
'limit' => 5,
]); ]);
if (!is_array($rows) || isset($rows['error']) || empty($rows)) return null;
if (!empty($entry) && !isset($entry['error']) && isset($entry[0]['match_id'])) { $live = null;
$matchId = $entry[0]['match_id']; foreach ($rows as $row) {
$match = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'white_player_id,black_player_id', 'limit' => 1]); $stale = true;
$color = 'b';
if (!empty($match) && !isset($match['error'])) {
$color = ($match[0]['white_player_id'] === $userId) ? 'w' : 'b';
}
// Clean up queue entry if (!empty($row['match_id'])) {
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]); $m = $sdb->get('matches', [
'id' => 'eq.' . $row['match_id'],
'select' => 'id,white_player_id,black_player_id,status',
'limit' => 1,
]);
$m = (is_array($m) && !empty($m) && !isset($m['error'])) ? $m[0] : null;
jsonResponse(['match_id' => $matchId, 'color' => $color]); $playable = $m
return; && in_array($m['status'], ['waiting', 'ready', 'in_progress'], true)
&& ($m['white_player_id'] === $userId || $m['black_player_id'] === $userId);
if ($playable && $live === null) {
$live = ['queue_id' => $row['id'], 'match' => $m];
$stale = false;
}
}
if ($stale) $sdb->delete('matchmaking_queue', ['id' => 'eq.' . $row['id']]);
} }
jsonResponse(['waiting' => true]); return $live;
} }
function handleDequeue($db, string $userId, array $input): void { function handleDequeue($db, string $userId, array $input): void {
......
...@@ -407,6 +407,33 @@ check('every round-1 board has a result', ncRound1.every(p => p.is_bye || p.resu ...@@ -407,6 +407,33 @@ check('every round-1 board has a result', ncRound1.every(p => p.is_bye || p.resu
check('the forfeited board is marked as such', check('the forfeited board is marked as such',
ncRound1.some(p => p.reason === 'forfeit'), JSON.stringify(ncRound1.map(p => p.reason))); ncRound1.some(p => p.reason === 'forfeit'), JSON.stringify(ncRound1.map(p => p.reason)));
console.log('\n=== 17. a stale queue claim never drops a player into a dead match ===');
sql(`DELETE FROM matchmaking_queue;`);
// Exactly the shape production carries: rows marked `matched` months ago,
// pointing at games that finished long since.
const deadMatch = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT id FROM matches WHERE status='completed' LIMIT 1) t`)).id;
sql(`INSERT INTO matchmaking_queue (player_id, game_key, time_control, status, match_id, queued_at)
VALUES ('${P[0]}','chess','rapid_10_0','matched','${deadMatch}', now() - interval '90 days')`);
r = await call('matchmaking.php', { action: 'status', game_key: 'chess' }, P[0]);
check('status does not hand back a finished match', r.data.waiting === true, JSON.stringify(r.data));
const leftover = sql(`SELECT count(*) FROM matchmaking_queue WHERE player_id='${P[0]}'`);
check('the stale row is cleared rather than left to mislead', leftover === '0', `${leftover} rows`);
// And queueing again works normally instead of returning the dead match.
sql(`INSERT INTO matchmaking_queue (player_id, game_key, time_control, status, match_id, queued_at)
VALUES ('${P[0]}','chess','rapid_10_0','matched','${deadMatch}', now() - interval '90 days')`);
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
check('queueing past a stale claim queues normally', r.data.queued === true, JSON.stringify(r.data));
// A genuinely live claim must still be honoured.
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[2]);
const liveM = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[3])).data;
r = await call('matchmaking.php', { action: 'status', game_key: 'chess' }, P[2]);
check('a live claim is still delivered', r.data.match_id === liveM.match_id, JSON.stringify(r.data));
check('and it comes with the opposite colour', r.data.color !== liveM.color, `${r.data.color} / ${liveM.color}`);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
console.log(''); console.log('');
if (failures.length) { if (failures.length) {
......
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