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,99 +105,132 @@ function handleLudoQueue(string $userId, array $input): void { ...@@ -105,99 +105,132 @@ function handleLudoQueue(string $userId, array $input): void {
// answered with an error object, `isset($opponents[0])` was false, and every // 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: // player was inserted to wait for an opponent the search could never find:
// two people could not be matched at ludo at all. // two people could not be matched at ludo at all.
$maxOpponents = $requestedPlayers - 1; $opponents = ludoFindOpponents($sdb, $userId, $requestedPlayers - 1, null);
$opponents = $sdb->get('ludo_queue', [
'user_id' => 'neq.' . $userId,
'match_id' => 'is.null',
'select' => 'id,user_id',
'order' => 'queued_at.asc',
'limit' => $maxOpponents,
]);
if (is_array($opponents) && !isset($opponents['error']) && isset($opponents[0])) { if ($opponents) {
// STEP 4: Atomically claim opponents — prevents race conditions // STEP 4: Atomically claim opponents — prevents race conditions
$claimedOpponents = []; $claimedOpponents = ludoClaim($sdb, $opponents);
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)) { if (empty($claimedOpponents)) {
// All opponents were grabbed by others — add self to queue // All opponents were grabbed by others — add self to queue
ludoEnqueue($sdb, $userId); ludoEnqueue($sdb, $userId, $requestedPlayers);
return; return;
} }
// STEP 5: Build player list from successfully claimed opponents // STEP 5: Build the match from the opponents we hold.
$humanPlayers = [$claimedOpponents[0]['user_id'], $userId]; $result = ludoBuildMatch($sdb, $userId, $claimedOpponents, $requestedPlayers);
if (isset($claimedOpponents[1]) && $requestedPlayers >= 3) $humanPlayers[] = $claimedOpponents[1]['user_id']; if ($result) jsonResponse($result);
if (isset($claimedOpponents[2]) && $requestedPlayers >= 4) $humanPlayers[] = $claimedOpponents[2]['user_id']; jsonError('Could not create the match', 500);
}
$players = $humanPlayers; // No opponent found — add to queue
$botCount = $requestedPlayers - count($players); ludoEnqueue($sdb, $userId, $requestedPlayers);
for ($i = 1; $i <= $botCount; $i++) { $players[] = 'bot_' . $i; } }
$positions = []; /**
for ($i = 0; $i < $requestedPlayers; $i++) { * Waiting opponents, oldest first. With $olderThan set, only rows queued
$positions[] = ['pos' => [-1,-1,-1,-1]]; * 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,queued_at',
'order' => 'queued_at.asc',
'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);
}
$matchData = [ /** Claim each row by swapping its null match_id for the placeholder; keep the ones we won. */
'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)), function ludoClaim($sdb, array $opponents): array {
'status' => 'in_progress', $claimed = [];
'player_count' => $requestedPlayers, foreach ($opponents as $opp) {
'players' => $players, $res = $sdb->update('ludo_queue', ['match_id' => LUDO_CLAIM_PLACEHOLDER], [
'current_turn' => 0, 'id' => 'eq.' . $opp['id'],
'dice_value' => null, 'match_id' => 'is.null',
'positions' => $positions, ]);
'moves' => [], if (!empty($res) && !isset($res['error'])) $claimed[] = $opp;
'winners' => [], }
'game_state' => ['turn_count' => 0], return $claimed;
'host_id' => $claimedOpponents[0]['user_id'] }
];
$match = $sdb->insert('ludo_matches', $matchData);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
foreach ($claimedOpponents as $opp) {
$sdb->update('ludo_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opp['id']]);
}
jsonResponse([ /** Put claimed rows back in the queue. */
'match_id' => $matchId, function ludoRelease($sdb, array $rows): void {
'player_index' => 1, foreach ($rows as $row) {
'player_count' => $requestedPlayers, $sdb->update('ludo_queue', ['match_id' => null], [
'players' => $players 'id' => 'eq.' . $row['id'],
]); 'match_id' => 'eq.' . LUDO_CLAIM_PLACEHOLDER,
return; ]);
} }
}
// The match could not be created. Release the claims, or those players /**
// wait on a placeholder that will never turn into a match. * Create the match for us and the opponents we hold, hand each of them its id,
foreach ($claimedOpponents as $opp) { * and return our view of it. On failure the claims are released and null comes back.
$sdb->update('ludo_queue', ['match_id' => null], [ */
'id' => 'eq.' . $opp['id'], function ludoBuildMatch($sdb, string $userId, array $claimedOpponents, int $requestedPlayers): ?array {
'match_id' => 'eq.' . LUDO_CLAIM_PLACEHOLDER, $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'];
jsonError('Could not create the match', 500);
// 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; }
$positions = [];
for ($i = 0; $i < $requestedPlayers; $i++) {
$positions[] = ['pos' => [-1,-1,-1,-1]];
} }
// No opponent found — add to queue $match = $sdb->insert('ludo_matches', [
ludoEnqueue($sdb, $userId); 'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'status' => 'in_progress',
'player_count' => $requestedPlayers,
'players' => $players,
'current_turn' => 0,
'dice_value' => null,
'positions' => $positions,
'moves' => [],
'winners' => [],
'game_state' => ['turn_count' => 0],
'host_id' => $seated[0]['user_id'],
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
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']]);
}
return [
'match_id' => $matchId,
'player_index' => 1,
'player_count' => $requestedPlayers,
'players' => $players,
];
} }
/** Put the player in the queue, and say so only if the row was written. */ /** Put the player in the queue, and say so only if the row was written. */
function ludoEnqueue($sdb, string $userId): void { function ludoEnqueue($sdb, string $userId, int $playerCount): void {
$row = $sdb->insert('ludo_queue', ['user_id' => $userId]); $row = $sdb->insert('ludo_queue', ['user_id' => $userId, 'player_count' => $playerCount]);
if (isset($row['error'])) jsonError('Could not join the queue', 500); if (isset($row['error'])) jsonError('Could not join the queue', 500);
jsonResponse(['queued' => true]); jsonResponse(['queued' => true]);
} }
...@@ -207,7 +240,7 @@ function handleLudoStatus(string $userId): void { ...@@ -207,7 +240,7 @@ function handleLudoStatus(string $userId): void {
$entry = $sdb->get('ludo_queue', [ $entry = $sdb->get('ludo_queue', [
'user_id' => 'eq.' . $userId, 'user_id' => 'eq.' . $userId,
'select' => 'id,match_id', 'select' => 'id,match_id,queued_at,player_count',
'limit' => 1 'limit' => 1
]); ]);
...@@ -237,6 +270,25 @@ function handleLudoStatus(string $userId): void { ...@@ -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]); 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