Commit 3df90193 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(ludo): two players who search at the same moment are matched

Both missed each other in the queue call and both inserted; status never
tried to match, so they waited for the 60 s timeout. A waiting player's
status poll now claims fresh rows queued before its own, after locking its
own row, so two pollers can never claim each other. ludo_queue stores the
requested table size for that path.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 425202c0
......@@ -105,41 +105,86 @@ function handleLudoQueue(string $userId, array $input): void {
// answered with an error object, `isset($opponents[0])` was false, and every
// player was inserted to wait for an opponent the search could never find:
// two people could not be matched at ludo at all.
$maxOpponents = $requestedPlayers - 1;
$opponents = $sdb->get('ludo_queue', [
$opponents = ludoFindOpponents($sdb, $userId, $requestedPlayers - 1, null);
if ($opponents) {
// STEP 4: Atomically claim opponents — prevents race conditions
$claimedOpponents = ludoClaim($sdb, $opponents);
if (empty($claimedOpponents)) {
// All opponents were grabbed by others — add self to queue
ludoEnqueue($sdb, $userId, $requestedPlayers);
return;
}
// STEP 5: Build the match from the opponents we hold.
$result = ludoBuildMatch($sdb, $userId, $claimedOpponents, $requestedPlayers);
if ($result) jsonResponse($result);
jsonError('Could not create the match', 500);
}
// No opponent found — add to queue
ludoEnqueue($sdb, $userId, $requestedPlayers);
}
/**
* Waiting opponents, oldest first. With $olderThan set, only rows queued
* before it and still fresh: a waiting player only ever claims the players
* who were waiting before them, so two pollers can never claim each other.
*/
function ludoFindOpponents($sdb, string $userId, int $limit, ?string $olderThan): array {
$params = [
'user_id' => 'neq.' . $userId,
'match_id' => 'is.null',
'select' => 'id,user_id',
'select' => 'id,user_id,queued_at',
'order' => 'queued_at.asc',
'limit' => $maxOpponents,
]);
'limit' => $olderThan === null ? $limit : 20,
];
if ($olderThan !== null) $params['queued_at'] = 'lt.' . $olderThan;
$rows = $sdb->get('ludo_queue', $params);
if (!is_array($rows) || isset($rows['error'])) return [];
if ($olderThan === null) return array_values($rows);
// Rows nobody has re-queued for 90 seconds belong to players who left.
$fresh = array_filter($rows, fn($r) => strtotime($r['queued_at'] ?? '') > time() - 90);
return array_slice(array_values($fresh), 0, $limit);
}
if (is_array($opponents) && !isset($opponents['error']) && isset($opponents[0])) {
// STEP 4: Atomically claim opponents — prevents race conditions
$claimedOpponents = [];
/** Claim each row by swapping its null match_id for the placeholder; keep the ones we won. */
function ludoClaim($sdb, array $opponents): array {
$claimed = [];
foreach ($opponents as $opp) {
$claimResult = $sdb->update('ludo_queue', [
'match_id' => '00000000-0000-0000-0000-000000000000'
], [
$res = $sdb->update('ludo_queue', ['match_id' => LUDO_CLAIM_PLACEHOLDER], [
'id' => 'eq.' . $opp['id'],
'match_id' => 'is.null'
'match_id' => 'is.null',
]);
if (!empty($claimResult) && !isset($claimResult['error'])) {
$claimedOpponents[] = $opp;
}
if (!empty($res) && !isset($res['error'])) $claimed[] = $opp;
}
return $claimed;
}
if (empty($claimedOpponents)) {
// All opponents were grabbed by others — add self to queue
ludoEnqueue($sdb, $userId);
return;
/** Put claimed rows back in the queue. */
function ludoRelease($sdb, array $rows): void {
foreach ($rows as $row) {
$sdb->update('ludo_queue', ['match_id' => null], [
'id' => 'eq.' . $row['id'],
'match_id' => 'eq.' . LUDO_CLAIM_PLACEHOLDER,
]);
}
}
// STEP 5: Build player list from successfully claimed opponents
/**
* Create the match for us and the opponents we hold, hand each of them its id,
* and return our view of it. On failure the claims are released and null comes back.
*/
function ludoBuildMatch($sdb, string $userId, array $claimedOpponents, int $requestedPlayers): ?array {
$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'];
// Opponents claimed beyond the table size go back to waiting.
$seated = array_slice($claimedOpponents, 0, count($humanPlayers) - 1);
ludoRelease($sdb, array_slice($claimedOpponents, count($seated)));
$players = $humanPlayers;
$botCount = $requestedPlayers - count($players);
for ($i = 1; $i <= $botCount; $i++) { $players[] = 'bot_' . $i; }
......@@ -149,7 +194,7 @@ function handleLudoQueue(string $userId, array $input): void {
$positions[] = ['pos' => [-1,-1,-1,-1]];
}
$matchData = [
$match = $sdb->insert('ludo_matches', [
'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'status' => 'in_progress',
'player_count' => $requestedPlayers,
......@@ -160,44 +205,32 @@ function handleLudoQueue(string $userId, array $input): void {
'moves' => [],
'winners' => [],
'game_state' => ['turn_count' => 0],
'host_id' => $claimedOpponents[0]['user_id']
];
$match = $sdb->insert('ludo_matches', $matchData);
'host_id' => $seated[0]['user_id'],
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
foreach ($claimedOpponents as $opp) {
if (!$matchId) {
// Release the claims, or those players wait on a placeholder that
// will never turn into a match.
ludoRelease($sdb, $seated);
return null;
}
foreach ($seated as $opp) {
$sdb->update('ludo_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opp['id']]);
}
jsonResponse([
return [
'match_id' => $matchId,
'player_index' => 1,
'player_count' => $requestedPlayers,
'players' => $players
]);
return;
}
// The match could not be created. Release the claims, or those players
// wait on a placeholder that will never turn into a match.
foreach ($claimedOpponents as $opp) {
$sdb->update('ludo_queue', ['match_id' => null], [
'id' => 'eq.' . $opp['id'],
'match_id' => 'eq.' . LUDO_CLAIM_PLACEHOLDER,
]);
}
jsonError('Could not create the match', 500);
}
// No opponent found — add to queue
ludoEnqueue($sdb, $userId);
'players' => $players,
];
}
/** Put the player in the queue, and say so only if the row was written. */
function ludoEnqueue($sdb, string $userId): void {
$row = $sdb->insert('ludo_queue', ['user_id' => $userId]);
function ludoEnqueue($sdb, string $userId, int $playerCount): void {
$row = $sdb->insert('ludo_queue', ['user_id' => $userId, 'player_count' => $playerCount]);
if (isset($row['error'])) jsonError('Could not join the queue', 500);
jsonResponse(['queued' => true]);
}
......@@ -207,7 +240,7 @@ function handleLudoStatus(string $userId): void {
$entry = $sdb->get('ludo_queue', [
'user_id' => 'eq.' . $userId,
'select' => 'id,match_id',
'select' => 'id,match_id,queued_at,player_count',
'limit' => 1
]);
......@@ -237,6 +270,25 @@ function handleLudoStatus(string $userId): void {
]);
}
// Still waiting: look for players who queued before us. Two players who
// press search at the same moment both miss each other in the queue call
// and both insert; this is where the later one picks up the earlier one.
if (!empty($entry) && !isset($entry['error']) && empty($entry[0]['match_id'])) {
$own = $entry[0];
$requestedPlayers = max(2, min(4, intval($own['player_count'] ?? 4)));
$opponents = ludoFindOpponents($sdb, $userId, $requestedPlayers - 1, $own['queued_at']);
// Hold our own row first, so nobody seats us elsewhere while we build.
if ($opponents && ludoClaim($sdb, [$own])) {
$claimed = ludoClaim($sdb, $opponents);
$result = $claimed ? ludoBuildMatch($sdb, $userId, $claimed, $requestedPlayers) : null;
if ($result) {
$sdb->delete('ludo_queue', ['id' => 'eq.' . $own['id']]);
jsonResponse($result);
}
ludoRelease($sdb, [$own]);
}
}
jsonResponse(['waiting' => true]);
}
......
-- The table size a player asked for, so a waiting player's status poll can
-- build the match they queued for (ludo-match.php matches from status too).
ALTER TABLE public.ludo_queue ADD COLUMN IF NOT EXISTS player_count integer NOT NULL DEFAULT 4;
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