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

fix(online): matchmaking for ludo, domino and backgammon actually matches

Found by the gameplay pass, verified against production with throwaway
accounts (31/31 checks):

- ludo: the queue filtered and ordered on created_at, a column ludo_queue does
  not have, so no two players were ever matched; five stale rows from July to
  September were also deleted.
- domino and ludo status/get decoded jsonb columns that already arrive as
  arrays: a PHP 8 TypeError, so the waiting player never learned of the match
  and every live poll failed.
- all three: the zero-UUID claim placeholder was reported as a found match and
  its row deleted; failed inserts were reported as success; domino hands were
  renumbered by array_merge; complete passed p_winners as a string (the RPC
  refused it) and could pay twice. Complete now pays once, to seated players.
- engine.js loads each game module independently, so one module failing to
  load can no longer stop the whole app from booting.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 6746a37a
......@@ -13,6 +13,13 @@ $userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
/**
* While an opponent is building the match they hold our queue row with this
* placeholder in match_id. It is a lock, never a match: status must not report
* it as found, and nothing may delete the row while it is held.
*/
const BG_CLAIM_PLACEHOLDER = '00000000-0000-0000-0000-000000000000';
switch ($action) {
case 'queue': handleQueue($userId, $input); break;
case 'dequeue': handleBgDequeue($userId); break;
......@@ -26,9 +33,33 @@ switch ($action) {
default: jsonError('Invalid action');
}
/** jsonb columns arrive as arrays; rows written json_encode()d arrive as strings. */
function bgJson($value, array $default = []): array {
if (is_array($value)) return $value;
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
if (is_array($decoded)) return $decoded;
}
return $default;
}
function handleBgDequeue(string $userId): void {
$sdb = supabaseService();
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
// A row an opponent has already claimed must survive: deleting it strands
// them alone in a match we never hear about. Report the claim instead.
$claimed = $sdb->get('backgammon_queue', [
'user_id' => 'eq.' . $userId,
'match_id' => 'not.is.null',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($claimed) && !isset($claimed['error']) && !empty($claimed)) {
$mid = $claimed[0]['match_id'];
jsonResponse(['success' => false, 'matched' => true, 'match_id' => $mid === BG_CLAIM_PLACEHOLDER ? null : $mid]);
}
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId, 'match_id' => 'is.null']);
jsonResponse(['success' => true]);
}
......@@ -37,8 +68,22 @@ function handleQueue(string $userId, array $input): void {
$variant = $input['variant'] ?? 'sheshbesh';
$matchLength = intval($input['match_length'] ?? 3);
// STEP 1: Remove ALL existing entries for this player (prevents ghost/self-match)
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
// STEP 0: If an opponent has already claimed us, hand that match back
// instead of re-queueing (deleting the row would strand them).
$mine = $sdb->get('backgammon_queue', [
'user_id' => 'eq.' . $userId,
'match_id' => 'not.is.null',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($mine) && !isset($mine['error']) && !empty($mine)) {
if ($mine[0]['match_id'] === BG_CLAIM_PLACEHOLDER) jsonResponse(['queued' => true]);
handleStatus($userId);
return;
}
// STEP 1: Remove our own waiting entries (prevents ghost/self-match)
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId, 'match_id' => 'is.null']);
// STEP 2: Clean stale entries from other players (older than 90 seconds)
$staleTime = gmdate('c', time() - 90);
......@@ -47,28 +92,20 @@ function handleQueue(string $userId, array $input): void {
'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);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json',
'Prefer: return=representation'
// STEP 3: Search for an opponent who wants the same game: same variant and
// the same match length (a 1-point game and a 7-point match are not the
// same request).
$opponents = $sdb->get('backgammon_queue', [
'user_id' => 'neq.' . $userId,
'match_id' => 'is.null',
'variant' => 'eq.' . $variant,
'match_length' => 'eq.' . $matchLength,
'select' => 'id,user_id',
'order' => 'created_at.asc',
'limit' => 1,
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
if (is_array($opponents) && !isset($opponents['error']) && isset($opponents[0])) {
$opponent = $opponents[0];
// STEP 4: Atomically claim opponent — set match_id to a placeholder to prevent races
......@@ -81,12 +118,7 @@ function handleQueue(string $userId, array $input): void {
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]);
bgEnqueue($sdb, $userId, $variant, $matchLength);
return;
}
......@@ -129,14 +161,32 @@ function handleQueue(string $userId, array $input): void {
]);
return;
}
// The match could not be created: release the claim, or that player
// waits on a placeholder that never becomes a match.
$sdb->update('backgammon_queue', ['match_id' => null], [
'id' => 'eq.' . $opponent['id'],
'match_id' => 'eq.' . BG_CLAIM_PLACEHOLDER,
]);
jsonError('Could not create the match', 500);
}
// No opponent found — queue up
$sdb->insert('backgammon_queue', [
bgEnqueue($sdb, $userId, $variant, $matchLength);
}
/**
* Put the player in the queue, and say so only if the row was written. A
* failed insert used to answer {queued: true} anyway, and the client then
* waited for an opponent who could never find it.
*/
function bgEnqueue($sdb, string $userId, string $variant, int $matchLength): void {
$row = $sdb->insert('backgammon_queue', [
'user_id' => $userId,
'variant' => $variant,
'match_length' => $matchLength
]);
if (isset($row['error'])) jsonError('Could not join the queue', 500);
jsonResponse(['queued' => true]);
}
......@@ -149,7 +199,10 @@ function handleStatus(string $userId): void {
'limit' => 1
]);
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])) {
// The placeholder is a claim still being built, not a match: keep waiting
// and keep the row, or the real match id that follows lands on nothing.
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])
&& $entry[0]['match_id'] !== BG_CLAIM_PLACEHOLDER) {
$matchId = $entry[0]['match_id'];
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
......@@ -157,10 +210,10 @@ function handleStatus(string $userId): void {
$players = [];
$colors = [];
if (!empty($matches) && !isset($matches['error'])) {
$players = json_decode($matches[0]['players'] ?? '[]', true);
$colors = json_decode($matches[0]['colors'] ?? '[]', true);
$players = bgJson($matches[0]['players'] ?? null);
$colors = bgJson($matches[0]['colors'] ?? null);
}
$playerIndex = array_search($userId, $players);
$playerIndex = array_search($userId, $players, true);
jsonResponse([
'match_id' => $matchId,
......@@ -240,18 +293,35 @@ function handleComplete(string $userId, array $input): void {
$sdb = supabaseService();
$sdb->update('backgammon_matches', [
// The client names the winner as its own id or as 'opponent'. Resolve that
// against the seats, so winner_id is always a seated player's id (or null)
// and never the string 'opponent', which a uuid column refuses.
$rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
$players = (is_array($rows) && !isset($rows['error']) && !empty($rows)) ? bgJson($rows[0]['players'] ?? null) : [];
if ($winner === 'opponent') {
$others = array_values(array_filter($players, fn($p) => $p !== $userId));
$winner = $others[0] ?? null;
}
if (!is_string($winner) || !in_array($winner, $players, true)) $winner = null;
// Claim the completion once: both clients report the end of a live match,
// and only the first report may pay out.
$claimed = $sdb->update('backgammon_matches', [
'status' => 'completed',
'completed_at' => date('c'),
'winner_id' => $winner,
'result' => $reason,
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
], ['id' => 'eq.' . $matchId, 'completed_at' => 'is.null']);
if (isset($claimed['error'])) jsonError('Could not complete the match', 500);
if (empty($claimed)) jsonResponse(['success' => true, 'already_completed' => true]);
// p_winners is jsonb: pass the array. json_encode() sent a JSON string and
// the function failed with "cannot get array length of a scalar".
$result = supabaseRpc('complete_match', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_winners' => json_encode([$winner]),
'p_winners' => [$winner],
'p_reason' => $reason
]);
......@@ -279,7 +349,8 @@ function handleBgLeave(string $userId, array $input): void {
$matches = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
$gameState = json_decode($matches[0]['game_state'] ?? '{}', true);
$gameState = bgJson($matches[0]['game_state'] ?? null);
if (array_is_list($gameState) && !empty($gameState)) $gameState = [];
$gameState['left_by'] = $userId;
$gameState['left_at'] = date('c');
......
......@@ -13,6 +13,13 @@ $userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
/**
* While an opponent is building the match they hold our queue row with this
* placeholder in match_id. It is a lock, never a match: status must not report
* it as found, and nothing may delete the row while it is held.
*/
const DOMINO_CLAIM_PLACEHOLDER = '00000000-0000-0000-0000-000000000000';
switch ($action) {
case 'start': handleStart($userId, $input); break;
case 'queue': handleQueue($userId, $input); break;
......@@ -27,9 +34,38 @@ switch ($action) {
default: jsonError('Invalid action');
}
/**
* players/hands/game_state come back as an array (jsonb written as an array,
* the queue path) or as a JSON string (written json_encode()d, the invite
* path). json_decode() on an array is a TypeError in PHP 8, which turned every
* status and get on a matchmade game into a 500.
*/
function dominoJson($value, array $default = []): array {
if (is_array($value)) return $value;
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
if (is_array($decoded)) return $decoded;
}
return $default;
}
function handleDequeue(string $userId): void {
$sdb = supabaseService();
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId]);
// A row an opponent has already claimed must survive: deleting it strands
// them alone in a match we never hear about. Report the claim instead.
$claimed = $sdb->get('domino_queue', [
'user_id' => 'eq.' . $userId,
'match_id' => 'not.is.null',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($claimed) && !isset($claimed['error']) && !empty($claimed)) {
$mid = $claimed[0]['match_id'];
jsonResponse(['success' => false, 'matched' => true, 'match_id' => $mid === DOMINO_CLAIM_PLACEHOLDER ? null : $mid]);
}
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId, 'match_id' => 'is.null']);
jsonResponse(['success' => true]);
}
......@@ -74,8 +110,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 0: If an opponent has already claimed us, hand that match back
// instead of re-queueing (deleting the row would strand them).
$mine = $sdb->get('domino_queue', [
'user_id' => 'eq.' . $userId,
'match_id' => 'not.is.null',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($mine) && !isset($mine['error']) && !empty($mine)) {
if ($mine[0]['match_id'] === DOMINO_CLAIM_PLACEHOLDER) jsonResponse(['queued' => true]);
handleStatus($userId);
return;
}
// STEP 1: Remove our own waiting entries (prevents ghost/self-match)
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId, 'match_id' => 'is.null']);
// STEP 2: Clean stale entries from other players (older than 90 seconds)
$staleTime = gmdate('c', time() - 90);
......@@ -85,25 +135,15 @@ function handleQueue(string $userId, array $input): void {
]);
// 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);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
$opponents = $sdb->get('domino_queue', [
'user_id' => 'neq.' . $userId,
'match_id' => 'is.null',
'select' => 'id,user_id',
'order' => 'created_at.asc',
'limit' => 1,
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
if (is_array($opponents) && !isset($opponents['error']) && isset($opponents[0])) {
$opponent = $opponents[0];
// STEP 4: Atomically claim opponent — prevents race conditions
......@@ -116,8 +156,7 @@ function handleQueue(string $userId, array $input): void {
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]);
dominoEnqueue($sdb, $userId);
return;
}
......@@ -151,10 +190,24 @@ function handleQueue(string $userId, array $input): void {
]);
return;
}
// The match could not be created: release the claim, or that player
// waits on a placeholder that never becomes a match.
$sdb->update('domino_queue', ['match_id' => null], [
'id' => 'eq.' . $opponent['id'],
'match_id' => 'eq.' . DOMINO_CLAIM_PLACEHOLDER,
]);
jsonError('Could not create the match', 500);
}
// No opponent found — add to queue
$sdb->insert('domino_queue', ['user_id' => $userId]);
dominoEnqueue($sdb, $userId);
}
/** Put the player in the queue, and say so only if the row was written. */
function dominoEnqueue($sdb, string $userId): void {
$row = $sdb->insert('domino_queue', ['user_id' => $userId]);
if (isset($row['error'])) jsonError('Could not join the queue', 500);
jsonResponse(['queued' => true]);
}
......@@ -167,16 +220,19 @@ function handleStatus(string $userId): void {
'limit' => 1
]);
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])) {
// The placeholder is a claim still being built, not a match: keep waiting
// and keep the row, or the real match id that follows lands on nothing.
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])
&& $entry[0]['match_id'] !== DOMINO_CLAIM_PLACEHOLDER) {
$matchId = $entry[0]['match_id'];
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId]);
$matches = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
$players = [];
if (!empty($matches) && !isset($matches['error'])) {
$players = json_decode($matches[0]['players'] ?? '[]', true);
$players = dominoJson($matches[0]['players'] ?? null);
}
$playerIndex = array_search($userId, $players);
$playerIndex = array_search($userId, $players, true);
jsonResponse([
'match_id' => $matchId,
......@@ -208,7 +264,10 @@ function handleMove(string $userId, array $input): void {
$raw = $existingMatch[0]['hands'];
$existingHands = is_string($raw) ? json_decode($raw, true) : $raw;
}
$mergedHands = array_merge($existingHands ?: [], $newHands ?: []);
// array_replace, not array_merge: the keys are seat numbers, and
// array_merge renumbers integer keys, so a seat's update was appended
// as a new entry and its stored hand never changed.
$mergedHands = array_replace($existingHands ?: [], $newHands ?: []);
$update['hands'] = !empty($mergedHands) ? $mergedHands : (object)[];
}
if (isset($input['boneyard'])) {
......@@ -285,9 +344,9 @@ function handleGet(string $userId, array $input): void {
}
}
$players = json_decode($match['players'] ?? '[]', true);
$playerIndex = array_search($userId, $players);
$hands = json_decode($match['hands'] ?? '{}', true);
$players = dominoJson($match['players'] ?? null);
$playerIndex = array_search($userId, $players, true);
$hands = dominoJson($match['hands'] ?? null);
$response = $match;
$response['my_hand'] = $hands[strval($playerIndex)] ?? [];
......@@ -313,9 +372,8 @@ function handleResign(string $userId, array $input): void {
$match = $matches[0];
if ($match['status'] === 'completed') jsonError('Match already ended');
$rawPlayers = $match['players'] ?? [];
$players = is_string($rawPlayers) ? json_decode($rawPlayers, true) : $rawPlayers;
$playerIndex = array_search($userId, $players);
$players = dominoJson($match['players'] ?? null);
$playerIndex = array_search($userId, $players, true);
$winnerIndex = $playerIndex === 0 ? 1 : 0;
$winnerId = $players[$winnerIndex] ?? null;
......@@ -334,27 +392,50 @@ function handleComplete(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$winners = $input['winners'] ?? [];
$winners = is_array($winners) ? array_values($winners) : [];
$reason = $input['reason'] ?? 'normal';
$first = $winners[0] ?? null;
$sdb = supabaseService();
// Mark match as completed
$sdb->update('domino_matches', [
// Claim the completion once. Both players' clients report the end of a
// live match; only the first report may pay out. (The old update also
// wrote a `winners` column the table does not have, so it always failed
// and the match was never marked completed.)
$claimed = $sdb->update('domino_matches', [
'status' => 'completed',
'completed_at' => date('c'),
'result' => $reason,
'winners' => is_array($winners) ? $winners : [],
'winner_id' => (is_string($first) && !str_starts_with($first, 'bot_')) ? $first : null,
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
], ['id' => 'eq.' . $matchId, 'completed_at' => 'is.null']);
if (empty($claimed) || isset($claimed['error'])) {
// Already completed by the other player: hand back what was paid.
$rows = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
$gs = dominoJson($rows[0]['game_state'] ?? null);
jsonResponse(['success' => true, 'already_completed' => true, 'rewards' => $gs['rewards'] ?? []]);
}
// Call universal rewards function
// p_winners is jsonb: pass the array itself. json_encode() here sent a
// JSON string, and the function failed with "cannot get array length of
// a scalar", so no domino game ever paid coins or XP. Bots are sent as
// null (the function skips nulls; a 'bot_' id fails its uuid cast).
$winners = array_map(fn($w) => (is_string($w) && !str_starts_with($w, 'bot_')) ? $w : null, $winners);
$result = supabaseRpc('complete_match', [
'p_game_key' => 'domino',
'p_match_id' => $matchId,
'p_winners' => json_encode($winners),
'p_winners' => $winners,
'p_reason' => $reason
]);
// Keep the payout on the match so the second reporter can show it too.
if (is_array($result) && isset($result['rewards'])) {
$gs = dominoJson($claimed[0]['game_state'] ?? null);
$gs['rewards'] = $result['rewards'];
$sdb->update('domino_matches', ['game_state' => $gs], ['id' => 'eq.' . $matchId]);
}
jsonResponse($result ?: ['success' => true]);
}
......
......@@ -13,6 +13,13 @@ $userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
/**
* While an opponent is building the match they hold our queue row with this
* placeholder in match_id. It is a lock, never a match: status must not report
* it as found, and nothing may delete the row while it is held.
*/
const LUDO_CLAIM_PLACEHOLDER = '00000000-0000-0000-0000-000000000000';
switch ($action) {
case 'queue': handleLudoQueue($userId, $input); break;
case 'dequeue': handleLudoDequeue($userId); break;
......@@ -25,9 +32,34 @@ switch ($action) {
default: jsonError('Invalid action');
}
/** players/game_state arrive as a JSON string (text column) or an array (jsonb). */
function ludoJson($value, array $default = []): array {
if (is_array($value)) return $value;
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
if (is_array($decoded)) return $decoded;
}
return $default;
}
function handleLudoDequeue(string $userId): void {
$sdb = supabaseService();
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]);
// A row an opponent has already claimed must survive: deleting it strands
// them alone in a match we never hear about. Report the claim instead and
// let the client go to the table.
$claimed = $sdb->get('ludo_queue', [
'user_id' => 'eq.' . $userId,
'match_id' => 'not.is.null',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($claimed) && !isset($claimed['error']) && !empty($claimed)) {
$mid = $claimed[0]['match_id'];
jsonResponse(['success' => false, 'matched' => true, 'match_id' => $mid === LUDO_CLAIM_PLACEHOLDER ? null : $mid]);
}
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId, 'match_id' => 'is.null']);
jsonResponse(['success' => true]);
}
......@@ -37,37 +69,52 @@ function handleLudoQueue(string $userId, array $input): void {
if ($requestedPlayers < 2) $requestedPlayers = 2;
if ($requestedPlayers > 4) $requestedPlayers = 4;
// STEP 1: Remove ALL existing entries for this player (prevents ghost/self-match)
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]);
// STEP 0: If an opponent has already claimed us (a real match id on our
// row), hand that match back instead of re-queueing. Deleting the row here
// would strand them alone at the table.
$mine = $sdb->get('ludo_queue', [
'user_id' => 'eq.' . $userId,
'match_id' => 'not.is.null',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($mine) && !isset($mine['error']) && !empty($mine)) {
// Someone is building a match with us in it right now: stay put, the
// status poll will hand it over in a moment.
if ($mine[0]['match_id'] === LUDO_CLAIM_PLACEHOLDER) jsonResponse(['queued' => true]);
handleLudoStatus($userId);
return;
}
// STEP 2: Clean stale entries from other players (older than 90 seconds)
// STEP 1: Remove our own waiting entries (prevents ghost/self-match). A row
// held by a claim in progress is left alone.
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId, 'match_id' => 'is.null']);
// STEP 2: Clean stale entries from other players (older than 90 seconds).
// The table's timestamp column is queued_at: filtering on created_at was a
// PostgREST error, so this cleanup never ran.
$staleTime = gmdate('c', time() - 90);
$sdb->delete('ludo_queue', [
'match_id' => 'is.null',
'created_at' => 'lt.' . $staleTime
'queued_at' => 'lt.' . $staleTime
]);
// STEP 3: Search for waiting opponents
// STEP 3: Search for waiting opponents.
//
// This ordered by created_at, a column ludo_queue does not have. PostgREST
// 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;
$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);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
$opponents = $sdb->get('ludo_queue', [
'user_id' => 'neq.' . $userId,
'match_id' => 'is.null',
'select' => 'id,user_id',
'order' => 'queued_at.asc',
'limit' => $maxOpponents,
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
if (is_array($opponents) && !isset($opponents['error']) && isset($opponents[0])) {
// STEP 4: Atomically claim opponents — prevents race conditions
$claimedOpponents = [];
foreach ($opponents as $opp) {
......@@ -84,8 +131,7 @@ function handleLudoQueue(string $userId, array $input): void {
if (empty($claimedOpponents)) {
// All opponents were grabbed by others — add self to queue
$sdb->insert('ludo_queue', ['user_id' => $userId]);
jsonResponse(['queued' => true]);
ludoEnqueue($sdb, $userId);
return;
}
......@@ -133,10 +179,26 @@ function handleLudoQueue(string $userId, array $input): void {
]);
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
$sdb->insert('ludo_queue', ['user_id' => $userId]);
ludoEnqueue($sdb, $userId);
}
/** 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]);
if (isset($row['error'])) jsonError('Could not join the queue', 500);
jsonResponse(['queued' => true]);
}
......@@ -149,7 +211,10 @@ function handleLudoStatus(string $userId): void {
'limit' => 1
]);
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])) {
// The placeholder is a claim still being built, not a match: keep waiting
// and keep the row, or the real match id that follows lands on nothing.
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])
&& $entry[0]['match_id'] !== LUDO_CLAIM_PLACEHOLDER) {
$matchId = $entry[0]['match_id'];
// Clean queue
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]);
......@@ -159,10 +224,10 @@ function handleLudoStatus(string $userId): void {
$players = [];
$playerCount = 4;
if (!empty($matches) && !isset($matches['error'])) {
$players = json_decode($matches[0]['players'] ?? '[]', true);
$players = ludoJson($matches[0]['players'] ?? null);
$playerCount = intval($matches[0]['player_count'] ?? 4);
}
$playerIndex = array_search($userId, $players);
$playerIndex = array_search($userId, $players, true);
jsonResponse([
'match_id' => $matchId,
......@@ -252,19 +317,35 @@ function handleLudoComplete(string $userId, array $input): void {
$sdb = supabaseService();
// Mark match as completed
$sdb->update('ludo_matches', [
// Mark match as completed, once. Every client at the table reports the end
// of the game, and each call used to pay the rewards again. The update only
// matches a row that is not completed yet, so exactly one call wins it.
$updated = $sdb->update('ludo_matches', [
'status' => 'completed',
'completed_at' => date('c'),
'result' => $reason,
'winners' => is_array($winners) ? $winners : []
], ['id' => 'eq.' . $matchId]);
// Call universal rewards function
], ['id' => 'eq.' . $matchId, 'status' => 'neq.completed']);
if (isset($updated['error'])) jsonError('Could not complete the match', 500);
if (empty($updated)) jsonResponse(['success' => true, 'already_completed' => true]);
// p_winners is jsonb: pass the array itself. json_encode() sent a JSON
// string and the function failed with "cannot get array length of a
// scalar", so ludo never paid. Bot seats go as null (skipped; a 'bot_' id
// fails the function's uuid cast) so finishing positions are kept.
// Only ids of people actually seated in this match can be paid: the client
// sends bot seats as 'bot_<n>' and has sent placeholders like 'unknown'.
$seated = [];
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
if (is_array($rows) && !isset($rows['error']) && !empty($rows)) $seated = ludoJson($rows[0]['players'] ?? null);
$winners = array_map(
fn($w) => (is_string($w) && in_array($w, $seated, true) && preg_match('/^[0-9a-f-]{36}$/i', $w)) ? $w : null,
is_array($winners) ? array_values($winners) : []
);
$result = supabaseRpc('complete_match', [
'p_game_key' => 'ludo',
'p_match_id' => $matchId,
'p_winners' => json_encode($winners),
'p_winners' => $winners,
'p_reason' => $reason
]);
......@@ -295,10 +376,9 @@ function handleLudoLeave(string $userId, array $input): void {
$matches = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
$rawPlayers = $matches[0]['players'] ?? [];
$players = is_string($rawPlayers) ? json_decode($rawPlayers, true) : $rawPlayers;
$rawGs = $matches[0]['game_state'] ?? [];
$gameState = is_string($rawGs) ? (json_decode($rawGs, true) ?: []) : (is_array($rawGs) && !array_is_list($rawGs) ? $rawGs : []);
$players = ludoJson($matches[0]['players'] ?? null);
$gameState = ludoJson($matches[0]['game_state'] ?? null);
if (array_is_list($gameState) && !empty($gameState)) $gameState = [];
// Mark player as bot-replaced in game_state (reversible within 30s)
$gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
......
......@@ -131,20 +131,13 @@ function onLogout() {
}
async function loadModules() {
await import('./modules/auth/mod.js');
await import('./modules/play/mod.js');
await import('./modules/chess/mod.js');
await import('./modules/domino/mod.js');
await import('./modules/ludo/mod.js');
await import('./modules/backgammon/mod.js');
await import('./modules/rank/mod.js');
await import('./modules/social/mod.js');
await import('./modules/tournaments/mod.js');
await import('./modules/profile/mod.js');
await import('./modules/rewards/mod.js');
await import('./modules/shop/mod.js');
await import('./modules/puzzles/mod.js');
await import('./modules/org/mod.js');
// One game failing to load must never take the whole app down with it: each
// module is loaded on its own and a failure is logged, not thrown.
const mods = ['auth', 'play', 'chess', 'domino', 'ludo', 'backgammon', 'rank', 'social',
'tournaments', 'profile', 'rewards', 'shop', 'puzzles', 'org'];
await Promise.all(mods.map(m => import(`./modules/${m}/mod.js`).catch(e => {
console.error(`[boot] module ${m} failed to load:`, e);
})));
}
boot();
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