Commit 6517bd2d authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: eliminate jsonb double-encoding that broke multiplayer sync

The root cause: PHP's json_encode() was used on jsonb fields before
passing to SupabaseClient->insert/update, which json_encodes the entire
payload. This double-encoded game_state into a JSON string scalar.

When the match_heartbeat RPC did `jsonb_string || jsonb_object`,
PostgreSQL created an array instead of merging, corrupting game_state.
The polling logic then couldn't find move_count or last_move in the
array, so moves never synced between players.

Fix: pass PHP arrays directly for all jsonb columns (game_state, moves,
positions, scores, winners, hands, boneyard). The Supabase client
handles encoding once at the HTTP layer.

Also: when reading game_state back from the DB, handle the case where
PostgREST returns it as a decoded array/object (not a string) since
jsonb columns are deserialized automatically.

Affected: chess, ludo, domino — all multiplayer match creation and
move/state updates.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 7f4f57a5
......@@ -37,14 +37,14 @@ function handleStart(string $userId, array $input): void {
$matchData = [
'status' => 'in_progress',
'players' => json_encode($players),
'players' => $players,
'current_turn' => 0,
'board' => '[]',
'hands' => '{}',
'boneyard' => '[]',
'moves' => '[]',
'scores' => json_encode(['0' => 0, '1' => 0]),
'game_state' => json_encode(['move_count' => 0, 'round' => 1, 'mode' => $mode, 'bot_level' => $botLevel]),
'board' => [],
'hands' => (object)[],
'boneyard' => [],
'moves' => [],
'scores' => ['0' => 0, '1' => 0],
'game_state' => ['move_count' => 0, 'round' => 1, 'mode' => $mode, 'bot_level' => $botLevel],
'host_id' => $userId,
'created_by' => $userId
];
......@@ -95,14 +95,14 @@ function handleQueue(string $userId, array $input): void {
$matchData = [
'status' => 'in_progress',
'players' => json_encode($players),
'players' => $players,
'current_turn' => 0,
'board' => '[]',
'hands' => '{}',
'boneyard' => '[]',
'moves' => '[]',
'scores' => json_encode(['0' => 0, '1' => 0]),
'game_state' => json_encode(['move_count' => 0, 'round' => 1, 'mode' => 'live']),
'board' => [],
'hands' => (object)[],
'boneyard' => [],
'moves' => [],
'scores' => ['0' => 0, '1' => 0],
'game_state' => ['move_count' => 0, 'round' => 1, 'mode' => 'live'],
'host_id' => $opponent['user_id']
];
......@@ -164,7 +164,7 @@ function handleMove(string $userId, array $input): void {
if (isset($input['board'])) {
$board = $input['board'];
$update['board'] = is_string($board) ? $board : json_encode($board);
$update['board'] = is_string($board) ? json_decode($board, true) : $board;
}
if (isset($input['hands'])) {
$newHands = is_string($input['hands']) ? json_decode($input['hands'], true) : $input['hands'];
......@@ -172,30 +172,33 @@ function handleMove(string $userId, array $input): void {
$existingMatch = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'hands', 'limit' => 1]);
$existingHands = [];
if (!empty($existingMatch) && !isset($existingMatch['error']) && !empty($existingMatch[0]['hands'])) {
$existingHands = is_string($existingMatch[0]['hands']) ? json_decode($existingMatch[0]['hands'], true) : $existingMatch[0]['hands'];
$raw = $existingMatch[0]['hands'];
$existingHands = is_string($raw) ? json_decode($raw, true) : $raw;
}
$mergedHands = array_merge($existingHands ?: [], $newHands ?: []);
$update['hands'] = json_encode($mergedHands);
$update['hands'] = !empty($mergedHands) ? $mergedHands : (object)[];
}
if (isset($input['boneyard'])) {
$boneyard = $input['boneyard'];
$update['boneyard'] = is_string($boneyard) ? $boneyard : json_encode($boneyard);
$update['boneyard'] = is_string($boneyard) ? json_decode($boneyard, true) : $boneyard;
}
if (isset($input['current_turn'])) {
$update['current_turn'] = intval($input['current_turn']);
}
if (isset($input['scores'])) {
$update['scores'] = is_string($input['scores']) ? $input['scores'] : json_encode($input['scores']);
$scores = $input['scores'];
$update['scores'] = is_string($scores) ? json_decode($scores, true) : $scores;
}
if (isset($input['game_state'])) {
$existing = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
$existingGs = [];
if (!empty($existing) && !isset($existing['error']) && !empty($existing[0]['game_state'])) {
$existingGs = json_decode($existing[0]['game_state'], true) ?: [];
$raw = $existing[0]['game_state'];
$existingGs = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) && !array_is_list($raw) ? $raw : []);
}
$newGs = is_string($input['game_state']) ? json_decode($input['game_state'], true) : $input['game_state'];
$merged = array_merge($existingGs, $newGs ?: []);
$update['game_state'] = json_encode($merged);
$update['game_state'] = $merged;
}
if (isset($input['status'])) {
$update['status'] = $input['status'];
......@@ -277,7 +280,8 @@ function handleResign(string $userId, array $input): void {
$match = $matches[0];
if ($match['status'] === 'completed') jsonError('Match already ended');
$players = json_decode($match['players'] ?? '[]', true);
$rawPlayers = $match['players'] ?? [];
$players = is_string($rawPlayers) ? json_decode($rawPlayers, true) : $rawPlayers;
$playerIndex = array_search($userId, $players);
$winnerIndex = $playerIndex === 0 ? 1 : 0;
$winnerId = $players[$winnerIndex] ?? null;
......@@ -286,7 +290,7 @@ function handleResign(string $userId, array $input): void {
'status' => 'completed',
'result' => 'player' . ($winnerIndex + 1) . '_wins',
'winner_id' => $winnerId,
'game_state' => json_encode(['resigned_by' => $userId, 'move_count' => 999]),
'game_state' => ['resigned_by' => $userId, 'move_count' => 999],
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
......@@ -306,7 +310,7 @@ function handleComplete(string $userId, array $input): void {
'status' => 'completed',
'completed_at' => date('c'),
'result' => $reason,
'winners' => json_encode($winners),
'winners' => is_array($winners) ? $winners : [],
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
......@@ -344,7 +348,8 @@ function handleDominoLeave(string $userId, array $input): void {
$matches = $sdb->get('domino_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);
$rawGs = $matches[0]['game_state'] ?? [];
$gameState = is_string($rawGs) ? (json_decode($rawGs, true) ?: []) : (is_array($rawGs) && !array_is_list($rawGs) ? $rawGs : []);
$gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
$gameState['bot_replaced'][] = [
......@@ -355,7 +360,7 @@ function handleDominoLeave(string $userId, array $input): void {
];
$sdb->update('domino_matches', [
'game_state' => json_encode($gameState),
'game_state' => $gameState,
'last_activity' => date('c')
], ['id' => 'eq.' . $matchId]);
......
......@@ -318,12 +318,12 @@ if ($method === 'POST') {
'status' => 'waiting',
'players' => json_encode($players),
'current_turn' => 0,
'board' => '[]',
'hands' => '{}',
'boneyard' => '[]',
'moves' => '[]',
'scores' => json_encode(['0' => 0, '1' => 0]),
'game_state' => json_encode(['mode' => 'live', 'move_count' => 0, 'round' => 1, 'invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()]),
'board' => [],
'hands' => (object)[],
'boneyard' => [],
'moves' => [],
'scores' => ['0' => 0, '1' => 0],
'game_state' => ['mode' => 'live', 'move_count' => 0, 'round' => 1, 'invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()],
'host_id' => $userId,
'created_by' => $userId
]);
......@@ -344,7 +344,7 @@ if ($method === 'POST') {
'players' => json_encode($players),
'current_turn' => 0,
'board_state' => '{}',
'game_state' => json_encode(['mode' => 'live', 'invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()]),
'game_state' => ['mode' => 'live', 'invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()],
'host_id' => $userId
]);
......@@ -379,9 +379,9 @@ if ($method === 'POST') {
'white_time_remaining_ms' => $initialTime,
'black_time_remaining_ms' => $initialTime,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]',
'moves' => [],
'move_count' => 0,
'game_state' => json_encode(['invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()]),
'game_state' => ['invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()],
'is_rated' => false,
'created_at' => gmdate('c')
]);
......
......@@ -95,8 +95,8 @@ function handleStart($db, string $userId, array $input): void {
'status' => 'in_progress',
'time_control' => $timeControl,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]',
'metadata' => json_encode(['mode' => $mode, 'bot_id' => $botId])
'moves' => [],
'metadata' => ['mode' => $mode, 'bot_id' => $botId]
];
$result = $db->insert('matches', $data);
......@@ -112,22 +112,33 @@ function handleGameMove($db, string $userId, array $input): void {
$update = ['updated_at' => date('c'), 'last_activity' => date('c')];
if (!empty($input['fen'])) $update['current_fen'] = $input['fen'];
if (!empty($input['move'])) $update['moves'] = $input['move'];
if (!empty($input['move'])) {
$moves = is_string($input['move']) ? json_decode($input['move'], true) : $input['move'];
$update['moves'] = is_array($moves) ? $moves : [];
}
if (isset($input['move_count'])) $update['move_count'] = intval($input['move_count']);
if (isset($input['white_time_remaining_ms'])) $update['white_time_remaining_ms'] = intval($input['white_time_remaining_ms']);
if (isset($input['black_time_remaining_ms'])) $update['black_time_remaining_ms'] = intval($input['black_time_remaining_ms']);
// Merge game_state instead of overwriting — preserves emotes, draw offers, etc.
if (!empty($input['game_state'])) {
$newState = json_decode($input['game_state'], true) ?: [];
$newState = is_string($input['game_state']) ? (json_decode($input['game_state'], true) ?: []) : $input['game_state'];
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
$existing = [];
if (is_array($matches) && !empty($matches) && !isset($matches['error'])) {
$raw = $matches[0]['game_state'] ?? null;
if ($raw) $existing = is_array($raw) ? $raw : (json_decode($raw, true) ?: []);
if ($raw) {
if (is_string($raw)) {
$existing = json_decode($raw, true) ?: [];
} elseif (is_array($raw) && !array_is_list($raw)) {
$existing = $raw;
} else {
$existing = [];
}
}
}
$merged = array_merge($existing, $newState);
$update['game_state'] = json_encode($merged);
$update['game_state'] = $merged;
}
$result = $sdb->update('matches', $update, ['id' => 'eq.' . $matchId]);
......@@ -172,7 +183,7 @@ function handleDraw($db, string $userId, array $input): void {
'status' => 'completed',
'result' => 'draw',
'ended_at' => date('c'),
'game_state' => json_encode(['draw_accepted' => true])
'game_state' => ['draw_accepted' => true]
], ['id' => 'eq.' . $matchId]);
jsonResponse(['result' => 'draw']);
......@@ -358,7 +369,8 @@ function handleChessLeave(string $userId, array $input): void {
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'white_player_id,black_player_id,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
$gameState = json_decode($matches[0]['game_state'] ?? '{}', true);
$raw = $matches[0]['game_state'] ?? null;
$gameState = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) && !array_is_list($raw) ? $raw : []);
$gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
$gameState['bot_replaced'][] = [
'player_id' => $userId,
......@@ -367,7 +379,7 @@ function handleChessLeave(string $userId, array $input): void {
];
$sdb->update('matches', [
'game_state' => json_encode($gameState),
'game_state' => $gameState,
'last_activity' => date('c')
], ['id' => 'eq.' . $matchId]);
......
......@@ -75,13 +75,13 @@ function handleLudoQueue(string $userId, array $input): void {
'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'status' => 'in_progress',
'player_count' => $requestedPlayers,
'players' => json_encode($players),
'players' => $players,
'current_turn' => 0,
'dice_value' => null,
'positions' => json_encode($positions),
'moves' => '[]',
'winners' => '[]',
'game_state' => json_encode(['turn_count' => 0]),
'positions' => $positions,
'moves' => [],
'winners' => [],
'game_state' => ['turn_count' => 0],
'host_id' => $opponents[0]['user_id']
];
......@@ -151,7 +151,7 @@ function handleLudoMove(string $userId, array $input): void {
$update = [];
if (isset($input['positions'])) {
$pos = $input['positions'];
$update['positions'] = is_string($pos) ? $pos : json_encode($pos);
$update['positions'] = is_string($pos) ? json_decode($pos, true) : $pos;
}
if (isset($input['current_turn'])) $update['current_turn'] = intval($input['current_turn']);
if (isset($input['dice_value'])) $update['dice_value'] = intval($input['dice_value']);
......@@ -160,9 +160,12 @@ function handleLudoMove(string $userId, array $input): void {
if (is_string($gs)) $gs = json_decode($gs, true);
$gs['turn_started_at'] = date('c');
$gs['last_mover'] = $userId;
$update['game_state'] = json_encode($gs);
$update['game_state'] = $gs;
}
if (isset($input['winners'])) {
$w = $input['winners'];
$update['winners'] = is_string($w) ? json_decode($w, true) : $w;
}
if (isset($input['winners'])) $update['winners'] = json_encode($input['winners']);
if (isset($input['status'])) $update['status'] = $input['status'];
$update['last_activity'] = date('c');
......@@ -222,7 +225,7 @@ function handleLudoComplete(string $userId, array $input): void {
'status' => 'completed',
'completed_at' => date('c'),
'result' => $reason,
'winners' => json_encode($winners)
'winners' => is_array($winners) ? $winners : []
], ['id' => 'eq.' . $matchId]);
// Call universal rewards function
......@@ -260,8 +263,10 @@ 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');
$players = json_decode($matches[0]['players'] ?? '[]', true);
$gameState = json_decode($matches[0]['game_state'] ?? '{}', true);
$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 : []);
// Mark player as bot-replaced in game_state (reversible within 30s)
$gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
......@@ -273,7 +278,7 @@ function handleLudoLeave(string $userId, array $input): void {
];
$sdb->update('ludo_matches', [
'game_state' => json_encode($gameState),
'game_state' => $gameState,
'last_activity' => date('c')
], ['id' => 'eq.' . $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