Commit d951ad46 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(social, multiplayer): working chat and groups, reliable challenges, true...

feat(social, multiplayer): working chat and groups, reliable challenges, true VS, resume sync, emotes from the sender

Social: chat sides came from a stored user id that Google sign-in never set,
so every message looked like the other player's; the server now marks each
message mine and the client reads its id from the token (core/me.js). Chat
rebuilt (my bubbles inline-end, grouping, times, seen, typing, reactions,
quick replies), live delivery fixed, groups fully working (join by code or
link, admins, unread, owner hand-over), presence, dock/bell/friend badges,
friend notifications, news feed, invite links, profile actions.
tests/play/social.mjs 106/106.

Challenges: the invite was only seen on the Friends screen; a global prompt
(play/invite-watch.js) now shows it everywhere with a countdown. Chess
declines failed on the result enum; the handshake lives in match_invites
with conditional updates, expiry and challenger presence. VS: self vs self
came from the stored id and profile.php answering with the caller when the
id was missing; every player's card now travels with the hand-off.
Backgammon simultaneous searches and ludo table sizes fixed.
tests/play/challenge.mjs 72/72 (4 games x 6 scenarios x 3 rounds).

Resume: leaving the screen dropped the saved match; your own sleep was
blamed on the opponent; heartbeats raced moves inside game_state (now
match_presence); moves on a bad line were dropped (outbox) and late retries
rolled boards back (stale steps refused); frozen loops restart on return.
Emotes appear at the sender's plate on both screens, per seat in ludo.
Resume and emote scenarios pass in all four suites.

Migrations applied: social, match-invites, match-presence.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 2c2cdc53
<?php
/**
* News: what my friends (and I) did lately at the table.
*
* Nothing in the app ever wrote activity_feed, so the feed is read straight
* from where the facts already live: finished games in each game's table,
* completed achievements and new friendships. Rows a writer does put in
* activity_feed are merged in too.
*
* GET ?limit= newest first, the last 14 days
*
* Each item: id (stable, for cheers), actor (profile), action, metadata
* {game_key, opponent, rating_change, achievement, friend}, created_at.
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
......@@ -7,61 +20,196 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/social.php';
$token = requireAuth();
$userId = getUserId($token);
$db = supabase($token);
if (!$userId) jsonError('Invalid user', 401);
$sdb = supabaseService();
$limit = min(intval($_GET['limit'] ?? 20), 50);
$limit = max(1, min(intval($_GET['limit'] ?? 30), 60));
$since = gmdate('c', time() - 14 * 86400);
// Get friend IDs
$friends = $db->get('friendships', [
'or' => "(requester_id.eq.{$userId},addressee_id.eq.{$userId})",
'status' => 'eq.accepted',
'select' => 'requester_id,addressee_id'
]);
$friends = friendIdsOf($sdb, $userId);
$people = array_values(array_unique(array_merge([$userId], $friends)));
$set = array_flip($people);
$list = implode(',', $people);
$items = [];
function rowsOf($r): array { return is_array($r) && !isset($r['error']) ? $r : []; }
// Chess (and anything else on the matches table): both seats are columns.
foreach (rowsOf($sdb->get('matches', [
'or' => "(white_player_id.in.({$list}),black_player_id.in.({$list}))",
'status' => 'eq.completed',
'completed_at' => 'gt.' . $since,
'select' => 'id,game_key,white_player_id,black_player_id,result,bot_id,rating_change_white,rating_change_black,completed_at,match_type',
'order' => 'completed_at.desc',
'limit' => 80
])) as $m) {
$res = $m['result'] ?? '';
if (!in_array($res, ['white_wins', 'black_wins', 'draw'], true)) continue;
foreach (['white', 'black'] as $side) {
$pid = $m[$side . '_player_id'];
if (!$pid || !isset($set[$pid])) continue;
$opp = $m[($side === 'white' ? 'black' : 'white') . '_player_id'];
$won = $res === $side . '_wins';
if ($res !== 'draw' && !$won) continue; // the feed celebrates; losses stay private
$items[] = [
'id' => 'm:' . $m['id'] . ':' . substr($pid, 0, 8),
'actor_id' => $pid,
'action' => $res === 'draw' ? 'game_draw' : 'game_win',
'metadata' => [
'game_key' => $m['game_key'] ?: 'chess',
'opponent_id' => $opp,
'bot' => $m['bot_id'] ?: null,
'rating_change' => $m['rating_change_' . $side],
],
'created_at' => $m['completed_at'],
];
}
}
$friendIds = [$userId];
if (is_array($friends) && !isset($friends['error'])) {
foreach ($friends as $f) {
$friendIds[] = $f['requester_id'] === $userId ? $f['addressee_id'] : $f['requester_id'];
// Domino and backgammon record the winner.
foreach (['domino' => 'domino_matches', 'backgammon' => 'backgammon_matches'] as $key => $table) {
foreach (rowsOf($sdb->get($table, [
'winner_id' => "in.({$list})",
'status' => 'eq.completed',
'completed_at' => 'gt.' . $since,
'select' => 'id,winner_id,players,completed_at',
'order' => 'completed_at.desc',
'limit' => 40
])) as $m) {
$players = is_array($m['players'] ?? null) ? $m['players'] : (json_decode($m['players'] ?? '[]', true) ?: []);
$opp = null;
foreach ($players as $p) { $id = is_array($p) ? ($p['id'] ?? null) : $p; if ($id && $id !== $m['winner_id']) { $opp = $id; break; } }
$items[] = [
'id' => substr($key, 0, 1) . ':' . $m['id'],
'actor_id' => $m['winner_id'],
'action' => 'game_win',
'metadata' => ['game_key' => $key, 'opponent_id' => isUuid($opp) ? $opp : null],
'created_at' => $m['completed_at'],
];
}
}
$idList = implode(',', $friendIds);
// Ludo keeps a podium; the first place is the win.
foreach (rowsOf($sdb->get('ludo_matches', [
'status' => 'eq.completed',
'completed_at' => 'gt.' . $since,
'select' => 'id,winners,players,completed_at',
'order' => 'completed_at.desc',
'limit' => 60
])) as $m) {
$winners = is_array($m['winners'] ?? null) ? $m['winners'] : (json_decode($m['winners'] ?? '[]', true) ?: []);
$first = $winners[0] ?? null;
if (is_array($first)) $first = $first['id'] ?? $first['player_id'] ?? null;
if (!is_string($first) || !isset($set[$first])) continue;
$items[] = [
'id' => 'l:' . $m['id'],
'actor_id' => $first,
'action' => 'game_win',
'metadata' => ['game_key' => 'ludo'],
'created_at' => $m['completed_at'],
];
}
// Get activity from friends + self
$sdb = supabaseService();
$activity = $sdb->get('activity_feed', [
'actor_id' => "in.({$idList})",
'select' => 'id,actor_id,action,target_type,target_id,metadata,created_at',
// Achievements unlocked.
$ach = rowsOf($sdb->get('player_achievements', [
'player_id' => "in.({$list})",
'completed' => 'is.true',
'completed_at' => 'gt.' . $since,
'select' => 'id,player_id,achievement_id,completed_at,achievements(name,name_ar)',
'order' => 'completed_at.desc',
'limit' => 40
]));
foreach ($ach as $a) {
$items[] = [
'id' => 'a:' . $a['id'],
'actor_id' => $a['player_id'],
'action' => 'achievement_unlock',
'metadata' => ['achievement' => $a['achievements']['name_ar'] ?? null, 'achievement_en' => $a['achievements']['name'] ?? null],
'created_at' => $a['completed_at'],
];
}
// New friendships among my circle (mine included).
foreach (rowsOf($sdb->get('friendships', [
'or' => "(requester_id.in.({$list}),addressee_id.in.({$list}))",
'status' => 'eq.accepted',
'updated_at' => 'gt.' . $since,
'select' => 'id,requester_id,addressee_id,updated_at',
'order' => 'updated_at.desc',
'limit' => 40
])) as $f) {
// Told from the side of someone I know; once per friendship.
$actor = isset($set[$f['requester_id']]) ? $f['requester_id'] : $f['addressee_id'];
$other = $actor === $f['requester_id'] ? $f['addressee_id'] : $f['requester_id'];
$items[] = [
'id' => 'f:' . $f['id'],
'actor_id' => $actor,
'action' => 'friend_add',
'metadata' => ['friend_id' => $other],
'created_at' => $f['updated_at'],
];
}
// Anything a writer put in activity_feed.
foreach (rowsOf($sdb->get('activity_feed', [
'actor_id' => "in.({$list})",
'created_at' => 'gt.' . $since,
'select' => 'id,actor_id,action,metadata,created_at',
'order' => 'created_at.desc',
'limit' => $limit
]);
'limit' => 40
])) as $a) {
$meta = is_string($a['metadata'] ?? null) ? (json_decode($a['metadata'], true) ?: []) : ($a['metadata'] ?? []);
$items[] = ['id' => 'x:' . $a['id'], 'actor_id' => $a['actor_id'], 'action' => $a['action'], 'metadata' => $meta, 'created_at' => $a['created_at']];
}
usort($items, fn($a, $b) => tsf($b['created_at']) <=> tsf($a['created_at']));
$items = array_slice($items, 0, $limit);
// Get actor profiles
$actorIds = array_unique(array_column($activity ?: [], 'actor_id'));
// Names for everyone the rows mention.
$ids = [];
foreach ($items as $it) {
$ids[] = $it['actor_id'];
foreach (['opponent_id', 'friend_id'] as $k) if (!empty($it['metadata'][$k])) $ids[] = $it['metadata'][$k];
}
$ids = array_values(array_unique(array_filter($ids, 'isUuid')));
$profiles = [];
if (!empty($actorIds)) {
$profileList = implode(',', $actorIds);
$profileData = $sdb->get('profiles', [
'id' => "in.({$profileList})",
'select' => 'id,username,display_name,avatar_url'
]);
if (is_array($profileData) && !isset($profileData['error'])) {
foreach ($profileData as $p) {
$profiles[$p['id']] = $p;
}
}
if ($ids) {
foreach (rowsOf($sdb->get('profiles', ['id' => 'in.(' . implode(',', $ids) . ')', 'select' => 'id,username,display_name,avatar_url'])) as $p) $profiles[$p['id']] = $p;
}
// Cheers I already gave, so the GG key shows as given.
$cheered = [];
foreach (rowsOf($sdb->get('notifications', [
'type' => 'eq.cheer',
'data->>from_id' => 'eq.' . $userId,
'created_at' => 'gt.' . $since,
'select' => 'data',
'limit' => 200
])) as $n) {
$d = is_string($n['data']) ? json_decode($n['data'], true) : $n['data'];
if (!empty($d['item_id'])) $cheered[$d['item_id']] = true;
}
// Enrich activity with actor info
$result = [];
if (is_array($activity) && !isset($activity['error'])) {
foreach ($activity as $a) {
$a['actor'] = $profiles[$a['actor_id']] ?? ['username' => 'Player'];
$result[] = $a;
$out = [];
foreach ($items as $it) {
$meta = $it['metadata'];
foreach (['opponent_id' => 'opponent', 'friend_id' => 'friend'] as $k => $as) {
if (!empty($meta[$k]) && isset($profiles[$meta[$k]])) $meta[$as] = playerName($profiles[$meta[$k]]);
}
$out[] = [
'id' => $it['id'],
'actor_id' => $it['actor_id'],
'actor' => $profiles[$it['actor_id']] ?? ['id' => $it['actor_id']],
'is_me' => $it['actor_id'] === $userId,
'action' => $it['action'],
'metadata' => (object)$meta,
'cheered' => isset($cheered[$it['id']]),
'created_at' => $it['created_at'],
];
}
jsonResponse(['activity' => $result]);
jsonResponse(['activity' => $out]);
......@@ -7,6 +7,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/hand-off.php';
require_once __DIR__ . '/../includes/match-presence.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -183,7 +185,8 @@ function handleQueue(string $userId, array $input): void {
'match_id' => $matchId,
'player_index' => 1,
'players' => $players,
'color' => $colors[1]
'color' => $colors[1],
'cards' => handOffCards($sdb, $players),
]);
return;
}
......@@ -222,6 +225,7 @@ function handleStatus(string $userId): void {
$entry = $sdb->get('backgammon_queue', [
'user_id' => 'eq.' . $userId,
'select' => 'id,match_id',
'order' => 'created_at.desc',
'limit' => 1
]);
......@@ -245,24 +249,96 @@ function handleStatus(string $userId): void {
'match_id' => $matchId,
'player_index' => $playerIndex !== false ? $playerIndex : 0,
'players' => $players,
'color' => $colors[$playerIndex] ?? 'white'
'color' => $colors[$playerIndex] ?? 'white',
'cards' => handOffCards($sdb, $players),
]);
return;
}
// Still unclaimed: two players who pressed search at the same moment both
// found an empty queue and both waited, each for the other, until the
// minute ran out. The later one picks up the earlier one here.
if (!empty($entry) && !isset($entry['error']) && empty($entry[0]['match_id'])) {
$found = bgMatchFromStatus($sdb, $userId, $entry[0]['id']);
if ($found) jsonResponse($found);
}
jsonResponse(['waiting' => true]);
}
/**
* Seat the oldest fresh player waiting for the same game (variant and length)
* who queued before us. Only towards older rows, so two pollers never claim
* each other; our own row is locked first so nobody seats us elsewhere while
* the match is built. Returns our hand-off, or null.
*/
function bgMatchFromStatus($sdb, string $userId, string $myRowId): ?array {
$mine = $sdb->get('backgammon_queue', ['id' => 'eq.' . $myRowId, 'select' => 'id,variant,match_length,created_at', 'limit' => 1]);
if (!is_array($mine) || isset($mine['error']) || empty($mine) || empty($mine[0]['created_at'])) return null;
$mine = $mine[0];
$older = $sdb->get('backgammon_queue', [
'user_id' => 'neq.' . $userId,
'match_id' => 'is.null',
'variant' => 'eq.' . $mine['variant'],
'match_length' => 'eq.' . $mine['match_length'],
'and' => '(created_at.lt."' . $mine['created_at'] . '",created_at.gt."' . gmdate('c', time() - 90) . '")',
'select' => 'id,user_id',
'order' => 'created_at.asc',
'limit' => 1,
]);
if (!is_array($older) || isset($older['error']) || empty($older)) return null;
$opp = $older[0];
$release = function (string $id) use ($sdb) {
$sdb->update('backgammon_queue', ['match_id' => null], ['id' => 'eq.' . $id, 'match_id' => 'eq.' . BG_CLAIM_PLACEHOLDER]);
};
$lock = $sdb->update('backgammon_queue', ['match_id' => BG_CLAIM_PLACEHOLDER], ['id' => 'eq.' . $mine['id'], 'match_id' => 'is.null']);
if (empty($lock) || isset($lock['error'])) return null;
$claim = $sdb->update('backgammon_queue', ['match_id' => BG_CLAIM_PLACEHOLDER], ['id' => 'eq.' . $opp['id'], 'match_id' => 'is.null']);
if (empty($claim) || isset($claim['error'])) { $release($mine['id']); return null; }
// The earlier queuer is the host at seat 0, as in handleQueue.
$variant = $mine['variant'];
$matchLength = intval($mine['match_length']) ?: 1;
$players = [$opp['user_id'], $userId];
$whiteIdx = rand(0, 1);
$colors = [$whiteIdx === 0 ? 'white' : 'black', $whiteIdx === 1 ? 'white' : 'black'];
$match = $sdb->insert('backgammon_matches', [
'status' => 'in_progress',
'players' => $players,
'colors' => $colors,
'variant' => $variant,
'match_length' => $matchLength,
'scores' => [0, 0],
'current_turn' => $whiteIdx,
'game_state' => ['variant' => $variant, 'match_length' => $matchLength, 'game_number' => 1, 'cube_value' => 1, 'cube_owner' => null, 'crawford' => false],
'host_id' => $opp['user_id'],
'created_by' => $userId,
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if (!$matchId) { $release($opp['id']); $release($mine['id']); return null; }
$sdb->update('backgammon_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opp['id']]);
$sdb->delete('backgammon_queue', ['id' => 'eq.' . $mine['id']]);
return ['match_id' => $matchId, 'player_index' => 1, 'players' => $players, 'color' => $colors[1], 'cards' => handOffCards($sdb, $players)];
}
function handleMove(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$update = [];
$newSeq = null;
if (isset($input['game_state'])) {
$gs = is_string($input['game_state']) ? $input['game_state'] : json_encode($input['game_state']);
// Stored as an object (it used to go in as a JSON string, which no
// filter could look inside).
$gs = is_string($input['game_state']) ? json_decode($input['game_state'], true) : $input['game_state'];
if (is_array($gs)) {
$update['game_state'] = $gs;
if (isset($gs['seq'])) $newSeq = intval($gs['seq']);
}
}
if (isset($input['match_state'])) {
$ms = is_string($input['match_state']) ? json_decode($input['match_state'], true) : $input['match_state'];
......@@ -279,10 +355,33 @@ function handleMove(string $userId, array $input): void {
$update['last_activity'] = date('c');
$update['updated_at'] = date('c');
if (!empty($update)) {
if ($newSeq === null) {
$sdb->update('backgammon_matches', $update, ['id' => 'eq.' . $matchId]);
}
jsonResponse(['success' => true]);
}
// Each write is a full snapshot numbered by the shared log (seq), and a
// client keeps it until the server has it. A snapshot that arrives late (a
// retry whose first try had landed, a phone that slept with it in hand)
// must not put the board back under the opponent's newer one: a snapshot
// not newer than the table's is refused as stale. Read and write are one
// compare-and-set on seq.
for ($attempt = 0; $attempt < 4; $attempt++) {
$rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
if (empty($rows) || isset($rows['error'])) jsonError('Not found', 404);
$raw = $rows[0]['game_state'] ?? null;
$cur = bgJson($raw);
$curSeq = isset($cur['seq']) ? intval($cur['seq']) : null;
if ($curSeq !== null && $newSeq <= $curSeq) jsonResponse(['success' => true, 'stale' => true, 'seq' => $curSeq]);
// A row whose game_state is still a JSON string has no seq a filter can
// read: it matches "is null", and is replaced by an object.
$storedSeq = (is_array($raw) && isset($raw['seq'])) ? intval($raw['seq']) : null;
$where = ['id' => 'eq.' . $matchId, 'game_state->>seq' => $storedSeq === null ? 'is.null' : 'eq.' . $storedSeq];
$res = $sdb->update('backgammon_matches', $update, $where);
if (isset($res['error'])) jsonError('Could not save the move', 500);
if (!empty($res)) jsonResponse(['success' => true]);
}
jsonError('The table is busy, try again', 503);
}
function handleGet(string $userId, array $input): void {
......@@ -307,6 +406,9 @@ function handleGet(string $userId, array $input): void {
$match['_timeout_player'] = $timeout['player_index'] ?? null;
}
}
// Who else is at the table, when asked (a client just back from sleep or
// offline wants it at once; otherwise the heartbeat answers with it).
if (!empty($input['with_presence'])) $match['presence'] = presenceOf($matchId, $userId);
jsonResponse($match);
}
......@@ -388,13 +490,9 @@ function handleBgHeartbeat(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$result = supabaseRpc('match_heartbeat', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_player_id' => $userId
]);
jsonResponse($result ?: ['status' => 'ok']);
// One row per player in match_presence (match_heartbeat() wrote the ping
// into game_state and could undo a move that landed at the same moment).
jsonResponse(['status' => 'ok', 'others' => presenceBeat($matchId, $userId, 'backgammon')]);
}
function handleBgLeave(string $userId, array $input): void {
......
This diff is collapsed.
......@@ -7,6 +7,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/hand-off.php';
require_once __DIR__ . '/../includes/match-presence.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -214,7 +216,8 @@ function handleStatus(string $userId): void {
jsonResponse([
'match_id' => $matchId,
'player_index' => $playerIndex !== false ? $playerIndex : 0,
'players' => $players
'players' => $players,
'cards' => handOffCards($sdb, $players),
]);
}
......@@ -248,7 +251,7 @@ function dominoCreateMatch($sdb, string $hostId, string $userId): ?array {
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if (!$matchId) return null;
return ['match_id' => $matchId, 'player_index' => 1, 'players' => $players, 'color' => 'player2'];
return ['match_id' => $matchId, 'player_index' => 1, 'players' => $players, 'color' => 'player2', 'cards' => handOffCards($sdb, $players)];
}
/**
......@@ -293,24 +296,43 @@ function handleMove(string $userId, array $input): void {
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$update = [];
$newGs = null;
if (isset($input['game_state'])) {
$newGs = is_string($input['game_state']) ? json_decode($input['game_state'], true) : $input['game_state'];
if (!is_array($newGs)) $newGs = [];
}
$newSeq = ($newGs !== null && isset($newGs['seq'])) ? intval($newGs['seq']) : null;
// Every write carries the table's step number (game_state.seq), and a
// client keeps a write until the server has it. A write that arrives late
// (a retry after a timeout whose first try had in fact landed, a phone
// that slept with it in hand) must not roll the table back over what the
// other player did meanwhile: a step not newer than the table's is
// refused as stale. The read and the write are one compare-and-set on
// the step, so two writes can never both win.
for ($attempt = 0; $attempt < 4; $attempt++) {
$rows = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'hands,game_state', 'limit' => 1]);
if (empty($rows) || isset($rows['error'])) jsonError('Not found', 404);
$row = $rows[0];
$raw = $row['game_state'] ?? null;
$existingGs = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) && !array_is_list($raw) ? $raw : []);
$curSeq = isset($existingGs['seq']) ? intval($existingGs['seq']) : null;
if ($newSeq !== null && $curSeq !== null && $newSeq <= $curSeq) {
jsonResponse(['success' => true, 'stale' => true, 'seq' => $curSeq]);
}
$update = [];
if (isset($input['board'])) {
$board = $input['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'];
// Merge with existing hands to preserve other player's data
$existingMatch = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'hands', 'limit' => 1]);
$existingHands = [];
if (!empty($existingMatch) && !isset($existingMatch['error']) && !empty($existingMatch[0]['hands'])) {
$raw = $existingMatch[0]['hands'];
$existingHands = is_string($raw) ? json_decode($raw, true) : $raw;
}
// Merge with the stored hands: each seat writes its own.
$rawHands = $row['hands'] ?? null;
$existingHands = is_string($rawHands) ? (json_decode($rawHands, true) ?: []) : (is_array($rawHands) ? $rawHands : []);
// 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.
// array_merge renumbers integer keys.
$mergedHands = array_replace($existingHands ?: [], $newHands ?: []);
$update['hands'] = !empty($mergedHands) ? $mergedHands : (object)[];
}
......@@ -318,41 +340,26 @@ function handleMove(string $userId, array $input): void {
$boneyard = $input['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['current_turn'])) $update['current_turn'] = intval($input['current_turn']);
if (isset($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'])) {
$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'] = $merged;
}
if (isset($input['status'])) {
$update['status'] = $input['status'];
}
if (isset($input['winner_id'])) {
$update['winner_id'] = $input['winner_id'];
}
if (isset($input['result'])) {
$update['result'] = $input['result'];
}
if ($newGs !== null) $update['game_state'] = array_merge($existingGs, $newGs);
if (isset($input['status'])) $update['status'] = $input['status'];
if (isset($input['winner_id'])) $update['winner_id'] = $input['winner_id'];
if (isset($input['result'])) $update['result'] = $input['result'];
$update['last_activity'] = date('c');
if (!empty($update)) {
$update['updated_at'] = date('c');
$sdb->update('domino_matches', $update, ['id' => 'eq.' . $matchId]);
$where = ['id' => 'eq.' . $matchId];
if ($newSeq !== null) $where['game_state->>seq'] = $curSeq === null ? 'is.null' : 'eq.' . $curSeq;
$res = $sdb->update('domino_matches', $update, $where);
if (isset($res['error'])) jsonError('Could not save the move', 500);
if (!empty($res) || $newSeq === null) jsonResponse(['success' => true]);
// Someone else wrote in between: read again and decide again.
}
jsonResponse(['success' => true]);
jsonError('The table is busy, try again', 503);
}
function handleGet(string $userId, array $input): void {
......@@ -401,6 +408,9 @@ function handleGet(string $userId, array $input): void {
}
}
unset($response['hands']);
// Who else is at the table, when asked (a client just back from sleep or
// offline wants it at once; otherwise the heartbeat answers with it).
if (!empty($input['with_presence'])) $response['presence'] = presenceOf($matchId, $userId);
jsonResponse($response);
}
......@@ -487,13 +497,9 @@ function handleDominoHeartbeat(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$result = supabaseRpc('match_heartbeat', [
'p_game_key' => 'domino',
'p_match_id' => $matchId,
'p_player_id' => $userId
]);
jsonResponse($result ?: ['status' => 'ok']);
// One row per player in match_presence (match_heartbeat() wrote the ping
// into game_state and could undo a move that landed at the same moment).
jsonResponse(['status' => 'ok', 'others' => presenceBeat($matchId, $userId, 'domino')]);
}
function handleDominoLeave(string $userId, array $input): void {
......
This diff is collapsed.
......@@ -7,6 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/match-presence.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
......@@ -80,6 +81,9 @@ function handleGet($db, string $userId, array $input): void {
$match['opponent_id'] = ($match['white_player_id'] === $userId)
? $match['black_player_id']
: $match['white_player_id'];
// Who else is at the table, when asked (a client just back from sleep or
// offline wants it at once; otherwise the heartbeat answers with it).
if (!empty($input['with_presence'])) $match['presence'] = presenceOf($matchId, $userId);
jsonResponse($match);
}
......@@ -809,28 +813,10 @@ function handleChessHeartbeat(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
supabaseRpc('match_heartbeat', [
'p_game_key' => 'chess',
'p_match_id' => $matchId,
'p_player_id' => $userId
]);
// Also stamp the heartbeat into game_state, keyed by player.
//
// This is the signal the opponent's client uses to tell "they are still
// here, just thinking" from "they have gone". Without it, disconnect
// detection has nothing but move activity to go on and would accuse a player
// of disconnecting every time they thought for more than thirty seconds.
// Merged (not written) so it cannot clobber a draw offer or an emote.
// The key is flat and per-player — merge_game_state is a shallow merge, so a
// nested "heartbeats" object would have each player's beat overwrite the other's.
supabaseRpc('merge_game_state', [
'p_table' => 'matches',
'p_match_id' => $matchId,
'p_patch' => ['hb_' . $userId => round(microtime(true) * 1000)],
]);
jsonResponse(['status' => 'ok']);
// One row per player in match_presence. The heartbeat used to go into
// game_state (match_heartbeat() plus an hb_<id> key), where it raced the
// moves and the draw offers kept there.
jsonResponse(['status' => 'ok', 'others' => presenceBeat($matchId, $userId, 'chess')]);
}
function handleChessLeave(string $userId, array $input): void {
......
This diff is collapsed.
......@@ -7,6 +7,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/hand-off.php';
require_once __DIR__ . '/../includes/match-presence.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -109,7 +111,7 @@ 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.
$opponents = ludoFindOpponents($sdb, $userId, $requestedPlayers - 1, null);
$opponents = ludoFindOpponents($sdb, $userId, $requestedPlayers - 1, null, $requestedPlayers);
if ($opponents) {
// STEP 4: Atomically claim opponents — prevents race conditions
......@@ -136,7 +138,7 @@ function handleLudoQueue(string $userId, array $input): void {
* 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 {
function ludoFindOpponents($sdb, string $userId, int $limit, ?string $olderThan, ?int $playerCount = null): array {
$params = [
'user_id' => 'neq.' . $userId,
'match_id' => 'is.null',
......@@ -145,6 +147,9 @@ function ludoFindOpponents($sdb, string $userId, int $limit, ?string $olderThan)
'limit' => $olderThan === null ? $limit : 20,
];
if ($olderThan !== null) $params['queued_at'] = 'lt.' . $olderThan;
// Only players who asked for the same table: a two-player search used to
// seat someone waiting for four at a two-seat table.
if ($playerCount !== null) $params['player_count'] = 'eq.' . $playerCount;
$rows = $sdb->get('ludo_queue', $params);
if (!is_array($rows) || isset($rows['error'])) return [];
if ($olderThan === null) return array_values($rows);
......@@ -229,6 +234,7 @@ function ludoBuildMatch($sdb, string $userId, array $claimedOpponents, int $requ
'player_index' => 1,
'player_count' => $requestedPlayers,
'players' => $players,
'cards' => handOffCards($sdb, $players),
];
}
......@@ -270,7 +276,8 @@ function handleLudoStatus(string $userId): void {
'match_id' => $matchId,
'player_index' => $playerIndex !== false ? $playerIndex : 0,
'player_count' => $playerCount,
'players' => $players
'players' => $players,
'cards' => handOffCards($sdb, $players),
]);
}
......@@ -280,7 +287,7 @@ function handleLudoStatus(string $userId): void {
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']);
$opponents = ludoFindOpponents($sdb, $userId, $requestedPlayers - 1, $own['queued_at'], $requestedPlayers);
// Hold our own row first, so nobody seats us elsewhere while we build.
if ($opponents && ludoClaim($sdb, [$own])) {
$claimed = ludoClaim($sdb, $opponents);
......@@ -308,12 +315,15 @@ function handleLudoMove(string $userId, array $input): void {
}
if (isset($input['current_turn'])) $update['current_turn'] = intval($input['current_turn']);
if (isset($input['dice_value'])) $update['dice_value'] = intval($input['dice_value']);
$newTc = null;
if (isset($input['game_state'])) {
$gs = $input['game_state'];
if (is_string($gs)) $gs = json_decode($gs, true);
if (!is_array($gs)) $gs = [];
$gs['turn_started_at'] = date('c');
$gs['last_mover'] = $userId;
$update['game_state'] = $gs;
if (isset($gs['turn_count'])) $newTc = intval($gs['turn_count']);
}
if (isset($input['winners'])) {
$w = $input['winners'];
......@@ -323,10 +333,34 @@ function handleLudoMove(string $userId, array $input): void {
$update['last_activity'] = date('c');
if (!empty($update)) {
if ($newTc === null) {
$sdb->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]);
}
jsonResponse(['success' => true]);
}
// Every step on the table is numbered (game_state.turn_count) and a client
// keeps a step until the server has it. A step that arrives late (a retry
// whose first try had landed, a phone that slept with it in hand) must not
// roll the table back over what happened since: a step not newer than the
// table's is refused as stale, and the client lays the row out instead.
// Read and write are one compare-and-set on the step number.
for ($attempt = 0; $attempt < 4; $attempt++) {
$rows = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'game_state', 'limit' => 1]);
if (empty($rows) || isset($rows['error'])) jsonError('Not found', 404);
$cur = ludoJson($rows[0]['game_state'] ?? null);
$curTc = isset($cur['turn_count']) ? intval($cur['turn_count']) : null;
if ($curTc !== null && $newTc <= $curTc) jsonResponse(['success' => true, 'stale' => true, 'turn_count' => $curTc]);
// A player who left is marked by the leave action (bot_replaced); a
// step written over it must not wipe the mark before the others saw it.
if (!empty($cur['bot_replaced']) && empty($update['game_state']['bot_replaced'])) {
$update['game_state']['bot_replaced'] = $cur['bot_replaced'];
}
$where = ['id' => 'eq.' . $matchId, 'game_state->>turn_count' => $curTc === null ? 'is.null' : 'eq.' . $curTc];
$res = $sdb->update('ludo_matches', $update, $where);
if (isset($res['error'])) jsonError('Could not save the move', 500);
if (!empty($res)) jsonResponse(['success' => true]);
}
jsonError('The table is busy, try again', 503);
}
function handleLudoGet(string $userId, array $input): void {
......@@ -361,6 +395,9 @@ function handleLudoGet(string $userId, array $input): void {
}
}
}
// Who else is at the table, when asked (a client just back from sleep or
// offline wants it at once; otherwise the heartbeat answers with it).
if (!empty($input['with_presence'])) $match['presence'] = presenceOf($matchId, $userId);
jsonResponse($match);
}
......@@ -488,13 +525,9 @@ function handleHeartbeat(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$result = supabaseRpc('match_heartbeat', [
'p_game_key' => 'ludo',
'p_match_id' => $matchId,
'p_player_id' => $userId
]);
jsonResponse($result ?: ['status' => 'ok']);
// One row per player in match_presence (match_heartbeat() wrote the ping
// into game_state and could undo a move that landed at the same moment).
jsonResponse(['status' => 'ok', 'others' => presenceBeat($matchId, $userId, 'ludo')]);
}
function handleLudoLeave(string $userId, array $input): void {
......@@ -504,15 +537,17 @@ function handleLudoLeave(string $userId, array $input): void {
$sdb = supabaseService();
// Get current match state
// Mark the player as bot-replaced in game_state. The mark is merged into
// the row as it stands, compare-and-set on the step number, so a move
// written at the same moment is neither lost nor rolled back.
for ($attempt = 0; $attempt < 4; $attempt++) {
$matches = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
$players = ludoJson($matches[0]['players'] ?? null);
$gameState = ludoJson($matches[0]['game_state'] ?? null);
if (array_is_list($gameState) && !empty($gameState)) $gameState = [];
$tc = isset($gameState['turn_count']) ? intval($gameState['turn_count']) : null;
// Mark player as bot-replaced in game_state (reversible within 30s)
$gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
$gameState['bot_replaced'][] = [
'player_id' => $userId,
......@@ -521,11 +556,12 @@ function handleLudoLeave(string $userId, array $input): void {
'permanent' => false
];
$sdb->update('ludo_matches', [
$where = ['id' => 'eq.' . $matchId, 'game_state->>turn_count' => $tc === null ? 'is.null' : 'eq.' . $tc];
$res = $sdb->update('ludo_matches', [
'game_state' => $gameState,
'last_activity' => date('c')
], ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true, 'bot_replaced' => true]);
], $where);
if (!empty($res) && !isset($res['error'])) jsonResponse(['success' => true, 'bot_replaced' => true]);
}
jsonError('The table is busy, try again', 503);
}
......@@ -38,20 +38,13 @@ $userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? ($_GET['action'] ?? '');
/** Emote keys the client may send. Anything else is rejected. */
/**
* Emote keys the client may send. Anything else is rejected. The client draws
* each as a flat sticker of its own; nothing here is ever shown as text.
*/
const REACTION_EMOTES = [
'gg' => '🤝',
'good_move' => '👏',
'think' => '🤔',
'hurry' => '⏱️',
'wow' => '😮',
'laugh' => '😂',
'sad' => '😢',
'angry' => '😤',
'hello' => '👋',
'fire' => '🔥',
'love' => '❤️',
'salute' => '🫡',
'gg', 'good_move', 'think', 'hurry', 'wow', 'laugh',
'sad', 'angry', 'hello', 'fire', 'love', 'salute',
];
/** Preset phrase ids. The client renders the localised text for each. */
......@@ -81,7 +74,7 @@ function handleSend(string $userId, array $input): void {
if (!isValidUuid($matchId)) jsonError('match_id must be a uuid');
if ($kind === 'emote') {
if (!isset(REACTION_EMOTES[$key])) jsonError('Unknown emote');
if (!in_array($key, REACTION_EMOTES, true)) jsonError('Unknown emote');
} elseif ($kind === 'phrase') {
if (!in_array($key, REACTION_PHRASES, true)) jsonError('Unknown phrase');
} else {
......@@ -153,9 +146,14 @@ function handlePoll(string $userId, array $input): void {
? 'gt.' . $after
: 'gte.' . gmdate('c', time() - 10);
// The cursor handed back starts a little before this read: a reaction
// written while we were reading (or answering) would otherwise fall
// between this poll and the next and never be seen. The client drops the
// repeats by id.
$cursor = gmdate('c', time() - 3);
$rows = $sdb->get('chat_messages', $params);
if (!is_array($rows) || isset($rows['error'])) {
jsonResponse(['messages' => [], 'now' => gmdate('c')]);
jsonResponse(['messages' => [], 'now' => $after ?: $cursor, 'server_time' => gmdate('c')]);
}
$senders = array_values(array_unique(array_filter(array_column($rows, 'sender_id'))));
......@@ -178,7 +176,6 @@ function handlePoll(string $userId, array $input): void {
'sender_id' => $r['sender_id'],
'kind' => $r['message_type'],
'key' => $r['content'],
'emoji' => REACTION_EMOTES[$r['content']] ?? null,
'name' => $prof['display_name'] ?? $prof['username'] ?? null,
'avatar_url' => $prof['avatar_url'] ?? null,
'at' => $r['created_at'],
......@@ -186,7 +183,7 @@ function handlePoll(string $userId, array $input): void {
];
}
jsonResponse(['messages' => $out, 'now' => gmdate('c')]);
jsonResponse(['messages' => $out, 'now' => $cursor, 'server_time' => gmdate('c')]);
}
// ---------------------------------------------------------------------------
......
......@@ -7,6 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/hand-off.php';
require_once __DIR__ . '/../includes/chess.php';
$token = requireAuth();
......@@ -165,7 +166,7 @@ function handleQueue($db, string $userId, array $input): void {
'match_id' => $created['match_id']
], ['id' => 'eq.' . $opponent['id']]);
jsonResponse(['match_id' => $created['match_id'], 'color' => $created['color'], 'opponent_id' => $opponent['player_id']]);
jsonResponse(['match_id' => $created['match_id'], 'color' => $created['color'], 'opponent_id' => $opponent['player_id'], 'cards' => handOffCards($sdb, [$userId, $opponent['player_id']])]);
return;
}
......@@ -203,10 +204,12 @@ function handleStatus($db, string $userId, array $input): void {
// The hand-off has been delivered, so the queue row has done its job.
$sdb->delete('matchmaking_queue', ['id' => 'eq.' . $claim['queue_id']]);
$opponentId = $color === 'w' ? $match['black_player_id'] : $match['white_player_id'];
jsonResponse([
'match_id' => $match['id'],
'color' => $color,
'opponent_id' => $color === 'w' ? $match['black_player_id'] : $match['white_player_id'],
'opponent_id' => $opponentId,
'cards' => handOffCards($sdb, [$userId, $opponentId]),
]);
}
......
<?php
/**
* The bell.
*
* GET the latest notices and the unread count
* POST {action:'read', id} one notice read
* POST {action:'mark-all-read'} all read
* POST {action:'clear'} delete the read ones
* PATCH {action:'read', id?} the older spelling of the two above
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
......@@ -7,43 +16,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/social.php';
$token = requireAuth();
$userId = getUserId($token);
$db = supabase($token);
if (!$userId) jsonError('Invalid user', 401);
$sdb = supabaseService();
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$limit = intval($_GET['limit'] ?? 50);
$notifications = $db->get('notifications', [
$limit = max(1, min(intval($_GET['limit'] ?? 50), 100));
$rows = $sdb->get('notifications', [
'user_id' => 'eq.' . $userId,
'select' => 'id,type,title,title_ar,body,body_ar,is_read,created_at,data',
'order' => 'created_at.desc',
'limit' => $limit
]);
if (!is_array($rows) || isset($rows['error'])) jsonResponse(['notifications' => [], 'unread' => 0]);
if (!is_array($notifications) || isset($notifications['error'])) {
jsonResponse(['notifications' => [], 'unread' => 0]);
// Older writers stored data as a JSON string inside the jsonb column.
foreach ($rows as &$n) {
if (is_string($n['data'] ?? null)) $n['data'] = json_decode($n['data'], true) ?: [];
$n['data'] = (object)($n['data'] ?? []);
}
unset($n);
$unread = count(array_filter($notifications, fn($n) => !$n['is_read']));
jsonResponse(['notifications' => $notifications, 'unread' => $unread]);
$unreadRows = $sdb->get('notifications', ['user_id' => 'eq.' . $userId, 'is_read' => 'is.false', 'select' => 'id']);
$unread = is_array($unreadRows) && !isset($unreadRows['error']) ? count($unreadRows) : 0;
jsonResponse(['notifications' => $rows, 'unread' => $unread]);
}
if ($method === 'PATCH') {
if ($method === 'POST' || $method === 'PATCH') {
$input = getInput();
$action = $input['action'] ?? 'read';
$id = $input['id'] ?? null;
if ($action === 'read') {
$notifId = $input['id'] ?? null;
if ($notifId) {
$db->update('notifications', ['is_read' => true], ['id' => 'eq.' . $notifId, 'user_id' => 'eq.' . $userId]);
} else {
$db->update('notifications', ['is_read' => true], ['user_id' => 'eq.' . $userId, 'is_read' => 'eq.false']);
if ($action === 'read' && $id) {
if (!isUuid($id)) jsonError('Invalid id');
$sdb->update('notifications', ['is_read' => true, 'read_at' => gmdate('c')], ['id' => 'eq.' . $id, 'user_id' => 'eq.' . $userId]);
jsonResponse(['success' => true]);
}
if ($action === 'mark-all-read' || $action === 'read') {
$sdb->update('notifications', ['is_read' => true, 'read_at' => gmdate('c')], ['user_id' => 'eq.' . $userId, 'is_read' => 'is.false']);
jsonResponse(['success' => true]);
}
if ($action === 'clear') {
$sdb->delete('notifications', ['user_id' => 'eq.' . $userId, 'is_read' => 'is.true']);
jsonResponse(['success' => true]);
}
jsonError('Invalid action');
}
jsonError('Method not allowed', 405);
......@@ -7,6 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/social.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -75,6 +76,7 @@ if ($method === 'GET') {
$f = $friendship[0];
$profile['friendship_status'] = $f['status'];
$profile['friendship_is_mine'] = ($f['requester_id'] === $userId);
$profile['friendship_id'] = $f['id'];
} else {
$profile['friendship_status'] = 'none';
$profile['friendship_is_mine'] = false;
......@@ -94,6 +96,9 @@ if ($method === 'GET') {
// Strip private fields for other players
unset($profile['email']);
// Online / at a table / last seen, from the social pulse.
$profile = withPresence($profile);
}
jsonResponse($profile);
......
This diff is collapsed.
<?php
/**
* The player cards that travel with a match hand-off (matchmaking and
* challenges): everything the VS screen shows, for every human at the table.
*
* The client used to fetch each opponent from profile.php after the match was
* found. That call does half a dozen queries and often missed the VS screen's
* deadline, so the opponent showed as "Opponent"; and when the id it was given
* was missing, profile.php answered with the caller's own profile and the
* player faced themselves. The server knows who sits where: it says so.
*
* @return array<string, array> id => card
*/
function handOffCards($sdb, array $ids): array {
$ids = array_values(array_unique(array_filter($ids, fn($x) => is_string($x) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $x))));
if (!$ids) return [];
$rows = $sdb->get('profiles', [
'id' => 'in.(' . implode(',', $ids) . ')',
'select' => 'id,username,display_name,avatar_url,level,elo_rapid,elo_blitz,elo_bullet,elo_classical,elo_domino,total_wins',
]);
if (!is_array($rows) || isset($rows['error'])) return [];
$ratings = $sdb->get('player_game_ratings', [
'player_id' => 'in.(' . implode(',', $ids) . ')',
'game_key' => 'in.(ludo,backgammon)',
'select' => 'player_id,game_key,rating',
]);
$byPlayer = [];
if (is_array($ratings) && !isset($ratings['error'])) {
foreach ($ratings as $r) $byPlayer[$r['player_id']][] = $r;
}
$cards = [];
foreach ($rows as $r) {
$r['ratings'] = $byPlayer[$r['id']] ?? [];
$cards[$r['id']] = $r;
}
return $cards;
}
This diff is collapsed.
<?php
/**
* Who is at the table: one heartbeat row per (match, player) in
* match_presence, shared by all four games.
*
* The heartbeat used to be written into the match's own game_state, where it
* raced every move (see migrations/2026-10-25-match-presence.sql). Here it
* cannot touch a board. Ages are worked out on the server, so a phone whose
* clock is wrong still reads them right.
*/
require_once __DIR__ . '/supabase.php';
/** Record that $userId is at $matchId now; returns everyone else's presence. */
function presenceBeat(string $matchId, string $userId, string $gameKey): array {
if (!presenceValidId($matchId) || !presenceValidId($userId)) return [];
$sdb = supabaseService();
$sdb->upsert('match_presence', [
'match_id' => $matchId,
'player_id' => $userId,
'game_key' => $gameKey,
'last_ping' => gmdate('Y-m-d\TH:i:s\Z'),
], 'match_id,player_id');
// Old rows go now and then: a day after the last ping nobody is coming back.
if (random_int(1, 60) === 1) {
$sdb->delete('match_presence', ['last_ping' => 'lt.' . gmdate('Y-m-d\TH:i:s\Z', time() - 86400)]);
}
return presenceOf($matchId, $userId);
}
/**
* Everyone's last ping at $matchId (but $exclude), as
* { player_id: { last_ping, age } } with age in whole seconds.
*/
function presenceOf(string $matchId, ?string $exclude = null): array {
if (!presenceValidId($matchId)) return [];
$rows = supabaseService()->get('match_presence', [
'match_id' => 'eq.' . $matchId,
'select' => 'player_id,last_ping',
]);
if (!is_array($rows) || isset($rows['error'])) return [];
$out = [];
$now = time();
foreach ($rows as $r) {
$id = $r['player_id'] ?? null;
if (!$id || $id === $exclude) continue;
$at = strtotime($r['last_ping'] ?? '');
if (!$at) continue;
$out[$id] = ['last_ping' => $r['last_ping'], 'age' => max(0, $now - $at)];
}
return $out;
}
function presenceValidId($id): bool {
return is_string($id) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) === 1;
}
<?php
/**
* Shared pieces of the social system: presence, notifications, blocks.
*
* Presence is pulsed by every open app (api/social.php, ~25 s) into
* profiles.last_seen_at and profiles.current_game. A player is online when
* they were seen within PRESENCE_WINDOW seconds; "in a game" when they are
* online and their last pulse carried a game key. The stored is_online flag
* is only a hint for older readers; every social endpoint derives presence
* from last_seen_at so a closed tab can never stay "online" forever.
*/
const PRESENCE_WINDOW = 75;
const SOCIAL_GAMES = ['chess', 'ludo', 'domino', 'backgammon'];
if (!function_exists('isUuid')) {
function isUuid($v): bool {
return is_string($v) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $v) === 1;
}
}
/** Add presence, is_online, current_game and last_seen_at to one profile row. */
function withPresence(array $p): array {
$seen = isset($p['last_seen_at']) && $p['last_seen_at'] ? strtotime($p['last_seen_at']) : 0;
$online = $seen && (time() - $seen) <= PRESENCE_WINDOW && ($p['is_online'] ?? true) !== false;
$game = $online && in_array($p['current_game'] ?? null, SOCIAL_GAMES, true) ? $p['current_game'] : null;
$p['is_online'] = $online;
$p['current_game'] = $game;
$p['presence'] = $game ? 'in_game' : ($online ? 'online' : 'offline');
$p['last_seen_at'] = $seen ? gmdate('c', $seen) : null;
return $p;
}
function withPresenceAll($rows): array {
if (!is_array($rows) || isset($rows['error'])) return [];
return array_map('withPresence', $rows);
}
/** The columns a social row needs to draw a player. */
const PLAYER_COLS = 'id,username,display_name,avatar_url,level,is_online,last_seen_at,current_game';
/** Ids of players blocked by me or blocking me (type=block only). */
function blockedBothWays($sdb, string $uid): array {
$rows = $sdb->get('player_blocks', [
'or' => "(player_id.eq.{$uid},blocked_id.eq.{$uid})",
'type' => 'eq.block',
'select' => 'player_id,blocked_id'
]);
$ids = [];
if (is_array($rows) && !isset($rows['error'])) {
foreach ($rows as $b) $ids[] = $b['player_id'] === $uid ? $b['blocked_id'] : $b['player_id'];
}
return $ids;
}
/** Ids I muted or blocked: their words never reach me. */
function silencedByMe($sdb, string $uid): array {
$rows = $sdb->get('player_blocks', ['player_id' => 'eq.' . $uid, 'select' => 'blocked_id']);
return is_array($rows) && !isset($rows['error']) ? array_column($rows, 'blocked_id') : [];
}
function isBlockedEitherWay($sdb, string $a, string $b): bool {
$rows = $sdb->get('player_blocks', [
'or' => "(and(player_id.eq.{$a},blocked_id.eq.{$b}),and(player_id.eq.{$b},blocked_id.eq.{$a}))",
'type' => 'eq.block',
'limit' => 1
]);
return is_array($rows) && !isset($rows['error']) && !empty($rows);
}
function playerName(?array $p): string {
if (!$p) return '';
return trim($p['display_name'] ?? '') ?: ($p['username'] ?? '');
}
function profileOf($sdb, string $id, string $cols = PLAYER_COLS): ?array {
$rows = $sdb->get('profiles', ['id' => 'eq.' . $id, 'select' => $cols, 'limit' => 1]);
return is_array($rows) && !isset($rows['error']) && !empty($rows) ? $rows[0] : null;
}
/**
* Drop a notice in someone's bell. `data.name` carries the other player's
* name so the app can word the notice in the reader's language.
*/
function socialNotify($sdb, string $uid, string $type, string $titleAr, string $titleEn, array $data = [], string $bodyAr = '', string $bodyEn = ''): void {
$sdb->insert('notifications', [
'user_id' => $uid,
'type' => $type,
'title' => $titleEn,
'title_ar' => $titleAr,
'body' => $bodyEn ?: null,
'body_ar' => $bodyAr ?: null,
'data' => $data ?: new \stdClass()
]);
}
/** The accepted friendship between two players, or null. */
function friendshipBetween($sdb, string $a, string $b, ?string $status = 'accepted'): ?array {
$q = [
'or' => "(and(requester_id.eq.{$a},addressee_id.eq.{$b}),and(requester_id.eq.{$b},addressee_id.eq.{$a}))",
'select' => 'id,requester_id,addressee_id,status,created_at,updated_at',
'limit' => 1
];
if ($status) $q['status'] = 'eq.' . $status;
$rows = $sdb->get('friendships', $q);
return is_array($rows) && !isset($rows['error']) && !empty($rows) ? $rows[0] : null;
}
/** Ids of my accepted friends. */
function friendIdsOf($sdb, string $uid): array {
$rows = $sdb->get('friendships', [
'or' => "(requester_id.eq.{$uid},addressee_id.eq.{$uid})",
'status' => 'eq.accepted',
'select' => 'requester_id,addressee_id'
]);
$ids = [];
if (is_array($rows) && !isset($rows['error'])) {
foreach ($rows as $f) $ids[] = $f['requester_id'] === $uid ? $f['addressee_id'] : $f['requester_id'];
}
return array_values(array_unique($ids));
}
/** Now, to the microsecond, so "read up to now" is never before a line
* written in the same second. */
function nowIso(): string {
return (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.uP');
}
/** A timestamp as a float of seconds, microseconds kept. */
function tsf(?string $t): float {
if (!$t) return 0.0;
try { return (float)(new DateTime($t))->format('U.u'); } catch (Exception $e) { return 0.0; }
}
/** Reaction stickers a player may put on a message. */
const REACTION_KEYS = ['heart', 'flame', 'party', 'handshake', 'trophy', 'star'];
-- A friend's challenge, as a handshake of its own.
--
-- The invite used to live inside the match row's game_state, one copy per game
-- table, and every step was a guess: check-invites scanned four tables with a
-- limit of five, a chess decline wrote result 'declined' (not a match_result
-- value, so the update failed and the challenge stayed open), nothing knew
-- whether the challenger was still waiting, and accept flipped the match to
-- in_progress with no condition on its state. One row per challenge, moved
-- between states only by conditional updates, fixes all of that:
--
-- pending -> accepted the friend took it (only while fresh and the host is still there)
-- pending -> declined the friend said no
-- pending -> cancelled the challenger backed out (or left the lobby)
-- pending -> expired two minutes passed
--
-- host_seen_at is the challenger's lobby heartbeat. A challenger who closed the
-- app stops beating, and the friend can no longer accept a game nobody will
-- sit down at.
CREATE TABLE IF NOT EXISTS public.match_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
game_key text NOT NULL CHECK (game_key IN ('chess', 'ludo', 'domino', 'backgammon')),
match_id uuid NOT NULL,
from_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
to_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'cancelled', 'expired')),
time_control text,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT now() + interval '120 seconds',
host_seen_at timestamptz NOT NULL DEFAULT now(),
responded_at timestamptz
);
CREATE UNIQUE INDEX IF NOT EXISTS match_invites_match_id ON public.match_invites (match_id);
CREATE INDEX IF NOT EXISTS match_invites_to_pending ON public.match_invites (to_id) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS match_invites_from_pending ON public.match_invites (from_id) WHERE status = 'pending';
-- Only the API (service role) reads or writes invites.
ALTER TABLE public.match_invites ENABLE ROW LEVEL SECURITY;
-- This database sets no default privileges, so a new table is invisible to the
-- API's service role until it is granted.
GRANT SELECT, INSERT, UPDATE, DELETE ON public.match_invites TO service_role;
-- 2026-10-25: who is at the table, kept apart from the match rows.
--
-- Presence used to live inside each match's game_state (connections.<id> and,
-- for chess, hb_<id>), written by match_heartbeat(). That function read the
-- row, merged the ping and wrote the whole game_state back: a move landing in
-- between was silently undone (domino lost its seq, chess a draw offer). It
-- never knew backgammon, ludo never called it, a game_state stored as a JSON
-- string turned into an array, and the full-row writes of ludo and backgammon
-- wiped every ping anyway.
--
-- One row per (match, player), upserted on every heartbeat. Nothing here can
-- touch a board. Read and written by the API with the service key only.
begin;
create table if not exists public.match_presence (
match_id uuid not null,
player_id uuid not null,
game_key text not null,
last_ping timestamptz not null default now(),
primary key (match_id, player_id)
);
create index if not exists idx_match_presence_ping on public.match_presence (last_ping);
create index if not exists idx_match_presence_player on public.match_presence (player_id);
alter table public.match_presence enable row level security;
commit;
-- The social system: presence, unread state, reactions, group invites.
-- Idempotent: every statement can run twice.
--
-- Backups taken before the data changes below (2026-10-25):
-- backup_20261025_profiles_presence (id, is_online, last_seen_at, current_game)
-- backup_20261025_groups, backup_20261025_group_members
-- ── Chat: one quick reaction per player per message ──────────────────────
-- {"<player id>": "<sticker key>"}; the API whitelists the keys.
ALTER TABLE public.friend_messages ADD COLUMN IF NOT EXISTS reactions jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE public.chat_messages ADD COLUMN IF NOT EXISTS reactions jsonb NOT NULL DEFAULT '{}'::jsonb;
-- Group chat history is read newest-first per channel.
CREATE INDEX IF NOT EXISTS idx_chat_messages_channel_time ON public.chat_messages (channel_id, created_at DESC);
-- ── Groups: unread state and invite codes ────────────────────────────────
ALTER TABLE public.group_members ADD COLUMN IF NOT EXISTS last_read_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE public.groups ADD COLUMN IF NOT EXISTS invite_code text;
ALTER TABLE public.groups ALTER COLUMN invite_code SET DEFAULT upper(substr(md5(random()::text || clock_timestamp()::text), 1, 6));
UPDATE public.groups SET invite_code = upper(substr(md5(random()::text || id::text), 1, 6)) WHERE invite_code IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS groups_invite_code_key ON public.groups (invite_code);
-- ── Presence ─────────────────────────────────────────────────────────────
-- Nothing ever wrote is_online, so some rows carry a stale true (test tools
-- set it by hand). From now on the app pulses last_seen_at and "online" means
-- "seen in the last 75 seconds"; clear the stale flags so nothing else that
-- reads the column directly is misled.
UPDATE public.profiles SET is_online = false
WHERE is_online IS TRUE AND (last_seen_at IS NULL OR last_seen_at < now() - interval '75 seconds');
CREATE INDEX IF NOT EXISTS idx_profiles_last_seen ON public.profiles (last_seen_at DESC);
-- ── One round trip for the whole social HUD ──────────────────────────────
-- Stamps presence and returns every count the dock, the bell and the friend
-- rows need. Players I muted or blocked never raise a count. Called every ~25 s by each open app (api/social.php pulse).
CREATE OR REPLACE FUNCTION public.el3ab_social_pulse(p_uid uuid, p_game text DEFAULT NULL, p_online boolean DEFAULT true)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_notif int;
v_requests int;
v_dm jsonb;
v_groups jsonb;
v_latest jsonb;
BEGIN
UPDATE profiles
SET last_seen_at = now(),
is_online = p_online,
current_game = CASE WHEN p_online THEN p_game ELSE NULL END
WHERE id = p_uid;
SELECT count(*) INTO v_notif FROM notifications WHERE user_id = p_uid AND is_read IS NOT TRUE;
SELECT count(*) INTO v_requests FROM friendships WHERE addressee_id = p_uid AND status = 'pending';
SELECT coalesce(jsonb_object_agg(sender_id, n), '{}'::jsonb) INTO v_dm FROM (
SELECT m.sender_id, count(*) AS n
FROM friend_messages m
JOIN friendships f ON f.id = m.friendship_id
WHERE f.status = 'accepted'
AND (f.requester_id = p_uid OR f.addressee_id = p_uid)
AND m.sender_id <> p_uid
AND m.read_at IS NULL
AND NOT EXISTS (SELECT 1 FROM player_blocks b WHERE b.player_id = p_uid AND b.blocked_id = m.sender_id)
GROUP BY m.sender_id
) s;
SELECT to_jsonb(x) INTO v_latest FROM (
SELECT m.id, m.sender_id, m.content, m.message_type, m.created_at, p.display_name, p.username
FROM friend_messages m
JOIN friendships f ON f.id = m.friendship_id
LEFT JOIN profiles p ON p.id = m.sender_id
WHERE f.status = 'accepted'
AND (f.requester_id = p_uid OR f.addressee_id = p_uid)
AND m.sender_id <> p_uid
AND m.read_at IS NULL
AND NOT EXISTS (SELECT 1 FROM player_blocks b WHERE b.player_id = p_uid AND b.blocked_id = m.sender_id)
ORDER BY m.created_at DESC
LIMIT 1
) x;
SELECT coalesce(jsonb_object_agg(group_id, n), '{}'::jsonb) INTO v_groups FROM (
SELECT gm.group_id, count(c.id) AS n
FROM group_members gm
JOIN chat_messages c
ON c.channel_type = 'group'
AND c.channel_id = gm.group_id
AND c.created_at > gm.last_read_at
AND c.sender_id IS DISTINCT FROM p_uid
AND c.message_type <> 'system'
WHERE gm.user_id = p_uid
AND NOT EXISTS (SELECT 1 FROM player_blocks b WHERE b.player_id = p_uid AND b.blocked_id = c.sender_id)
GROUP BY gm.group_id
) g;
RETURN jsonb_build_object(
'notifications', v_notif,
'requests', v_requests,
'dm', v_dm,
'groups', v_groups,
'latest_dm', v_latest,
'now', now()
);
END;
$$;
REVOKE ALL ON FUNCTION public.el3ab_social_pulse(uuid, text, boolean) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.el3ab_social_pulse(uuid, text, boolean) TO service_role;
-- ── Reactions: set, change or take back one reaction, atomically ─────────
CREATE OR REPLACE FUNCTION public.el3ab_react(p_kind text, p_id uuid, p_uid uuid, p_key text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v jsonb;
BEGIN
IF p_kind = 'dm' THEN
UPDATE friend_messages
SET reactions = CASE WHEN p_key IS NULL OR reactions->>p_uid::text = p_key
THEN reactions - p_uid::text
ELSE reactions || jsonb_build_object(p_uid::text, p_key) END
WHERE id = p_id
RETURNING reactions INTO v;
ELSE
UPDATE chat_messages
SET reactions = CASE WHEN p_key IS NULL OR reactions->>p_uid::text = p_key
THEN reactions - p_uid::text
ELSE reactions || jsonb_build_object(p_uid::text, p_key) END
WHERE id = p_id
RETURNING reactions INTO v;
END IF;
RETURN v;
END;
$$;
REVOKE ALL ON FUNCTION public.el3ab_react(text, uuid, uuid, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.el3ab_react(text, uuid, uuid, text) TO service_role;
NOTIFY pgrst, 'reload schema';
......@@ -36,6 +36,7 @@ export function init(guestMode = false) {
bus.on('tournament:pending-updated', updateTournamentBadge);
bus.on('game:ended', afterGame);
bus.on('tournament:paired', () => updateTournamentBadge([1]));
bus.on('social:badge', updateSocialBadge);
}
function avatarMarkup(player, size) {
......@@ -363,6 +364,28 @@ function updateTournamentBadge(pending) {
}
}
// Unread messages and friend requests ride on the friends tab (social/live.js).
let lastSocial = 0;
function updateSocialBadge(n) {
const tab = tabBar?.querySelector('[data-world="social"]');
if (!tab) return;
let badge = tab.querySelector('.social-badge');
const count = Math.max(0, Number(n) || 0);
if (count > 0) {
if (!badge) {
badge = document.createElement('span');
badge.className = 'social-badge badge num';
badge.style.cssText = 'position:absolute;top:2px;inset-inline-end:calc(50% - 26px);';
tab.appendChild(badge);
}
badge.textContent = count > 99 ? '99+' : String(count);
if (count > lastSocial) { badge.classList.remove('pop-in'); void badge.offsetWidth; badge.classList.add('pop-in'); }
} else if (badge) {
badge.remove();
}
lastSocial = count;
}
// Signed out there is no HUD and no tab bar: the screen gets the whole phone.
export function show() { document.documentElement.classList.remove('no-chrome'); hudEl?.classList.remove('hidden'); tabBar?.classList.remove('hidden'); }
export function hide() { document.documentElement.classList.add('no-chrome'); hudEl?.classList.add('hidden'); tabBar?.classList.add('hidden'); }
This diff is collapsed.
......@@ -11,46 +11,9 @@ import * as store from './store.js';
import * as net from './net.js';
const WAIT_CHIP_AFTER_MS = 2500; // a quick opponent never sees the waiting chip
const PRESENCE_FRESH_MS = 25000; // a first ping counts as "here" only if this recent
const ENDPOINT = { chess: 'game.php', ludo: 'ludo-match.php', domino: 'domino-match.php', backgammon: 'backgammon-match.php' };
function parseState(data) {
if (!data || !data.game_state) return null;
try {
const gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state;
return gs && typeof gs === 'object' ? gs : null;
} catch (e) { return null; }
}
/**
* When the opponent was last seen, from the heartbeat the server records in
* game_state: chess stamps a flat `hb_<playerId>` key, and the heartbeat RPC
* keeps `connections.<playerId>.last_ping` for every game. Returns 0 when
* there is nothing to go on.
*/
function opponentHeartbeatAt(data) {
const gs = parseState(data);
if (!gs) return 0;
const me = myPlayerId();
const mine = 'hb_' + me;
let latest = 0;
for (const [key, ts] of Object.entries(gs)) {
if (!key.startsWith('hb_') || key === mine) continue;
const v = typeof ts === 'number' ? ts : Date.parse(ts);
if (Number.isFinite(v) && v > latest) latest = v;
}
const conns = gs.connections;
if (conns && typeof conns === 'object') {
for (const [id, info] of Object.entries(conns)) {
if (id === me) continue;
const v = session.pingTime(info?.last_ping);
if (v > latest) latest = v;
}
}
return latest;
}
function myPlayerId() {
try { return store.get('auth.userId'); } catch (e) { return null; }
}
......@@ -82,7 +45,7 @@ async function claimAbandonWin(matchId, gameType, lastData) {
}
export function start(matchId, gameType, options = {}) {
const { onMove, onGameEnd, onOpponentAbandon } = options;
const { onMove, onGameEnd, onOpponentAbandon, onResume } = options;
// A refresh (or coming back into the app) finds this very match saved: say
// so until the first answer from the server puts the table back.
......@@ -90,8 +53,6 @@ export function start(matchId, gameType, options = {}) {
let recovering = !!(saved && saved.matchId === matchId);
if (recovering) ui.showReconnecting();
let lastMoveCount = 0;
let lastOpponentSeenAt = 0;
let connectionLost = false;
let lastData = null;
let opponentHere = false;
......@@ -119,29 +80,10 @@ export function start(matchId, gameType, options = {}) {
ui.showConnectionRestored();
}
// Only *evidence of the opponent* counts as opponent activity.
//
// This used to call session.markOpponentActive() unconditionally on every
// poll. Since we poll every 2s regardless of what the opponent is doing,
// the "last seen" timestamp never aged and disconnect and abandon
// detection could never fire — a player whose opponent walked away waited
// forever, which stalls a whole tournament round.
const moveCount = data.move_count || data.current_turn || 0;
if (moveCount > lastMoveCount) {
lastMoveCount = moveCount;
session.markOpponentActive();
opponentArrived(false);
}
const hb = opponentHeartbeatAt(data);
if (hb > lastOpponentSeenAt) {
const first = lastOpponentSeenAt === 0;
lastOpponentSeenAt = hb;
// A ping left over from before they went away is not a sign of life.
if (!first || Math.abs(Date.now() - hb) < PRESENCE_FRESH_MS) {
session.markOpponentActive();
opponentArrived(true);
}
}
// Only evidence of the opponent counts as opponent activity: their
// heartbeat (match-session reads it from the row's presence) and their
// moves (the game marks those, since only it knows whose move it was).
// Polling every 2s says nothing about whether they are still there.
onMove?.(data);
......@@ -155,6 +97,13 @@ export function start(matchId, gameType, options = {}) {
onOpponentPresence: () => opponentArrived(true),
// Back from a hidden tab, a sleeping phone or a dropped line: the game
// rebuilds from the fresh row (it also arrives through onOpponentMove).
onResume: (data, info) => {
if (recovering && data) { recovering = false; ui.hideReconnecting(); }
onResume?.(data, info);
},
onOpponentDisconnect: () => {
if (ended) return;
ui.hideWaitingForOpponent(false);
......@@ -191,7 +140,7 @@ export function start(matchId, gameType, options = {}) {
return {
session: sess,
markOpponentActive: () => session.markOpponentActive(),
markOpponentActive: (playerId) => { session.markOpponentActive(playerId); opponentArrived(false); },
showThinking: () => ui.showOpponentThinking(),
hideThinking: () => ui.hideOpponentThinking(),
showReconnecting: () => ui.showReconnecting(),
......@@ -200,7 +149,8 @@ export function start(matchId, gameType, options = {}) {
yourTurn: (sub) => ui.showYourTurn(sub),
timeLow: (seconds) => ui.showTimeLow(seconds),
banner: (opts) => ui.banner(opts),
cleanup: () => { ended = true; clearTimeout(waitTimer); session.destroy(); ui.cleanup(); }
/** Close the table. `ended`: the match is over, so forget it for recovery too. */
cleanup: ({ ended: over = false } = {}) => { ended = true; clearTimeout(waitTimer); session.destroy({ forget: over }); ui.cleanup(); }
};
}
......
This diff is collapsed.
......@@ -155,7 +155,7 @@ const STYLE = `
@keyframes muBannerOut { to { opacity:0; transform:translateX(calc(var(--dir) * -60%)) scale(0.92); } }
/* The opponent is thinking / has not arrived: an ink pill. */
#thinking-indicator, #mu-waiting {
#thinking-indicator, #mu-waiting, #mu-offline {
position:fixed; inset-inline:0; margin-inline:auto; width:max-content; max-width:calc(100vw - 32px);
top:calc(var(--safe-top) + 64px); z-index:400;
display:flex; align-items:center; gap:8px;
......@@ -169,6 +169,10 @@ const STYLE = `
animation:muPillIn 420ms var(--ease-pop) both;
}
#mu-waiting { top:calc(var(--safe-top) + 40%); min-height:54px; font-size:var(--fs-lg); }
/* Our own line is down: a red-edged pill over the top of the table. */
#mu-offline { top:calc(var(--safe-top) + 10px); z-index:720; background:var(--btn-red-2); box-shadow:0 4px 0 var(--btn-red-edge), var(--shadow-md); }
#mu-offline .gi { width:30px; height:30px; }
#mu-offline .mu-pill-dots > span { background:#fff; }
#thinking-indicator .gi, #mu-waiting .gi { width:30px; height:30px; }
#mu-waiting .gi { width:38px; height:38px; }
.mu-pill-dots { display:inline-flex; gap:3px; }
......@@ -214,7 +218,10 @@ function show(content, name = 'panel') {
currentPanel = name;
ov.style.display = 'flex';
ov.style.opacity = '0';
requestAnimationFrame(() => { ov.style.opacity = '1'; });
// A timer behind the frame: a page back from sleep can lose a frame it asked for.
const on = () => { if (currentPanel === name) ov.style.opacity = '1'; };
requestAnimationFrame(on);
setTimeout(on, 80);
ov.setAttribute('aria-labelledby', 'mu-title');
}
......@@ -384,23 +391,30 @@ function showAutoWin() {
}
// ========== CONNECTION LOST (your own network) ==========
// A pill at the top of the table, not a wall over it: the board stays live.
// Whatever is played meanwhile is kept and goes out when the line is back
// (every game sends through an outbox), so a short drop costs nothing.
export function showConnectionLost() {
show(panel({
ribbon: t('match.connection_lost'),
tone: 'red',
sticker: 'refresh',
stickerCls: 'mu-spin',
body: `
<div class="mu-sub">${t('match.reconnecting')}</div>
<div class="mu-dots" aria-hidden="true"><span></span><span></span><span></span></div>`,
}), 'connection');
ensureStyles();
if (currentPanel === 'connection') hide();
if (document.getElementById('mu-offline')) return;
const el = document.createElement('div');
el.id = 'mu-offline';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.innerHTML = `${gicon('refresh', 30, { cls: 'mu-spin' })}<span>${t('match.connection_lost')}</span><span class="mu-pill-dots" aria-hidden="true"><span></span><span></span><span></span></span>`;
document.body.appendChild(el);
audio.play('error');
juice.hapticError();
}
// ========== CONNECTION RESTORED ==========
export function showConnectionRestored() {
const pill = document.getElementById('mu-offline');
if (currentPanel === 'connection' || currentPanel === 'restoring') hide();
if (!pill) return;
pill.classList.add('mu-pill-out');
setTimeout(() => pill.remove(), 240);
banner({ sticker: 'check', title: t('match.reconnected'), tone: 'green', sound: 'notification', haptic: 'success' });
}
......@@ -555,6 +569,7 @@ export function cleanup() {
abandonClaim = null;
hideOpponentThinking();
document.getElementById('mu-waiting')?.remove();
document.getElementById('mu-offline')?.remove();
clearTimeout(bannerTimer);
bannerLane?.remove();
bannerLane = null;
......
// Who "I" am, from the one source the server also trusts: the signed-in
// token's subject. auth.userId used to be the only answer, and some ways in
// (Google sign-in returning through the URL hash, the native shell handing
// over a token) never set it, so anything comparing ids against it quietly
// treated the player as a stranger.
import * as store from './store.js';
/** The `sub` claim of a JWT, or null. */
export function tokenSubject(token) {
if (!token || typeof token !== 'string') return null;
const part = token.split('.')[1];
if (!part) return null;
try {
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '='));
const sub = JSON.parse(json).sub;
return typeof sub === 'string' && sub ? sub : null;
} catch (e) {
return null;
}
}
/** My player id: the token's subject, else whatever the store remembered. */
export function myId() {
return tokenSubject(store.get('auth.token')) || store.get('auth.userId') || store.get('player')?.id || null;
}
/** Same id? Compares as lower-case strings, so a uuid's case can't split them. */
export function sameId(a, b) {
return a != null && b != null && String(a).toLowerCase() === String(b).toLowerCase();
}
/** Keep auth.userId equal to the token's subject from now on. */
export function syncUserId() {
const sub = tokenSubject(store.get('auth.token'));
if (sub && store.get('auth.userId') !== sub) store.set('auth.userId', sub);
}
This diff is collapsed.
......@@ -33,6 +33,7 @@ export function connect() {
}, 30000);
// Re-subscribe all active subscriptions
Object.keys(subscriptions).forEach(topic => joinChannel(topic));
Object.keys(broadcasts).forEach(topic => joinBroadcastTopic(topic));
};
sock.onmessage = (e) => {
......@@ -52,6 +53,9 @@ export function connect() {
old: body.old_record ?? body.old
}));
}
} else if (msg.event === 'broadcast' && broadcasts[msg.topic]) {
const b = msg.payload || {};
broadcasts[msg.topic].forEach(cb => cb(b.event, b.payload || {}));
} else if (msg.event === 'phx_reply' && msg.payload && msg.payload.status === 'error') {
// A refused channel join used to fail silently, leaving the client
// convinced it was subscribed and waiting forever for updates.
......@@ -85,6 +89,7 @@ export function disconnect() {
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
connected = false;
subscriptions = {};
broadcasts = {};
}
export function subscribe(table, filter, callback) {
......@@ -133,6 +138,44 @@ export function subscribeTournamentRounds(tournamentId, callback) {
return subscribe('el3ab_tournament_rounds', `tournament_id=eq.${tournamentId}`, callback);
}
// ── Broadcast: short-lived messages between open apps, no table involved ──
// (the chat's "typing..." line). Nothing is stored; a missed one is harmless.
let broadcasts = {};
/**
* Join a broadcast channel. onEvent(event, payload) gets what the others send.
* Returns { send(event, payload), leave() }.
*/
export function channel(name, onEvent) {
const topic = `realtime:${name}`;
if (!broadcasts[topic]) broadcasts[topic] = [];
broadcasts[topic].push(onEvent);
if (connected) joinBroadcastTopic(topic);
else connect();
return {
send(event, payload = {}) {
send({ topic, event: 'broadcast', payload: { type: 'broadcast', event, payload }, ref: String(++ref) });
},
leave() {
if (!broadcasts[topic]) return;
broadcasts[topic] = broadcasts[topic].filter(cb => cb !== onEvent);
if (broadcasts[topic].length === 0) {
leaveChannel(topic);
delete broadcasts[topic];
}
},
};
}
function joinBroadcastTopic(topic) {
send({
topic,
event: 'phx_join',
payload: { config: { broadcast: { self: false, ack: false }, presence: { key: '' } }, access_token: store.get('auth.token') || ANON_KEY },
ref: String(++ref)
});
}
function joinChannel(topic) {
const token = store.get('auth.token') || ANON_KEY;
send({
......
......@@ -82,6 +82,18 @@ export function pop() {
transitionTo(target.id, target.params, leaving.id, true);
}
/** Go back n scenes at once; only the one landed on is mounted again. */
export function popMany(n = 1) {
if (isTransitioning) return;
const stack = sceneStacks[currentWorld];
const steps = Math.min(n, stack.length - 1);
if (steps <= 0) return;
const leaving = stack[stack.length - 1];
stack.splice(stack.length - steps, steps);
const target = stack[stack.length - 1];
transitionTo(target.id, target.params, leaving.id, true);
}
export function replace(sceneId, params = {}) {
const stack = sceneStacks[currentWorld];
const prev = stack[stack.length - 1];
......@@ -100,6 +112,8 @@ export function setRoot(world, sceneId, params = {}) {
}
export function getCurrentWorld() { return currentWorld; }
/** The id of the scene on top of the current world's stack. */
export function currentSceneId() { const st = sceneStacks[currentWorld]; return st[st.length - 1]?.id || null; }
export function getWorlds() { return worlds; }
function transitionTo(sceneId, params, prevId, isBack = false) {
......
......@@ -6,11 +6,12 @@ import * as input from './core/input.js';
import * as hud from './core/hud.js';
import * as theme from './core/theme.js';
import { setLang } from './core/i18n.js';
import { getRecoverableMatch } from './core/match-session.js';
import { getRecoverableMatch, endpointFor } from './core/match-session.js';
import * as tournamentSession from './core/tournament-session.js';
import * as nativeBridge from './core/native-bridge.js';
import * as feel from './core/feel.js';
import { toast } from './core/ui.js';
import { syncUserId } from './core/me.js';
async function boot() {
// Handle OAuth callback — extract tokens from URL hash before anything else
......@@ -36,6 +37,11 @@ async function boot() {
history.replaceState(null, '', '/');
}
// auth.userId follows the token's subject, whichever way the token arrived
// (form login, Google's redirect, the native shell, a refresh).
syncUserId();
bus.on('store:auth.token', syncUserId);
setLang(store.get('language') || 'ar');
await theme.load();
audio.init();
......@@ -57,12 +63,17 @@ async function boot() {
scene.setRoot('tournaments', 'tournaments-hub');
scene.setRoot('profile', 'profile-view');
// Check for active match to resume (tab refresh / re-entry recovery)
// A game left mid-play (a refresh, the app closed, the phone restarted)
// is opened again where it stands.
const recoverable = getRecoverableMatch();
if (recoverable) {
// Verify match is still running on server before reconnecting
const gameScene = recoverable ? (recoverable.gameType === 'chess' ? 'chess-game' : recoverable.gameType + '-game') : null;
if (recoverable && recoverable.bot) {
// A bot game lives on this device: its scene restores it from the save.
scene.push(gameScene, { mode: 'bot', recovered: true, matchId: String(recoverable.matchId || '').startsWith('bot-') ? null : recoverable.matchId, resumeId: recoverable.resumeId || null });
} else if (recoverable) {
// An online match: only when the server says it is still being played.
try {
const endpoint = recoverable.gameType === 'ludo' ? 'ludo-match.php' : recoverable.gameType === 'domino' ? 'domino-match.php' : recoverable.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php';
const endpoint = endpointFor(recoverable.gameType);
const res = await fetch(`/api/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
......@@ -70,17 +81,14 @@ async function boot() {
});
const matchData = await res.json();
if (matchData && !matchData.error && matchData.status === 'in_progress') {
// Match still running — rejoin
const gameScene = recoverable.gameType === 'chess' ? 'chess-game' : recoverable.gameType + '-game';
scene.push(gameScene, { mode: 'live', matchId: recoverable.matchId, recovered: true });
} else {
// Match ended, abandoned, or not found — clear recovery and go home
// Match ended, abandoned, or not found: clear recovery and go home.
localStorage.removeItem('el3ab_active_match');
scene.switchWorld(store.get('activeWorld') || 'play');
}
} catch (e) {
// Network error — try to rejoin anyway
const gameScene = recoverable.gameType === 'chess' ? 'chess-game' : recoverable.gameType + '-game';
// Network error: try to rejoin anyway (the game retries its own reads).
scene.push(gameScene, { mode: 'live', matchId: recoverable.matchId, recovered: true });
}
} else {
......
This diff is collapsed.
......@@ -495,8 +495,15 @@ export class ChessBoard {
}
// ── Animation loop ────────────────────────────────────────────────────
/**
* Run the frame loop while anything moves. A phone that slept, or a tab
* that was frozen, can drop a frame already asked for: the loop would then
* wait for it forever and nothing would land again. A request older than
* half a second is taken as lost and asked for again.
*/
kick() {
if (this._raf) return;
if (this._raf && performance.now() - (this._rafAt || 0) < 500) return;
if (this._raf) cancelAnimationFrame(this._raf);
const step = (now) => {
this._raf = 0;
let live = false;
......@@ -509,11 +516,21 @@ export class ChessBoard {
}
if (this.anims.length) live = true;
this.draw(now);
if (live) this._raf = requestAnimationFrame(step);
if (live) { this._rafAt = performance.now(); this._raf = requestAnimationFrame(step); }
};
this._rafAt = performance.now();
this._raf = requestAnimationFrame(step);
}
/** Back from sleep or a hidden tab: land whatever was in flight and draw the board as it is. */
wake() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = 0; }
this.settleSlides();
this.anims = this.anims.filter(a => a.type === 'check');
this.draw();
if (this.anims.length) this.kick();
}
// ── Painting ──────────────────────────────────────────────────────────
/** The 64 flat squares, cached per size. */
squaresLayer() {
......
This diff is collapsed.
......@@ -170,11 +170,25 @@ export class DominoBoard {
return clientX >= c.left && clientX <= c.right && clientY >= c.top && clientY <= c.bottom;
}
/** A frame asked for more than half a second ago was lost (a phone that slept): ask again. */
_kick() {
if (this._raf) return;
if (this._raf && performance.now() - (this._rafAt || 0) < 500) return;
if (this._raf) cancelAnimationFrame(this._raf);
this._rafAt = performance.now();
this._raf = requestAnimationFrame((t) => this._frame(t));
}
/** Back from sleep or a hidden tab: finish what was moving and draw the table as it is. */
wake() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = 0; }
for (const [id, tw] of this.tweens) this.shown.set(id, { x: tw.to.x, y: tw.to.y, w: tw.to.w, h: tw.to.h });
this.tweens.clear();
this.landing = null;
this.glow = null;
this.draw();
if (this.targets.length) this._kick();
}
_frame(now) {
this._raf = 0;
let busy = false;
......
......@@ -37,14 +37,20 @@ export function flyTile({ from, to, top = 0, bottom = 0, turn = 0, flip = 'none'
{ transform: `translate(0px, 0px) rotateY(${y1}deg) rotate(${turn}deg) scale(1)`, filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.35))' },
];
return new Promise((resolve) => {
let landed = false;
const done = () => {
if (landed) return;
landed = true;
resolve(el);
if (!keep) requestAnimationFrame(() => el.remove());
if (!keep) { requestAnimationFrame(() => el.remove()); setTimeout(() => el.remove(), 120); }
};
if (reduced()) { el.style.transform = frames[2].transform; done(); return; }
const anim = el.animate(frames, { duration, easing: 'cubic-bezier(0.3, 0.7, 0.3, 1)', fill: 'forwards' });
anim.onfinish = done;
anim.oncancel = done;
// A hidden or sleeping page may not run the animation: the tile still
// lands on time, and the moves after it are not held up.
setTimeout(done, duration + 400);
});
}
......
......@@ -140,7 +140,9 @@ export class DominoHand {
{ translate: `${tx + dx * 0.4}px ${dy * 0.4 - 30}px`, scale: 1.15, rotate: '6deg', opacity: 1, offset: 0.55 },
{ translate: `${tx}px 0px`, scale: 1, rotate: '0deg', opacity: 1 },
], { duration, easing: 'cubic-bezier(0.3, 0.75, 0.3, 1.15)' });
return new Promise(r => { a.onfinish = r; a.oncancel = r; });
// A timer stands behind the animation: a hidden or sleeping page may not
// run it, and a draw that never "lands" would hold up the table.
return new Promise(r => { a.onfinish = r; a.oncancel = r; setTimeout(r, duration + 400); });
}
_bind(el) {
......
This diff is collapsed.
This diff is collapsed.
// Who "me" is, for the VS screen and the seats.
//
// store's auth.userId was the answer, and it was wrong whenever it was not
// written (a Google sign-in) or was left over (a guest who then signed in):
// every "the id that is not mine" then picked the player themselves and the
// VS screen showed them against themselves. core/me.js reads the id from the
// session token itself; this adds the profile that goes with it.
import * as store from '../../core/store.js';
import { myId } from '../../core/me.js';
export { myId };
/** The stored profile, but only if it really is mine (else a bare card with my id). */
export function myProfile() {
const id = myId();
const p = store.get('player') || {};
return !id || !p.id || p.id === id ? { ...p, id: id || p.id } : { id };
}
This diff is collapsed.
......@@ -5,6 +5,7 @@ import { mountTimeSelect } from './scenes/time-select.js';
import { mountQueue, unmountQueue } from './scenes/queue.js';
import { mountLobby, unmountLobby } from './scenes/lobby.js';
import { mountChallenge } from './scenes/challenge.js';
import { startInviteWatch } from './invite-watch.js';
scene.register('play-table', mountTable);
scene.register('play-bot-select', mountBotSelect);
......@@ -12,3 +13,6 @@ scene.register('play-time-select', mountTimeSelect);
scene.register('play-queue', mountQueue, unmountQueue);
scene.register('game-lobby', mountLobby, unmountLobby);
scene.register('challenge-friend', mountChallenge);
// A friend's challenge reaches the player on any screen, not only on Friends.
startInviteWatch();
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -7,6 +7,7 @@ import { mountGroups } from './scenes/groups.js';
import { mountGroupCreate } from './scenes/group-create.js';
import { mountGroupChat, unmountGroupChat } from './scenes/group-chat.js';
import { mountGroupMembers } from './scenes/group-members.js';
import * as live from './live.js';
scene.register('friends', mountFriends, unmountFriends);
scene.register('notifications', mountNotifications);
......@@ -16,3 +17,6 @@ scene.register('groups', mountGroups);
scene.register('group-create', mountGroupCreate);
scene.register('group-chat', mountGroupChat, unmountGroupChat);
scene.register('group-members', mountGroupMembers);
// Presence and every social count (bell, friends tab, unread rows).
live.start();
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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