Commit 7267057e authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(play): a bot plays for a player who drops; tournaments wait, then rule

Casual online games: a player gone 30 s gets a ring on the other screen
(no claim button); at 60 s a bot plays their seat until they are back, and
the game goes on to its natural end. Nobody is handed a win for a drop.
- Chess: the server plays the absent side (game.php standin_move: the
  engine near their rating; the caller never picks the move), through the
  normal move path, so clock and position checks apply. includes/chess.php
  chessApplyUci() applies the engine's move (castling, en passant,
  promotion, counters).
- Backgammon: the client still at the table plays the seat with the bot
  and publishes each action tagged with the seat it was for; a client that
  comes back to actions played for it rebuilds from the snapshot.
- Ludo: three silent turns no longer end a casual game; the table's bot
  keeps the seat. Domino already played gone seats; it now never ends a
  casual game on a drop.
- A player still away when the game ends is not paid (no AFK farming).
- A "they left" claim while the other player is plainly at the table is
  refused by the server in every game.
- Casual tables everyone left 15+ minutes ago are closed by the scheduler
  (unpaid), and bot games idle a day; 184 rows had been stuck in progress.

Tournament games: no bot. The tournament sets how long to wait and what
then: a forfeit, or a draw (chess). The ring counts the tournament's wait,
the server checks the absence itself (match_presence), and the scheduler
applies the rule when no client is left to claim it.

Tests: tests/play/standin-api.mjs (server rules, chess), a backgammon
stand-in section; resume, online and tournament suites pass.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 62d54dbf
...@@ -590,6 +590,13 @@ function handleComplete(string $userId, array $input): void { ...@@ -590,6 +590,13 @@ function handleComplete(string $userId, array $input): void {
} }
if (!is_string($winner) || !in_array($winner, $players, true)) $winner = null; if (!is_string($winner) || !in_array($winner, $players, true)) $winner = null;
// "The opponent left" while they are plainly still at the table: refused
// (a casual seat that goes quiet is played by the table's bot).
if ($reason === 'abandon') {
$others = array_values(array_filter(array_map('strval', $players), fn($p) => $p !== $userId));
if (presenceSomeoneHere((string)$matchId, $others)) jsonResponse(['success' => false, 'refused' => 'not_gone']);
}
// Claim the completion once: both clients report the end of a live match, // Claim the completion once: both clients report the end of a live match,
// and only the first report may pay out. // and only the first report may pay out.
$claimed = $sdb->update('backgammon_matches', [ $claimed = $sdb->update('backgammon_matches', [
...@@ -613,6 +620,9 @@ function handleComplete(string $userId, array $input): void { ...@@ -613,6 +620,9 @@ function handleComplete(string $userId, array $input): void {
if (is_string($p) && $p !== $winner && !str_starts_with($p, 'bot_')) $paid[] = ['id' => $p, 'result' => 'loss']; if (is_string($p) && $p !== $winner && !str_starts_with($p, 'bot_')) $paid[] = ['id' => $p, 'result' => 'loss'];
} }
} }
// A seat the table was still playing for (its player away) is not paid.
$away = presenceAbsent((string)$matchId, array_column($paid, 'id'));
if ($away) $paid = array_values(array_filter($paid, fn($w) => !in_array($w['id'], $away, true)));
$result = supabaseRpc('complete_match', [ $result = supabaseRpc('complete_match', [
'p_game_key' => 'backgammon', 'p_game_key' => 'backgammon',
'p_match_id' => $matchId, 'p_match_id' => $matchId,
......
...@@ -73,4 +73,10 @@ if (tournamentThrottle('redeem_prune', 3600)) { ...@@ -73,4 +73,10 @@ if (tournamentThrottle('redeem_prune', 3600)) {
$report['redeem_attempts_pruned'] = redeemPruneAttempts(30); $report['redeem_attempts_pruned'] = redeemPruneAttempts(30);
} }
// Casual tables everyone left 15+ minutes ago: closed, unpaid. Every 5 minutes.
require_once __DIR__ . '/../includes/match-sweep.php';
if ($onlyTournament === '' && tournamentThrottle('match_sweep', 300)) {
try { $report['stale_tables'] = matchSweepStale($db); } catch (Throwable $e) { $report['errors'][] = 'match_sweep: ' . $e->getMessage(); }
}
echo json_encode($report, JSON_UNESCAPED_UNICODE); echo json_encode($report, JSON_UNESCAPED_UNICODE);
...@@ -635,6 +635,13 @@ function handleComplete(string $userId, array $input): void { ...@@ -635,6 +635,13 @@ function handleComplete(string $userId, array $input): void {
} }
$first = $winners[0] ?? null; $first = $winners[0] ?? null;
// "The others left" while one of them is plainly still at the table:
// refused (a casual seat that goes quiet is played by the table).
if ($reason === 'abandon' && empty($full['tournament_id'])) {
$others = array_values(array_filter(array_map('strval', dominoJson($full['players'] ?? null)), fn($p) => $p !== $userId));
if (presenceSomeoneHere((string)$matchId, $others)) jsonResponse(['success' => false, 'refused' => 'not_gone']);
}
// Anti-farming: a match closed within DOMINO_MIN_PAID_SECONDS of its // Anti-farming: a match closed within DOMINO_MIN_PAID_SECONDS of its
// start pays nobody (a real round of domino takes longer than that). // start pays nobody (a real round of domino takes longer than that).
$tooShort = strtotime($full['created_at'] ?? 'now') > time() - DOMINO_MIN_PAID_SECONDS; $tooShort = strtotime($full['created_at'] ?? 'now') > time() - DOMINO_MIN_PAID_SECONDS;
...@@ -666,6 +673,9 @@ function handleComplete(string $userId, array $input): void { ...@@ -666,6 +673,9 @@ function handleComplete(string $userId, array $input): void {
// JSON string, and the function failed with "cannot get array length of // JSON string, and the function failed with "cannot get array length of
// a scalar", so no domino game ever paid coins or XP. Bots are sent as // a scalar", so no domino game ever paid coins or XP. Bots are sent as
// null (the function skips nulls; a 'bot_' id fails its uuid cast). // null (the function skips nulls; a 'bot_' id fails its uuid cast).
// A seat the table was still playing for (its player away) is not paid.
$away = presenceAbsent((string)$matchId, array_filter($winners));
if ($away) $winners = array_map(fn($w) => in_array($w, $away, true) ? null : $w, $winners);
$result = supabaseRpc('complete_match', [ $result = supabaseRpc('complete_match', [
'p_game_key' => 'domino', 'p_game_key' => 'domino',
'p_match_id' => $matchId, 'p_match_id' => $matchId,
......
...@@ -50,6 +50,9 @@ switch ($action) { ...@@ -50,6 +50,9 @@ switch ($action) {
case 'heartbeat': case 'heartbeat':
handleChessHeartbeat($userId, $input); handleChessHeartbeat($userId, $input);
break; break;
case 'standin_move':
handleStandinMove($db, $userId, $input);
break;
case 'leave': case 'leave':
handleChessLeave($userId, $input); handleChessLeave($userId, $input);
break; break;
...@@ -217,6 +220,10 @@ function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = nu ...@@ -217,6 +220,10 @@ function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = nu
// Both players, each with their result (win / loss / draw), so losses and // Both players, each with their result (win / loss / draw), so losses and
// draws are paid from reward_config too; this used to send the winner only. // draws are paid from reward_config too; this used to send the winner only.
$winners = chessPayoutPlayers($match, $derived); $winners = chessPayoutPlayers($match, $derived);
// A player still away at the end (a bot was playing their seat) is not paid
// for it: going quiet and letting the stand-in win must not farm coins.
$away = presenceAbsent($matchId, array_column($winners, 'id'));
if ($away) $winners = array_values(array_filter($winners, fn($w) => !in_array($w['id'], $away, true)));
// A bot-fill match's queue row (kept a moment after the hand-off) is done. // A bot-fill match's queue row (kept a moment after the hand-off) is done.
if ($isGhost) $sdb->delete('matchmaking_queue', ['match_id' => 'eq.' . $matchId]); if ($isGhost) $sdb->delete('matchmaking_queue', ['match_id' => 'eq.' . $matchId]);
...@@ -446,6 +453,67 @@ function handleGameMove($db, string $userId, array $input): void { ...@@ -446,6 +453,67 @@ function handleGameMove($db, string $userId, array $input): void {
jsonResponse(['success' => true]); jsonResponse(['success' => true]);
} }
/**
* A casual game whose player to move has gone (no ping for STANDIN_AFTER_S):
* the player still at the table asks, and the server plays that move for the
* absent side with the engine, near their own rating. The caller never picks
* the move. It goes through handleGameMove() as the absent player, so the
* clock, the turn check and the position check all apply. Refused in
* tournaments (they wait and then apply the tournament's disconnect rule) and
* once the player is back.
*/
function handleStandinMove($db, string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$rows = $sdb->get('matches', [
'id' => 'eq.' . $matchId,
'select' => 'id,white_player_id,black_player_id,status,current_fen,move_count,moves,tournament_id,match_type',
'limit' => 1,
]);
$match = (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null;
if (!$match) jsonError('Match not found', 404);
if ($match['white_player_id'] !== $userId && $match['black_player_id'] !== $userId) jsonError('Not authorized for this match', 403);
if ($match['status'] !== 'in_progress') jsonError('Match not in progress', 409);
if (!empty($match['tournament_id'])) jsonResponse(['success' => false, 'standin' => false, 'reason' => 'tournament']);
$side = chessSideToMove($match['current_fen'] ?? null);
$absent = $side === 'w' ? $match['white_player_id'] : ($side === 'b' ? $match['black_player_id'] : null);
if (!$absent || $absent === $userId) jsonResponse(['success' => false, 'standin' => false, 'reason' => 'your_turn']);
if (!presenceGoneFor($matchId, $absent, STANDIN_AFTER_S)) jsonResponse(['success' => false, 'standin' => false, 'reason' => 'player_here']);
// The engine plays near the absent player's own level.
$rating = 1200;
$r = $sdb->get('player_game_ratings', ['player_id' => 'eq.' . $absent, 'game_key' => 'eq.chess', 'select' => 'rating', 'limit' => 1]);
if (is_array($r) && !empty($r[0]['rating'])) $rating = (int)$r[0]['rating'];
$bot = $rating < 800 ? 'amina' : ($rating < 1000 ? 'tarek' : ($rating < 1200 ? 'nour' : ($rating < 1400 ? 'omar' : ($rating < 1600 ? 'layla' : 'ziad'))));
$ch = curl_init(STOCKFISH_API . '/api/chess/move');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => json_encode(['fen' => $match['current_fen'], 'bot_id' => $bot]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json']]);
$resp = json_decode((string)curl_exec($ch), true);
$uci = is_array($resp) ? (string)($resp['best_move'] ?? '') : '';
$newFen = $uci !== '' ? chessApplyUci((string)$match['current_fen'], $uci) : null;
if (!$newFen) {
mpLog($matchId, 'chess', $userId, 'standin_failed', ['uci' => $uci]);
jsonError('The stand-in could not move', 503);
}
$moves = is_string($match['moves'] ?? null) ? (json_decode($match['moves'], true) ?: []) : ($match['moves'] ?? []);
if (!is_array($moves)) $moves = [];
$move = ['from' => substr($uci, 0, 2), 'to' => substr($uci, 2, 2), 'promotion' => strlen($uci) > 4 ? $uci[4] : null, 'color' => $side, 'standin' => true];
$moves[] = $move;
mpLog($matchId, 'chess', $userId, 'standin_move', ['for' => $absent, 'uci' => $uci, 'bot' => $bot]);
handleGameMove($db, $absent, [
'match_id' => $matchId,
'fen' => $newFen,
'move' => $moves,
'move_count' => (int)($match['move_count'] ?? 0) + 1,
'game_state' => ['last_move' => $move + ['san' => null], 'standin' => ['player' => $absent, 'at' => gmdate('c')]],
]);
}
function handleResign($db, string $userId, array $input): void { function handleResign($db, string $userId, array $input): void {
$matchId = $input['match_id'] ?? ''; $matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required'); if (!$matchId) jsonError('match_id required');
...@@ -577,6 +645,21 @@ function handleComplete($db, string $userId, array $input): void { ...@@ -577,6 +645,21 @@ function handleComplete($db, string $userId, array $input): void {
$derived = chessDeriveResult($reason, $finalFen, $actorColor); $derived = chessDeriveResult($reason, $finalFen, $actorColor);
// A win claimed because the opponent left: the server checks they are
// really gone. Casual games do not end this way (a bot plays for them)
// unless the table is long dead; a tournament waits its grace and then
// applies its rule, a forfeit or a draw.
if ($reason === 'abandon' && $claimedOutcome === 'win') {
$opponent = $myColor === 'w' ? $match['black_player_id'] : $match['white_player_id'];
$policy = disconnectPolicy($match['tournament_id'] ?? null);
if (!disconnectClaimAllowed($matchId, $userId, [$opponent], $policy)) {
jsonResponse(['success' => false, 'refused' => 'not_gone', 'policy' => $policy]);
}
if ($policy['tournament'] && $policy['outcome'] === 'draw') {
$derived = ['result' => 'draw', 'reason' => 'abandon', 'winner' => null];
}
}
$extra = []; $extra = [];
if ($fen) $extra['current_fen'] = $fen; if ($fen) $extra['current_fen'] = $fen;
if ($pgn) $extra['pgn'] = $pgn; if ($pgn) $extra['pgn'] = $pgn;
......
...@@ -613,6 +613,13 @@ function handleLudoComplete(string $userId, array $input): void { ...@@ -613,6 +613,13 @@ function handleLudoComplete(string $userId, array $input): void {
} }
} }
// "Everyone else left" while someone else is plainly still at the table:
// refused (a casual seat that goes quiet is played by the table's bot).
if ($reason === 'abandon' && is_array($rows) && !empty($rows[0])) {
$others = array_values(array_filter(array_map('strval', ludoJson($rows[0]['players'] ?? null)), fn($p) => $p !== $userId));
if (presenceSomeoneHere((string)$matchId, $others)) jsonResponse(['success' => false, 'refused' => 'not_gone']);
}
// Mark match as completed, once. Every client at the table reports the end // Mark match as completed, once. Every client at the table reports the end
// of the game, and each call used to pay the rewards again. The update only // of the game, and each call used to pay the rewards again. The update only
// matches a row that is not completed yet, so exactly one call wins it. // matches a row that is not completed yet, so exactly one call wins it.
...@@ -638,6 +645,10 @@ function handleLudoComplete(string $userId, array $input): void { ...@@ -638,6 +645,10 @@ function handleLudoComplete(string $userId, array $input): void {
fn($w) => (is_string($w) && in_array($w, $seated, true) && preg_match('/^[0-9a-f-]{36}$/i', $w)) ? $w : null, fn($w) => (is_string($w) && in_array($w, $seated, true) && preg_match('/^[0-9a-f-]{36}$/i', $w)) ? $w : null,
is_array($winners) ? array_values($winners) : [] is_array($winners) ? array_values($winners) : []
); );
// A seat the table's bot was still playing at the end is not paid (null
// keeps the places of the others).
$away = presenceAbsent((string)$matchId, array_filter($winners));
if ($away) $winners = array_map(fn($w) => in_array($w, $away, true) ? null : $w, $winners);
$result = supabaseRpc('complete_match', [ $result = supabaseRpc('complete_match', [
'p_game_key' => 'ludo', 'p_game_key' => 'ludo',
'p_match_id' => $matchId, 'p_match_id' => $matchId,
......
...@@ -39,7 +39,7 @@ require_once __DIR__ . '/../includes/tournament-registration.php'; ...@@ -39,7 +39,7 @@ require_once __DIR__ . '/../includes/tournament-registration.php';
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
/** The columns a player may see; never live_custom_css, live_branding, sponsor_branding, created_by or Swiss ids. */ /** The columns a player may see; never live_custom_css, live_branding, sponsor_branding, created_by or Swiss ids. */
const TOURNAMENT_PLAYER_COLS = 'id,slug,name,name_ar,description,description_ar,rules_ar,game_key,format,time_control,status,' const TOURNAMENT_PLAYER_COLS = 'id,slug,disconnect_grace_seconds,disconnect_outcome,name,name_ar,description,description_ar,rules_ar,game_key,format,time_control,status,'
. 'max_players,min_players,swiss_rounds,rounds_total,starts_at,registration_opens_at,registration_closes_at,' . 'max_players,min_players,swiss_rounds,rounds_total,starts_at,registration_opens_at,registration_closes_at,'
. 'prize_pool_coins,prize_distribution,entry_fee_coins,min_rating,max_rating,members_only,org_id,access,entrant_kind,' . 'prize_pool_coins,prize_distribution,entry_fee_coins,min_rating,max_rating,members_only,org_id,access,entrant_kind,'
. 'team_size,current_round,current_phase,total_phases,tournament_mode,is_rated,tiebreak_rules,banner_url,auto_start,' . 'team_size,current_round,current_phase,total_phases,tournament_mode,is_rated,tiebreak_rules,banner_url,auto_start,'
......
...@@ -278,3 +278,79 @@ const TIME_CONTROL_ENUM = [ ...@@ -278,3 +278,79 @@ const TIME_CONTROL_ENUM = [
function chessNormaliseTimeControl(?string $tc, string $fallback = 'rapid_10_0'): string { function chessNormaliseTimeControl(?string $tc, string $fallback = 'rapid_10_0'): string {
return (is_string($tc) && in_array($tc, TIME_CONTROL_ENUM, true)) ? $tc : $fallback; return (is_string($tc) && in_array($tc, TIME_CONTROL_ENUM, true)) ? $tc : $fallback;
} }
/**
* Play a UCI move ("e2e4", "e7e8q", "e1g1") on a FEN and return the new FEN,
* or null when the move does not fit the position (no piece of the side to
* move on the from-square, or a capture of its own piece). Used by the
* stand-in that plays for a player who has gone (game.php standin_move): the
* engine chose a legal move, so this only has to apply it faithfully, castling,
* en passant, promotion and the counters included.
*/
function chessApplyUci(string $fen, string $uci): ?string {
$f = chessParseFen($fen);
if (!$f || !preg_match('/^([a-h][1-8])([a-h][1-8])([qrbn])?$/', strtolower($uci), $m)) return null;
// Board: $b[rank 0..7 from rank 8 down][file 0..7], '' for empty.
$b = [];
foreach (explode('/', $f['placement']) as $row) {
$line = [];
foreach (str_split($row) as $c) {
if ($c >= '1' && $c <= '8') { for ($i = 0; $i < (int)$c; $i++) $line[] = ''; } else $line[] = $c;
}
$b[] = $line;
}
$sq = fn(string $s) => [8 - (int)$s[1], ord($s[0]) - 97]; // [row, col]
[$fr, $fc] = $sq($m[1]);
[$tr, $tc] = $sq($m[2]);
$piece = $b[$fr][$fc];
$white = $f['side'] === 'w';
if ($piece === '' || (ctype_upper($piece) !== $white)) return null;
$target = $b[$tr][$tc];
if ($target !== '' && ctype_upper($target) === $white) return null;
$kind = strtolower($piece);
$capture = $target !== '';
$ep = '-';
// En passant: a pawn moving diagonally onto the empty en-passant square.
if ($kind === 'p' && $fc !== $tc && $target === '' && $m[2] === $f['en_passant']) {
$b[$fr][$tc] = '';
$capture = true;
}
// A double pawn step leaves an en-passant square behind it.
if ($kind === 'p' && abs($tr - $fr) === 2) {
$ep = $m[1][0] . (string)(($white ? 3 : 6));
}
$b[$tr][$tc] = $piece;
$b[$fr][$fc] = '';
// Promotion (a queen when the engine did not say).
if ($kind === 'p' && ($tr === 0 || $tr === 7)) {
$promo = $m[3] ?? 'q';
$b[$tr][$tc] = $white ? strtoupper($promo) : $promo;
}
// Castling: the king moved two files; bring the rook across.
if ($kind === 'k' && abs($tc - $fc) === 2) {
$rookFrom = $tc > $fc ? 7 : 0;
$rookTo = $tc > $fc ? 5 : 3;
$b[$tr][$rookTo] = $b[$tr][$rookFrom];
$b[$tr][$rookFrom] = '';
}
// Castling rights: a king move drops both; a rook leaving or being taken on its corner drops one.
$rights = $f['castling'] === '-' ? '' : $f['castling'];
if ($kind === 'k') $rights = str_replace($white ? ['K', 'Q'] : ['k', 'q'], '', $rights);
foreach (['a1' => 'Q', 'h1' => 'K', 'a8' => 'q', 'h8' => 'k'] as $corner => $r) {
if ($m[1] === $corner || $m[2] === $corner) $rights = str_replace($r, '', $rights);
}
$rows = [];
foreach ($b as $line) {
$out = ''; $empty = 0;
foreach ($line as $c) {
if ($c === '') { $empty++; continue; }
if ($empty) { $out .= $empty; $empty = 0; }
$out .= $c;
}
$rows[] = $out . ($empty ? $empty : '');
}
$half = ($kind === 'p' || $capture) ? 0 : $f['halfmove'] + 1;
$full = $f['fullmove'] + ($white ? 0 : 1);
return implode('/', $rows) . ' ' . ($white ? 'b' : 'w') . ' ' . ($rights === '' ? '-' : $rights) . ' ' . $ep . ' ' . $half . ' ' . $full;
}
...@@ -54,3 +54,94 @@ function presenceOf(string $matchId, ?string $exclude = null): array { ...@@ -54,3 +54,94 @@ function presenceOf(string $matchId, ?string $exclude = null): array {
function presenceValidId($id): bool { 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; 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;
} }
// ── When a player is gone, and what happens then ─────────────────────────
// Casual games: after STANDIN_AFTER_S without a ping a bot plays the seat
// (the client still at the table drives it; chess asks the server to move),
// until the player pings again. Nobody is handed a win for it, and a player
// still away when the game ends is not paid for it.
// Tournament games: no bot. After the tournament's disconnect_grace_seconds
// its disconnect_outcome applies: 'forfeit' (the player still there wins) or
// 'draw'. A client claims it; the server checks the ages; the scheduler
// applies it when nobody is left to claim.
const STANDIN_AFTER_S = 60;
const STALE_TABLE_S = 900; // a casual table nobody has pinged for this long is closed
/** Seconds since $playerId last pinged $matchId, or null when they never did. */
function presenceAge(string $matchId, string $playerId): ?int {
if (!presenceValidId($matchId) || !presenceValidId($playerId)) return null;
$rows = supabaseService()->get('match_presence', [
'match_id' => 'eq.' . $matchId, 'player_id' => 'eq.' . $playerId, 'select' => 'last_ping', 'limit' => 1,
]);
$at = (is_array($rows) && !empty($rows[0]['last_ping'])) ? strtotime($rows[0]['last_ping']) : false;
return $at ? max(0, time() - $at) : null;
}
/** Is $playerId gone from $matchId for at least $seconds? A player who never pinged counts as gone. */
function presenceGoneFor(string $matchId, string $playerId, int $seconds): bool {
$age = presenceAge($matchId, $playerId);
return $age === null || $age >= $seconds;
}
/**
* The disconnect rule for a match: ['tournament' => bool, 'grace' => seconds,
* 'outcome' => 'standin' | 'forfeit' | 'draw'].
*/
function disconnectPolicy(?string $tournamentId): array {
if (!$tournamentId || !presenceValidId($tournamentId)) {
return ['tournament' => false, 'grace' => STANDIN_AFTER_S, 'outcome' => 'standin'];
}
$rows = supabaseService()->get('el3ab_tournaments', [
'id' => 'eq.' . $tournamentId, 'select' => 'disconnect_grace_seconds,disconnect_outcome', 'limit' => 1,
]);
$t = (is_array($rows) && !empty($rows[0])) ? $rows[0] : [];
$grace = max(30, min(3600, (int)($t['disconnect_grace_seconds'] ?? 120)));
$outcome = ($t['disconnect_outcome'] ?? 'forfeit') === 'draw' ? 'draw' : 'forfeit';
return ['tournament' => true, 'grace' => $grace, 'outcome' => $outcome];
}
/**
* May $claimant end $matchId because everyone in $others is gone? The claimant
* must be here (pinged within the last minute) and every other seat gone for
* the policy's grace. Casual tables never end this way (a bot plays on), unless
* they have been gone so long the table is stale.
*/
function disconnectClaimAllowed(string $matchId, string $claimant, array $others, array $policy): bool {
$mine = presenceAge($matchId, $claimant);
if ($mine !== null && $mine > STANDIN_AFTER_S) return false;
$need = $policy['tournament'] ? (int)$policy['grace'] : STALE_TABLE_S;
foreach ($others as $o) {
if (!$o || $o === $claimant || str_starts_with((string)$o, 'bot')) continue;
if (!presenceGoneFor($matchId, (string)$o, $need)) return false;
}
return true;
}
/**
* The players of $players who were at $matchId and have been away for
* STANDIN_AFTER_S or more (their seat is being played for them). A player who
* never pinged is not judged: some clients and tools finish a game without a
* heartbeat.
*/
function presenceAbsent(string $matchId, array $players): array {
$seen = presenceOf($matchId);
$out = [];
foreach ($players as $p) {
if (!presenceValidId($p)) continue;
$age = $seen[$p]['age'] ?? null;
if ($age !== null && $age >= STANDIN_AFTER_S) $out[] = $p;
}
return $out;
}
/** Has any of $others (people, not bot seats) pinged $matchId within $seconds? Then nobody may claim they left. */
function presenceSomeoneHere(string $matchId, array $others, int $seconds = 30): bool {
$seen = presenceOf($matchId);
foreach ($others as $o) {
$o = (string)$o;
if ($o === '' || str_starts_with($o, 'bot')) continue;
if (isset($seen[$o]) && $seen[$o]['age'] < $seconds) return true;
}
return false;
}
<?php
/**
* Close casual tables everyone has left (run by the scheduler, api/cron.php).
*
* A live game whose players have all been silent for STALE_TABLE_S (no
* match_presence ping) is over in all but name: nobody is coming back to it,
* and it used to stay "in_progress" for ever (over a hundred rows by
* 2026-10-27). It is marked abandoned and pays nobody. Tournament games are
* left to the tournament engine. Bot games live on the device and can be
* resumed for 4 hours (match-session RECOVERY_WINDOW_MS), so they close only
* after BOT_TABLE_DEAD_S without activity.
*/
const BOT_TABLE_DEAD_S = 86400;
require_once __DIR__ . '/supabase.php';
require_once __DIR__ . '/match-presence.php';
function matchSweepStale($sdb, int $limit = 40): array {
$cutoff = gmdate('Y-m-d\TH:i:s\Z', time() - STALE_TABLE_S);
$now = gmdate('c');
$tables = [
// table => [activity column, select, players(row), is a bot game(row), closing patch]
'matches' => ['updated_at', 'id,white_player_id,black_player_id,bot_id,match_type,game_state',
fn($r) => [$r['white_player_id'] ?? null, $r['black_player_id'] ?? null],
fn($r) => !empty($r['bot_id']) || ($r['match_type'] ?? '') === 'bot' || (matchSweepJson($r['game_state'] ?? null)['mode'] ?? '') === 'ghost',
['status' => 'abandoned', 'result' => 'aborted', 'completed_at' => $now, 'updated_at' => $now]],
'ludo_matches' => ['last_activity', 'id,players,game_state',
fn($r) => matchSweepJson($r['players'] ?? null),
fn($r) => in_array(matchSweepJson($r['game_state'] ?? null)['mode'] ?? '', ['bot', 'ghost'], true),
['status' => 'abandoned', 'result' => 'abandoned', 'completed_at' => $now]],
'domino_matches' => ['updated_at', 'id,players,game_state',
fn($r) => matchSweepJson($r['players'] ?? null),
fn($r) => in_array(matchSweepJson($r['game_state'] ?? null)['mode'] ?? '', ['bot', 'ghost'], true),
['status' => 'abandoned', 'result' => 'abandoned', 'completed_at' => $now, 'updated_at' => $now]],
'backgammon_matches' => ['updated_at', 'id,players,game_state',
fn($r) => matchSweepJson($r['players'] ?? null),
fn($r) => (bool)array_filter(matchSweepJson($r['players'] ?? null), fn($p) => is_string($p) && str_starts_with($p, 'bot')),
['status' => 'abandoned', 'result' => 'abandoned', 'completed_at' => $now, 'updated_at' => $now]],
];
$closed = [];
foreach ($tables as $table => [$col, $select, $playersOf, $isBot, $patch]) {
$rows = $sdb->get($table, [
'status' => 'eq.in_progress', 'tournament_id' => 'is.null', $col => 'lt.' . $cutoff,
'select' => $select . ',' . $col, 'order' => $col . '.asc', 'limit' => $limit,
]);
if (!is_array($rows) || isset($rows['error'])) continue;
foreach ($rows as $r) {
if ($isBot($r)) {
$at = strtotime((string)($r[$col] ?? '')) ?: time();
if (time() - $at < BOT_TABLE_DEAD_S) continue;
$done = $sdb->update($table, $patch, ['id' => 'eq.' . $r['id'], 'status' => 'eq.in_progress']);
if (is_array($done) && !empty($done) && !isset($done['error'])) $closed[] = $table . ':' . $r['id'];
continue;
}
$people = array_values(array_filter(array_map('strval', $playersOf($r)), 'presenceValidId'));
if (!$people) continue;
$seen = presenceOf((string)$r['id']);
$someoneHere = false;
foreach ($people as $p) if (isset($seen[$p]) && $seen[$p]['age'] < STALE_TABLE_S) { $someoneHere = true; break; }
if ($someoneHere) continue;
$done = $sdb->update($table, $patch, ['id' => 'eq.' . $r['id'], 'status' => 'eq.in_progress']);
if (is_array($done) && !empty($done) && !isset($done['error'])) $closed[] = $table . ':' . $r['id'];
}
}
return ['closed' => count($closed), 'ids' => array_slice($closed, 0, 20)];
}
function matchSweepJson($v): array {
if (is_array($v)) return $v;
$d = is_string($v) ? json_decode($v, true) : null;
return is_array($d) ? $d : [];
}
<?php <?php
require_once __DIR__ . '/match-presence.php';
/** /**
* EL3AB native tournament engine. * EL3AB native tournament engine.
...@@ -1783,6 +1784,30 @@ function tournamentCronSweepRound($sdb, array $t, array $round, int $noShow, arr ...@@ -1783,6 +1784,30 @@ function tournamentCronSweepRound($sdb, array $t, array $round, int $noShow, arr
continue; continue;
} }
// A player dropped mid-game and the tournament's wait is over: its
// disconnect rule decides (a forfeit, or a draw), even when no client
// is left to claim it. Only a player who was at the board and went
// quiet is judged; if both went, the clocks decide as before.
if (function_exists('presenceAge')) {
$grace = max(30, min(3600, (int)($t['disconnect_grace_seconds'] ?? 120)));
$wAge = presenceAge((string)$match['id'], (string)$match['white_player_id']);
$bAge = presenceAge((string)$match['id'], (string)$match['black_player_id']);
$wGone = $wAge !== null && $wAge >= $grace;
$bGone = $bAge !== null && $bAge >= $grace;
if ($wGone !== $bGone) {
$hereAge = $wGone ? $bAge : $wAge;
if ($hereAge !== null && $hereAge < STANDIN_AFTER_S) {
$derived = ($t['disconnect_outcome'] ?? 'forfeit') === 'draw'
? ['result' => 'draw', 'reason' => 'abandon', 'winner' => null]
: chessDeriveResult('abandon', $match['current_fen'] ?? null, $wGone ? 'w' : 'b');
if (tournamentCronFinaliseMatch($sdb, $match, $derived)) {
$report['forfeited'][] = ['tournament' => $t['id'], 'board' => $p['board'] ?? null, 'reason' => 'disconnected', 'result' => $derived['result']];
}
continue;
}
}
}
$elapsedMs = max(0, (time() - strtotime($clockAnchor)) * 1000); $elapsedMs = max(0, (time() - strtotime($clockAnchor)) * 1000);
$col = $sideToMove === 'w' ? 'white_time_remaining_ms' : 'black_time_remaining_ms'; $col = $sideToMove === 'w' ? 'white_time_remaining_ms' : 'black_time_remaining_ms';
if ((int)($match[$col] ?? 0) - $elapsedMs > 0) continue; if ((int)($match[$col] ?? 0) - $elapsedMs > 0) continue;
...@@ -1800,7 +1825,7 @@ function tournamentCronSweepRound($sdb, array $t, array $round, int $noShow, arr ...@@ -1800,7 +1825,7 @@ function tournamentCronSweepRound($sdb, array $t, array $round, int $noShow, arr
* A trimmed copy of game.php's finaliseMatch(): the same conditional write and * A trimmed copy of game.php's finaliseMatch(): the same conditional write and
* the same follow-up work, without needing a request-scoped user. * the same follow-up work, without needing a request-scoped user.
*/ */
function tournamentCronFinaliseMatch($db, array $match, array $derived, string $zeroClockColumn): bool { function tournamentCronFinaliseMatch($db, array $match, array $derived, string $zeroClockColumn = ''): bool {
if (!in_array($derived['result'], MATCH_RESULT_ENUM, true)) return false; if (!in_array($derived['result'], MATCH_RESULT_ENUM, true)) return false;
$metadata = tournamentDecodeJson($match['metadata'] ?? null); $metadata = tournamentDecodeJson($match['metadata'] ?? null);
...@@ -1808,14 +1833,15 @@ function tournamentCronFinaliseMatch($db, array $match, array $derived, string $ ...@@ -1808,14 +1833,15 @@ function tournamentCronFinaliseMatch($db, array $match, array $derived, string $
$metadata['winner'] = $derived['winner']; $metadata['winner'] = $derived['winner'];
$metadata['ended_by'] = 'system'; $metadata['ended_by'] = 'system';
$written = $db->update('matches', [ $patch = [
'status' => 'completed', 'status' => 'completed',
'result' => $derived['result'], 'result' => $derived['result'],
'completed_at' => gmdate('c'), 'completed_at' => gmdate('c'),
'updated_at' => gmdate('c'), 'updated_at' => gmdate('c'),
'metadata' => $metadata, 'metadata' => $metadata,
$zeroClockColumn => 0, ];
], ['id' => 'eq.' . $match['id'], 'status' => 'neq.completed']); if ($zeroClockColumn !== '') $patch[$zeroClockColumn] = 0; // a flag fall; a disconnect leaves the clocks as they are
$written = $db->update('matches', $patch, ['id' => 'eq.' . $match['id'], 'status' => 'neq.completed']);
if (isset($written['error']) || empty($written)) return false; if (isset($written['error']) || empty($written)) return false;
......
...@@ -138,6 +138,12 @@ function tgPresence($sdb, string $matchId): array { ...@@ -138,6 +138,12 @@ function tgPresence($sdb, string $matchId): array {
} }
/** Away from the game for more than $seconds: no ping that recent (a seat that never pinged, since the row was made). */ /** Away from the game for more than $seconds: no ping that recent (a seat that never pinged, since the row was made). */
/** How long this tournament waits for a player who dropped (the manager's disconnect_grace_seconds). */
function tgGraceOf(?array $t): int {
$g = (int)($t['disconnect_grace_seconds'] ?? TG_ABSENT_SECONDS);
return max(30, min(3600, $g ?: TG_ABSENT_SECONDS));
}
function tgAway(array $presence, string $playerId, array $row, int $seconds = TG_ABSENT_SECONDS): bool { function tgAway(array $presence, string $playerId, array $row, int $seconds = TG_ABSENT_SECONDS): bool {
$since = $presence[$playerId] ?? (strtotime((string)($row['created_at'] ?? '')) ?: time()); $since = $presence[$playerId] ?? (strtotime((string)($row['created_at'] ?? '')) ?: time());
return time() - $since > $seconds; return time() - $since > $seconds;
...@@ -463,7 +469,8 @@ function tgEvaluate($sdb, array $ctx): array { ...@@ -463,7 +469,8 @@ function tgEvaluate($sdb, array $ctx): array {
$sc = $second ? count($second['by']) : 0; $sc = $second ? count($second['by']) : 0;
$presence = tgPresence($sdb, (string)$ctx['row']['id']); $presence = tgPresence($sdb, (string)$ctx['row']['id']);
$pending = array_values(array_filter($seats, fn($s) => !isset($reports[$s]) && !tgAway($presence, $s, $ctx['row']))); $grace = tgGraceOf($ctx['t'] ?? null);
$pending = array_values(array_filter($seats, fn($s) => !isset($reports[$s]) && !tgAway($presence, $s, $ctx['row'], $grace)));
if ($tc >= 2 && $tc > $sc) { if ($tc >= 2 && $tc > $sc) {
$how = $tc === count($seats) ? 'all' : 'majority'; $how = $tc === count($seats) ? 'all' : 'majority';
......
-- What a tournament does when a player drops mid-game (set in the manager):
-- disconnect_grace_seconds how long the table waits for them to come back
-- disconnect_outcome then: 'forfeit' (the player still there wins) or
-- 'draw' (two-player games; a heat places them last)
-- Casual games do not use these: a bot plays the seat until the player is back.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE public.el3ab_tournaments
ADD COLUMN IF NOT EXISTS disconnect_grace_seconds integer NOT NULL DEFAULT 120,
ADD COLUMN IF NOT EXISTS disconnect_outcome text NOT NULL DEFAULT 'forfeit';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'el3ab_tournaments_disconnect_grace_check') THEN
ALTER TABLE public.el3ab_tournaments ADD CONSTRAINT el3ab_tournaments_disconnect_grace_check
CHECK (disconnect_grace_seconds BETWEEN 30 AND 3600);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'el3ab_tournaments_disconnect_outcome_check') THEN
ALTER TABLE public.el3ab_tournaments ADD CONSTRAINT el3ab_tournaments_disconnect_outcome_check
CHECK (disconnect_outcome IN ('forfeit', 'draw'));
END IF;
END $$;
-- Presence is read by match: the scheduler sweeps stale tables by last_ping.
CREATE INDEX IF NOT EXISTS match_presence_match_idx ON public.match_presence (match_id, last_ping);
COMMIT;
NOTIFY pgrst, 'reload schema';
...@@ -215,6 +215,11 @@ const strings = { ...@@ -215,6 +215,11 @@ const strings = {
'match.disconnected_waiting': 'مستنيينه يرجع...', 'match.disconnected_waiting': 'مستنيينه يرجع...',
'match.disconnected_auto_win': 'لو مرجعش في الوقت، هتكسب', 'match.disconnected_auto_win': 'لو مرجعش في الوقت، هتكسب',
'match.claim_early_win': 'خد الفوز', 'match.claim_early_win': 'خد الفوز',
'match.disconnected_auto_draw': 'لو مرجعش في الوقت، الماتش تعادل',
'match.disconnected_standin': 'لو مرجعش، البوت هيلعب مكانه لحد ما يرجع',
'match.claim_draw': 'سجّل التعادل',
'match.standin_on': 'البوت بيلعب مكان الخصم لحد ما يرجع',
'match.standin_off': 'الخصم رجع وكمّل',
'match.opponent_returned': 'الخصم رجع، كمّل!', 'match.opponent_returned': 'الخصم رجع، كمّل!',
'match.you_won': 'كسبت!', 'match.you_won': 'كسبت!',
'match.opponent_left': 'الخصم خرج من الماتش', 'match.opponent_left': 'الخصم خرج من الماتش',
...@@ -1600,6 +1605,11 @@ const strings = { ...@@ -1600,6 +1605,11 @@ const strings = {
'match.disconnected_waiting': 'Waiting for them to return...', 'match.disconnected_waiting': 'Waiting for them to return...',
'match.disconnected_auto_win': 'You will win automatically if they don\'t return in time', 'match.disconnected_auto_win': 'You will win automatically if they don\'t return in time',
'match.claim_early_win': 'Claim Win', 'match.claim_early_win': 'Claim Win',
'match.disconnected_auto_draw': 'If they don\'t return in time, the game is a draw',
'match.disconnected_standin': 'If they don\'t return, a bot plays for them until they\'re back',
'match.claim_draw': 'Record the draw',
'match.standin_on': 'Your opponent dropped. A bot plays for them until they\'re back',
'match.standin_off': 'Your opponent is back',
'match.opponent_returned': 'Opponent returned — continue!', 'match.opponent_returned': 'Opponent returned — continue!',
'match.you_won': 'You Won!', 'match.you_won': 'You Won!',
'match.opponent_left': 'Opponent left the match', 'match.opponent_left': 'Opponent left the match',
......
...@@ -9,8 +9,13 @@ import * as ui from './match-ui.js'; ...@@ -9,8 +9,13 @@ import * as ui from './match-ui.js';
import * as mp from './multiplayer.js'; import * as mp from './multiplayer.js';
import * as store from './store.js'; import * as store from './store.js';
import * as net from './net.js'; import * as net from './net.js';
import { t } from './i18n.js';
const WAIT_CHIP_AFTER_MS = 2500; // a quick opponent never sees the waiting chip const WAIT_CHIP_AFTER_MS = 2500; // a quick opponent never sees the waiting chip
// A casual opponent counts as gone 30 s after the last sign of them
// (match-session); 30 s later a bot takes their seat (includes/match-presence.php
// STANDIN_AFTER_S is 60 s, measured on the server).
const STANDIN_DELAY_MS = 30000;
const ENDPOINT = { chess: 'game.php', ludo: 'ludo-match.php', domino: 'domino-match.php', backgammon: 'backgammon-match.php' }; const ENDPOINT = { chess: 'game.php', ludo: 'ludo-match.php', domino: 'domino-match.php', backgammon: 'backgammon-match.php' };
...@@ -45,7 +50,39 @@ async function claimAbandonWin(matchId, gameType, lastData) { ...@@ -45,7 +50,39 @@ async function claimAbandonWin(matchId, gameType, lastData) {
} }
export function start(matchId, gameType, options = {}) { export function start(matchId, gameType, options = {}) {
const { onMove, onGameEnd, onOpponentAbandon, onResume } = options; const { onMove, onGameEnd, onOpponentAbandon, onResume, onStandIn } = options;
// Casual or tournament, from the row (tournament_id); the tournament's wait
// and its rule (a forfeit or a draw) from its detail. Casual: a bot plays the
// seat of a player who has gone (onStandIn(true)), until they are back
// (onStandIn(false)). Tournament: no bot; a countdown of the tournament's
// wait, then the claim, which the server checks.
let policy = { tournament: !!options.tournamentId, grace: 120, outcome: 'forfeit' };
let policyAsked = false;
const learnPolicy = (row) => {
if (policyAsked) return;
const tid = options.tournamentId || row?.tournament_id;
if (!tid && !row) return;
policyAsked = true;
if (!tid) return;
policy.tournament = true;
net.get('tournaments.php', { action: 'detail', id: tid }).then((d) => {
const tour = d?.tournament || d || {};
const g = Number(tour.disconnect_grace_seconds);
if (g > 0) policy.grace = Math.max(30, Math.min(3600, g));
if (tour.disconnect_outcome === 'draw' && gameType === 'chess') policy.outcome = 'draw';
}).catch(() => {});
};
if (options.tournamentId) learnPolicy(null);
let standin = false;
let standinTimer = null;
const standinOff = () => {
clearTimeout(standinTimer);
standinTimer = null;
if (!standin) return;
standin = false;
onStandIn?.(false);
};
// A refresh (or coming back into the app) finds this very match saved: say // 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. // so until the first answer from the server puts the table back.
...@@ -71,6 +108,7 @@ export function start(matchId, gameType, options = {}) { ...@@ -71,6 +108,7 @@ export function start(matchId, gameType, options = {}) {
const sess = session.create(matchId, gameType, { const sess = session.create(matchId, gameType, {
onOpponentMove: (data) => { onOpponentMove: (data) => {
lastData = data; lastData = data;
learnPolicy(data);
ui.hideOpponentThinking(); ui.hideOpponentThinking();
if (recovering) { recovering = false; ui.hideReconnecting(); } if (recovering) { recovering = false; ui.hideReconnecting(); }
...@@ -89,6 +127,7 @@ export function start(matchId, gameType, options = {}) { ...@@ -89,6 +127,7 @@ export function start(matchId, gameType, options = {}) {
if (data.status === 'completed') { if (data.status === 'completed') {
ended = true; ended = true;
standinOff();
clearTimeout(waitTimer); clearTimeout(waitTimer);
ui.hideWaitingForOpponent(false); ui.hideWaitingForOpponent(false);
onGameEnd?.(data); onGameEnd?.(data);
...@@ -107,17 +146,35 @@ export function start(matchId, gameType, options = {}) { ...@@ -107,17 +146,35 @@ export function start(matchId, gameType, options = {}) {
onOpponentDisconnect: () => { onOpponentDisconnect: () => {
if (ended) return; if (ended) return;
ui.hideWaitingForOpponent(false); ui.hideWaitingForOpponent(false);
ui.showOpponentDisconnect({ if (!policy.tournament) {
seconds: 60, // Casual: nobody is handed a win. The ring counts down, then a bot
onClaim: async () => { // takes the seat until they are back.
if (ended) return; ui.showOpponentDisconnect({
if (onOpponentAbandon) { onOpponentAbandon(lastData); return; } seconds: STANDIN_DELAY_MS / 1000, standin: true,
try { await claimAbandonWin(matchId, gameType, lastData); } catch (e) {} onClaim: () => {
}, if (ended || standin) return;
}); standin = true;
ui.toast(t('match.standin_on'), 'gold');
onStandIn?.(true);
},
});
return;
}
const claim = async () => {
if (ended) return;
if (onOpponentAbandon) { onOpponentAbandon(lastData, { outcome: policy.outcome }); return; }
let res = null;
try { res = await claimAbandonWin(matchId, gameType, lastData); } catch (e) {}
// The server measures the absence itself; too early, wait a little more.
if (res?.refused && !ended) ui.showOpponentDisconnect({ seconds: 15, draw: policy.outcome === 'draw', onClaim: claim });
};
// The opponent has been unseen 30 s already when this fires.
ui.showOpponentDisconnect({ seconds: Math.max(10, policy.grace - 30), draw: policy.outcome === 'draw', onClaim: claim });
}, },
onOpponentReconnect: () => { onOpponentReconnect: () => {
if (standin) ui.toast(t('match.standin_off'), 'green');
standinOff();
if (ui.isShowing() === 'disconnect') ui.showOpponentReconnect(); if (ui.isShowing() === 'disconnect') ui.showOpponentReconnect();
}, },
...@@ -141,6 +198,8 @@ export function start(matchId, gameType, options = {}) { ...@@ -141,6 +198,8 @@ export function start(matchId, gameType, options = {}) {
return { return {
session: sess, session: sess,
markOpponentActive: (playerId) => { session.markOpponentActive(playerId); opponentArrived(false); }, markOpponentActive: (playerId) => { session.markOpponentActive(playerId); opponentArrived(false); },
// Is a bot playing the opponent's seat right now (casual games)?
isStandIn: () => standin,
showThinking: () => ui.showOpponentThinking(), showThinking: () => ui.showOpponentThinking(),
hideThinking: () => ui.hideOpponentThinking(), hideThinking: () => ui.hideOpponentThinking(),
showReconnecting: () => ui.showReconnecting(), showReconnecting: () => ui.showReconnecting(),
......
...@@ -270,6 +270,7 @@ function ringMarkup(seconds, sticker = 'hourglass') { ...@@ -270,6 +270,7 @@ function ringMarkup(seconds, sticker = 'hourglass') {
let abandonTimer = null; let abandonTimer = null;
let abandonSeconds = 60; let abandonSeconds = 60;
let abandonClaim = null; let abandonClaim = null;
let abandonStandin = false; // casual: at zero a bot takes the seat (no win, no claim button)
const CLAIM_AFTER_LEFT = 30; // the early-win button wakes up with this many seconds left const CLAIM_AFTER_LEFT = 30; // the early-win button wakes up with this many seconds left
/** /**
...@@ -282,7 +283,10 @@ const CLAIM_AFTER_LEFT = 30; // the early-win button wakes up with this many s ...@@ -282,7 +283,10 @@ const CLAIM_AFTER_LEFT = 30; // the early-win button wakes up with this many s
* win panel shows straight away, as before. * win panel shows straight away, as before.
*/ */
export function showOpponentDisconnect(options = {}) { export function showOpponentDisconnect(options = {}) {
const { seconds = 60, onClaim = null } = options; // draw: the tournament's rule makes it a draw, not a win, when they stay gone.
// standin: a casual game; at zero the panel closes and a bot plays their seat.
const { seconds = 60, onClaim = null, draw = false, standin = false } = options;
abandonStandin = standin;
clearInterval(abandonTimer); clearInterval(abandonTimer);
audio.play('notification'); audio.play('notification');
juice.hapticMedium(); juice.hapticMedium();
...@@ -295,8 +299,8 @@ export function showOpponentDisconnect(options = {}) { ...@@ -295,8 +299,8 @@ export function showOpponentDisconnect(options = {}) {
body: ` body: `
${ringMarkup(seconds)} ${ringMarkup(seconds)}
<div class="mu-sub">${t('match.disconnected_waiting')}</div> <div class="mu-sub">${t('match.disconnected_waiting')}</div>
<div class="mu-note">${t('match.disconnected_auto_win')}</div> <div class="mu-note">${t(standin ? 'match.disconnected_standin' : draw ? 'match.disconnected_auto_draw' : 'match.disconnected_auto_win')}</div>
<button class="btn btn-gold btn-block" id="claim-win-btn" disabled>${gicon('trophy', 30)}<span>${t('match.claim_early_win')}</span></button>`, ${standin ? '' : `<button class="btn btn-gold btn-block" id="claim-win-btn" disabled>${gicon(draw ? 'handshake' : 'trophy', 30)}<span>${t(draw ? 'match.claim_draw' : 'match.claim_early_win')}</span></button>`}`,
}), 'disconnect'); }), 'disconnect');
const fill = document.getElementById('mu-ring-fill'); const fill = document.getElementById('mu-ring-fill');
...@@ -337,6 +341,11 @@ async function claimWin() { ...@@ -337,6 +341,11 @@ async function claimWin() {
abandonClaim = null; abandonClaim = null;
const btn = document.getElementById('claim-win-btn'); const btn = document.getElementById('claim-win-btn');
if (btn) { btn.disabled = true; btn.setAttribute('aria-busy', 'true'); } if (btn) { btn.disabled = true; btn.setAttribute('aria-busy', 'true'); }
if (abandonStandin) {
if (currentPanel === 'disconnect') hide();
try { await claim?.(); } catch (e) {}
return;
}
if (claim) { if (claim) {
try { await claim(); } catch (e) {} try { await claim(); } catch (e) {}
} }
......
...@@ -475,6 +475,7 @@ function performMove(m, actor, done) { ...@@ -475,6 +475,7 @@ function performMove(m, actor, done) {
// 31: this move brought the first checker home; the start opens. // 31: this move brought the first checker home; the start opens.
const arrived = !!runnerOut && !openingRule(game, type) && m.to !== 'off'; const arrived = !!runnerOut && !openingRule(game, type) && m.to !== 'off';
if (S.live && (actor === 'me' || actor === 'auto')) emit({ kind: 'move', from: m.from, steps: m.steps }); if (S.live && (actor === 'me' || actor === 'auto')) emit({ kind: 'move', from: m.from, steps: m.steps });
else if (S.live && actor === 'standin') emit({ kind: 'move', from: m.from, steps: m.steps, for: type });
const main = actions.find(a => a.type !== 'hit'); const main = actions.find(a => a.type !== 'hit');
const hit = actions.find(a => a.type === 'hit'); const hit = actions.find(a => a.type === 'hit');
...@@ -580,6 +581,7 @@ function beginTurn(delay = 0) { ...@@ -580,6 +581,7 @@ function beginTurn(delay = 0) {
S.phase = 'opp'; S.phase = 'opp';
updateUI(); updateUI();
if (!S.live) later(botTurn, delay + ghostFill.delay(S.ghost, 'bg-roll', themeNum('bg_bot_delay', 700))); if (!S.live) later(botTurn, delay + ghostFill.delay(S.ghost, 'bg-roll', themeNum('bg_bot_delay', 700)));
else if (S.standin) later(standinTurn, delay + 900);
} }
function canRollNow() { function canRollNow() {
...@@ -647,7 +649,7 @@ function afterRoll(type) { ...@@ -647,7 +649,7 @@ function afterRoll(type) {
banner(mine ? (onBar ? t('backgammon.bar_blocked') : t('backgammon.no_moves')) : t('backgammon.opp_no_moves'), mine ? 'red' : 'blue', 1300); banner(mine ? (onBar ? t('backgammon.bar_blocked') : t('backgammon.no_moves')) : t('backgammon.opp_no_moves'), mine ? 'red' : 'blue', 1300);
audio.play('error'); audio.play('error');
if (mine) haptic('error'); if (mine) haptic('error');
if (!S.live || mine) later(() => endTurn(), 1400); if (!S.live || mine || S.standin) later(() => endTurn(), 1400);
return; return;
} }
if (type === S.myColor) { if (type === S.myColor) {
...@@ -659,6 +661,7 @@ function afterRoll(type) { ...@@ -659,6 +661,7 @@ function afterRoll(type) {
S.phase = 'opp'; S.phase = 'opp';
updateUI(); updateUI();
if (!S.live) botPlay(); if (!S.live) botPlay();
else if (S.standin) standinPlay();
} }
} }
...@@ -694,10 +697,12 @@ function afterMyMove() { ...@@ -694,10 +697,12 @@ function afterMyMove() {
function endTurn(fromRemote = false) { function endTurn(fromRemote = false) {
if (!S || S.over || S.game.isOver) return; if (!S || S.over || S.game.isOver) return;
const wasMine = S.game.turn === S.myColor; const wasMine = S.game.turn === S.myColor;
const forOpp = S.live && !wasMine && !fromRemote && S.standin ? S.game.turn : null;
nextTurn(S.game); nextTurn(S.game);
S.dice = null; S.dice = null;
S.sel = null; S.sel = null;
if (S.live && wasMine && !fromRemote) emit({ kind: 'end' }); if (S.live && wasMine && !fromRemote) emit({ kind: 'end' });
else if (forOpp !== null) emit({ kind: 'end', for: forOpp });
beginTurn(); beginTurn();
} }
...@@ -794,6 +799,58 @@ function botStep() { ...@@ -794,6 +799,58 @@ function botStep() {
}); });
} }
// ── Stand-in (casual live games) ───────────────────────────────────────────
// The opponent has gone: this client plays their seat with the bot until they
// are back. Each action is applied here exactly as a remote one would be, and
// published tagged with the seat it was for, so a client that comes back to
// find actions played for it rebuilds from the snapshot instead of replaying.
function setStandIn(on) {
if (!S || !S.live || S.tournamentId) return;
S.standin = !!on;
if (!on) return;
// A double we offered before they went: the stand-in answers it.
if (S.waitingAnswer && S.match.cube?.offered) {
const g = S.game;
const take = shouldBotAccept(S.match.cube, getPipCount(g.state, S.oppColor), getPipCount(g.state, S.myColor), 'medium');
const kind = take ? 'take' : 'drop';
applyRemote({ kind, g: S.match.gameNumber }, () => emit({ kind, for: S.oppColor }));
return;
}
if (S.game && S.game.turn === S.oppColor && !S.busy) {
if (!S.game.dice) standinTurn();
else if (S.phase === 'opp') standinPlay();
}
}
function standinTurn() {
if (!S || !S.standin || S.over || S.busy) return;
const g = S.game;
if (g.isOver || g.turn !== S.oppColor || g.dice) return;
const values = [1 + Math.floor(Math.random() * 6), 1 + Math.floor(Math.random() * 6)];
emit({ kind: 'roll', values, for: S.oppColor });
doRoll(S.oppColor, values, () => afterRoll(S.oppColor));
}
function standinPlay() {
const play = getBotPlay(S.game, 'medium');
S.botPlan = play ? play.moves.slice() : [];
later(standinStep, 700);
}
function standinStep() {
if (!S || S.over || S.game.isOver) return;
if (!S.standin) return; // they are back: their client takes the turn from here
if (turnIsDone(S.game)) { later(() => endTurn(), 520); return; }
const legal = getValidMoves(S.game);
const next = S.botPlan.shift();
const m = (next && legal.find(x => x.from === next.from && x.steps === next.steps)) || legal[0];
performMove(m, 'standin', () => {
if (S.game.isOver) { onGameOver(); return; }
later(standinStep, 450);
});
}
// ── Doubling ─────────────────────────────────────────────────────────────── // ── Doubling ───────────────────────────────────────────────────────────────
function canDoubleNow() { function canDoubleNow() {
...@@ -812,6 +869,7 @@ function onDoubleClick() { ...@@ -812,6 +869,7 @@ function onDoubleClick() {
emit({ kind: 'double' }); emit({ kind: 'double' });
updateUI(); updateUI();
banner(t('backgammon.waiting_answer'), 'blue', 1600); banner(t('backgammon.waiting_answer'), 'blue', 1600);
if (S.standin) later(() => setStandIn(true), 1200);
return; return;
} }
S.waitingAnswer = true; S.waitingAnswer = true;
...@@ -1101,9 +1159,27 @@ async function initLive() { ...@@ -1101,9 +1159,27 @@ async function initLive() {
// quiet (the win is claimed at zero), a banner when they come back. // quiet (the win is claimed at zero), a banner when they come back.
onOpponentDisconnect: () => { onOpponentDisconnect: () => {
if (!S || S.resultShown) return; if (!S || S.resultShown) return;
matchUi.showOpponentDisconnect({ seconds: 60, onClaim: () => { if (S && !S.resultShown) { matchUi.cleanup(); showMatchResult('abandon'); } } }); const s2 = S;
if (!S.tournamentId) {
// Casual: nobody is handed a win. The ring counts down 30 s, then a
// bot takes the seat until they are back.
matchUi.showOpponentDisconnect({ seconds: 30, standin: true, onClaim: () => {
if (S !== s2 || S.resultShown || S.standin) return;
matchUi.toast(t('match.standin_on'), 'gold');
setStandIn(true);
} });
return;
}
// A tournament: wait its grace (30 s are gone already), then claim; the server checks.
const ring = (grace) => matchUi.showOpponentDisconnect({ seconds: Math.max(10, grace - 30), onClaim: () => { if (S && !S.resultShown) { matchUi.cleanup(); showMatchResult('abandon'); } } });
net.get('tournaments.php', { action: 'detail', id: S.tournamentId })
.then(d => ring(Number((d?.tournament || d)?.disconnect_grace_seconds) || 120))
.catch(() => ring(120));
},
onOpponentReconnect: () => {
if (S?.standin) { matchUi.toast(t('match.standin_off'), 'green'); setStandIn(false); }
if (matchUi.isShowing() === 'disconnect') matchUi.showOpponentReconnect();
}, },
onOpponentReconnect: () => { if (matchUi.isShowing() === 'disconnect') matchUi.showOpponentReconnect(); },
// Fired for an explicit leave (the row says abandoned) and by the session's // Fired for an explicit leave (the row says abandoned) and by the session's
// own silence timer; only the first ends the match, the ring decides the other. // own silence timer; only the first ends the match, the ring decides the other.
onOpponentAbandon: async () => { onOpponentAbandon: async () => {
...@@ -1244,7 +1320,7 @@ function onServer(row) { ...@@ -1244,7 +1320,7 @@ function onServer(row) {
const fresh = (gs.log || []).filter(e => e.seq > S.queuedSeq).sort((a, b) => a.seq - b.seq); const fresh = (gs.log || []).filter(e => e.seq > S.queuedSeq).sort((a, b) => a.seq - b.seq);
const contiguous = fresh.length && fresh[0].seq === S.queuedSeq + 1 && fresh[fresh.length - 1].seq === gs.seq const contiguous = fresh.length && fresh[0].seq === S.queuedSeq + 1 && fresh[fresh.length - 1].seq === gs.seq
&& fresh.every((e, i) => e.seq === fresh[0].seq + i); && fresh.every((e, i) => e.seq === fresh[0].seq + i);
if (!contiguous) { snap(gs); return; } if (!contiguous || fresh.some(e => e.for === S.myColor)) { snap(gs); return; }
S.replay.push(...fresh); S.replay.push(...fresh);
S.queuedSeq = gs.seq; S.queuedSeq = gs.seq;
pumpReplay(); pumpReplay();
......
...@@ -340,6 +340,8 @@ export function mountGame(el, params) { ...@@ -340,6 +340,8 @@ export function mountGame(el, params) {
liveSession = matchLive.start(matchId, 'chess', { liveSession = matchLive.start(matchId, 'chess', {
onMove: (data) => handleLivePollData(el, data), onMove: (data) => handleLivePollData(el, data),
onGameEnd: () => {}, // handleLivePollData settles a completed match itself onGameEnd: () => {}, // handleLivePollData settles a completed match itself
tournamentId: gameState.tournamentId,
onStandIn: (on) => standIn(on),
}); });
syncFromServer(el, { initial: true }); syncFromServer(el, { initial: true });
} }
...@@ -586,6 +588,30 @@ function moveFeedback(el, m, who, inCheck, over) { ...@@ -586,6 +588,30 @@ function moveFeedback(el, m, who, inCheck, over) {
// =========================================================================== // ===========================================================================
// Bot // Bot
// =========================================================================== // ===========================================================================
/**
* A casual opponent who has gone: the server plays their moves (game.php
* standin_move picks it with the engine; this client never chooses it), and
* the move arrives like any other. Stops when they are back or the game ends.
*/
function standIn(on) {
if (!gameState) return;
clearInterval(gameState.standinTimer);
gameState.standinTimer = null;
if (!on || gameState.mode !== 'live' || !gameState.matchId) return;
const token = gameState.token;
let busy = false;
gameState.standinTimer = setInterval(async () => {
if (!alive(token) || gameState.gameOver) { clearInterval(gameState?.standinTimer); return; }
if (busy || engine.turn() === gameState.playerColor) return;
busy = true;
try {
const r = await net.post('game.php', { action: 'standin_move', match_id: gameState.matchId });
// Back already, or a tournament: nothing to play for them.
if (alive(token) && r && r.standin === false && r.reason !== 'your_turn') standIn(false);
} catch (e) {} finally { busy = false; }
}, 2500);
}
async function requestBotMove(el, attempt = 0) { async function requestBotMove(el, attempt = 0) {
if (!gameState || gameState.gameOver) return; if (!gameState || gameState.gameOver) return;
const token = gameState.token; const token = gameState.token;
...@@ -911,8 +937,9 @@ function handleLivePollData(el, data) { ...@@ -911,8 +937,9 @@ function handleLivePollData(el, data) {
function ingestServerMoves(el, data) { function ingestServerMoves(el, data) {
const list = parseJson(data.moves, []); const list = parseJson(data.moves, []);
const hist = engine.history(); const hist = engine.history();
// A move of theirs is the surest sign they are still at the table. // A move of theirs is the surest sign they are still at the table; one the
liveSession?.markOpponentActive(gameState.opponentId); // stand-in played for them (standin: true) is not.
if (!(Array.isArray(list) && list[list.length - 1]?.standin)) liveSession?.markOpponentActive(gameState.opponentId);
const prefixOk = Array.isArray(list) && list.length > hist.length const prefixOk = Array.isArray(list) && list.length > hist.length
&& hist.every((m, i) => list[i] && list[i].from === m.from && list[i].to === m.to); && hist.every((m, i) => list[i] && list[i].from === m.from && list[i].to === m.to);
if (!prefixOk) { if (!prefixOk) {
...@@ -1320,6 +1347,7 @@ function navigateToResult(r) { ...@@ -1320,6 +1347,7 @@ function navigateToResult(r) {
} }
export function unmountGame() { export function unmountGame() {
if (gameState) clearInterval(gameState.standinTimer);
// The live session owns a poll, a heartbeat, a disconnect watchdog and the // The live session owns a poll, a heartbeat, a disconnect watchdog and the
// recovery key. All of it goes with the scene. // recovery key. All of it goes with the scene.
if (liveSession) { if (liveSession) {
......
...@@ -1416,7 +1416,10 @@ async function applyRemote(data, sess) { ...@@ -1416,7 +1416,10 @@ async function applyRemote(data, sess) {
&& presentPeople().filter(i => i !== cur)[0] === S.myIndex && S.takenOver !== `${cur}:${S.seq}`) { && presentPeople().filter(i => i !== cur)[0] === S.myIndex && S.takenOver !== `${cur}:${S.seq}`) {
S.takenOver = `${cur}:${S.seq}`; S.takenOver = `${cur}:${S.seq}`;
S.strikes[cur] = (S.strikes[cur] || 0) + 1; S.strikes[cur] = (S.strikes[cur] || 0) + 1;
if (S.strikes[cur] >= 3) { S.left.add(cur); onPlayerLeft(cur); return; } // Three silent turns count as leaving in a tournament heat (its own
// disconnect rule decides on the server). In a casual game the table's
// bot keeps playing the seat until its player is back.
if (S.strikes[cur] >= 3 && S.tournamentId) { S.left.add(cur); onPlayerLeft(cur); return; }
if (!S.awayToldFor.has(cur)) { S.awayToldFor.add(cur); banner({ sticker: 'bot', title: esc(t('ludo.player_away', { name: displayName(S.names[cur]) })), tone: 'purple', sound: 'notification' }); } if (!S.awayToldFor.has(cur)) { S.awayToldFor.add(cur); banner({ sticker: 'bot', title: esc(t('ludo.player_away', { name: displayName(S.names[cur]) })), tone: 'purple', sound: 'notification' }); }
takeOver(cur); takeOver(cur);
} }
......
...@@ -771,6 +771,59 @@ async function goOnline(page, { variant = 'sheshbesh', length = 3, cube = true } ...@@ -771,6 +771,59 @@ async function goOnline(page, { variant = 'sheshbesh', length = 3, cube = true }
const boardSig = (s) => JSON.stringify([s.points, s.bar, s.off, s.turn, s.match.scores, s.match.cube && [s.match.cube.value, s.match.cube.owner, s.match.cube.offered]]); const boardSig = (s) => JSON.stringify([s.points, s.bar, s.off, s.turn, s.match.scores, s.match.cube && [s.match.cube.value, s.match.cube.owner, s.match.cube.offered]]);
/**
* Stand-in (casual): B's app goes away mid-match. A sees the ring, then a bot
* plays B's seat on A's client: B's turns are rolled and played, published
* tagged for B's colour, and the turn comes back to A each time. Runs on the
* in-test server, whose presence comes from the heartbeats.
*/
async function sectionStandin(browser) {
console.log('\n=== stand-in: the opponent goes, a bot plays for them ===');
const A = await createAccount('لاعب أ بديل');
const Bacc = await createAccount('لاعب ب بديل');
const browserB = await launch();
const pa = await bootPage(browser, A, { label: 'A' });
const pb = await bootPage(browserB, Bacc, { label: 'B' });
const fake = makeFakeServer();
await useFake(pa, fake, A); await useFake(pb, fake, Bacc);
try {
await goOnline(pa); await sleep(1500); await goOnline(pb);
const ok = await pa.waitForFunction(() => window.__bgTest && window.__bgTest.state().variant, { timeout: 45000, polling: 200 }).then(() => true, () => false)
&& await pb.waitForFunction(() => window.__bgTest && window.__bgTest.state().variant, { timeout: 15000, polling: 200 }).then(() => true, () => false);
if (!check('stand-in: both players matched into a game', ok)) return;
await setSpeed(pa);
const sa = await st(pa);
const oppColor = typeof sa.myColor === 'number' ? 1 - sa.myColor : (sa.myColor === 'white' ? 'black' : 'white');
// B goes: the page closes mid-match, its heartbeat stops.
await pb.close(); await browserB.close().catch(() => {});
const t0 = Date.now();
const ring = await pa.waitForFunction(() => document.querySelector('#match-overlay')?.dataset.panel === 'disconnect', { timeout: 60000, polling: 500 }).then(() => true, () => false);
check('stand-in: A sees the ring once B has gone quiet (no claim button)', ring && !(await pa.$('#claim-win-btn')));
// The ring runs out, closes, and the stand-in takes B's seat.
const closed = await pa.waitForFunction(() => document.querySelector('#match-overlay')?.dataset.panel !== 'disconnect', { timeout: 45000, polling: 500 }).then(() => true, () => false);
check('stand-in: the ring closes by itself when it runs out (no win screen)', closed && !(await st(pa))?.resultShown);
// From here A keeps playing its own turns; B's are played on A's client.
const m = () => [...fake.matches.values()][0];
let played = false;
for (let i = 0; i < 40 && !played; i++) {
const s = await st(pa);
if (s?.resultShown) break;
if (s?.canRoll || (s?.turn === s?.myColor && s?.phase === 'moving')) { await myTurn(pa).catch(() => {}); continue; }
const log = m()?.game_state?.log || [];
played = log.some(e => e.kind === 'roll' && e.for === oppColor) && log.some(e => e.kind === 'end' && e.for === oppColor);
await sleep(2500);
}
check('stand-in: B\'s turn was rolled and played for them, tagged for their colour', played, `after ${Math.round((Date.now() - t0) / 1000)} s`);
const back = await until(pa, x => x.resultShown || x.turn === x.myColor, { timeout: 30000, what: 'the turn back to A' }).then(() => true, () => false);
check('stand-in: the turn comes back to A', back);
check('stand-in: A was not handed a win', !(await st(pa))?.resultShown || (await st(pa))?.winner !== sa.myColor);
await shot(pa, 'standin-A');
noErrors(pa, 'stand-in A');
} finally {
if (!pa.isClosed()) await pa.close();
}
}
async function sectionOnline(browser) { async function sectionOnline(browser) {
console.log(`\n=== online: two players (${ONLINE}) ===`); console.log(`\n=== online: two players (${ONLINE}) ===`);
const modes = ONLINE === 'auto' ? ['real', 'fake'] : [ONLINE]; const modes = ONLINE === 'auto' ? ['real', 'fake'] : [ONLINE];
...@@ -960,6 +1013,7 @@ try { ...@@ -960,6 +1013,7 @@ try {
}); });
if (want('online')) await sectionOnline(browser).catch(e => check('online: ran without throwing', false, e.message)); if (want('online')) await sectionOnline(browser).catch(e => check('online: ran without throwing', false, e.message));
if (want('resume')) await sectionResume().catch(e => check('resume: ran without throwing', false, e.stack)); if (want('resume')) await sectionResume().catch(e => check('resume: ran without throwing', false, e.stack));
if (want('standin')) await sectionStandin(browser).catch(e => check('standin: ran without throwing', false, e.stack));
} finally { } finally {
try { await browser?.close(); } catch (e) {} try { await browser?.close(); } catch (e) {}
await removeAccounts(); await removeAccounts();
......
#!/usr/bin/env node
/**
* A player who drops mid-game, at the API (includes/match-presence.php):
*
* casual chess the server plays the absent side's move (game.php
* standin_move), never the caller's own, and stops once the
* player pings again; a win claimed "because they left" is
* refused while they are there; a player still away when the
* game ends is not paid.
* tournament no stand-in; the claim waits the tournament's
* disconnect_grace_seconds, measured on the server.
*
* node tests/play/standin-api.mjs [--base http://127.0.0.1:8080]
*
* Absence is staged by writing an old last_ping into match_presence. Every
* row it makes is removed again, failures included.
*/
import fs from 'fs';
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : d; };
const BASE = arg('--base', process.env.EL3AB_BASE || 'http://127.0.0.1:8080');
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = process.env.SUPABASE_SERVICE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
const START = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
const src = fs.readFileSync(new URL('../../public/js/lib/chess.min.js', import.meta.url), 'utf8');
const mod = { exports: {} };
new Function('module', 'exports', 'globalThis', src)(mod, mod.exports, globalThis);
const Chess = globalThis.Chess || mod.exports.Chess || mod.exports;
const failures = [];
const made = { users: [], matches: [], tournaments: [] };
const check = (label, ok, detail = '') => {
console.log(` ${ok ? 'ok ' : 'FAIL '} ${label}${!ok && detail ? ' -- ' + detail : ''}`);
if (!ok) failures.push(label);
return ok;
};
async function sb(p, { method = 'GET', body, prefer = 'return=representation' } = {}) {
const r = await fetch(`${SB}/rest/v1/${p}`, { method, headers: { apikey: SK, Authorization: `Bearer ${SK}`, 'Content-Type': 'application/json', Prefer: prefer }, body: body ? JSON.stringify(body) : undefined });
const t = await r.text();
try { return JSON.parse(t); } catch { return t; }
}
async function api(acc, endpoint, body) {
const r = await fetch(`${BASE}/api/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${acc.token}` }, body: JSON.stringify(body) });
const t = await r.text();
try { return JSON.parse(t); } catch { return { raw: t, status: r.status }; }
}
async function account(name) {
const r = await fetch(`${BASE}/api/auth.php`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'guest' }) }).then(x => x.json());
const acc = { id: r.profile.id, token: r.access_token };
made.users.push(acc.id);
await sb(`profiles?id=eq.${acc.id}`, { method: 'PATCH', body: { is_test: true, display_name: name } });
return acc;
}
const ping = (matchId, playerId, agoSeconds) => sb('match_presence?on_conflict=match_id,player_id', {
method: 'POST', prefer: 'resolution=merge-duplicates,return=representation',
body: { match_id: matchId, player_id: playerId, game_key: 'chess', last_ping: new Date(Date.now() - agoSeconds * 1000).toISOString() },
});
async function newMatch(white, black, extra = {}) {
const [m] = await sb('matches', { method: 'POST', body: {
game_key: 'chess', match_type: 'casual', white_player_id: white.id, black_player_id: black.id, status: 'in_progress',
time_control: 'rapid_10_0', initial_time_ms: 600000, increment_ms: 0, white_time_remaining_ms: 600000, black_time_remaining_ms: 600000,
starting_fen: START, current_fen: START, moves: [], move_count: 0, game_state: {}, is_rated: false,
started_at: new Date(Date.now() - 300000).toISOString(), ...extra,
} });
made.matches.push(m.id);
return m;
}
const row = async (id) => (await sb(`matches?id=eq.${id}&select=*`))[0];
async function play(acc, m, san) {
const g = new Chess(m.current_fen);
const mv = g.move(san);
const moves = [...(Array.isArray(m.moves) ? m.moves : []), { from: mv.from, to: mv.to, san: mv.san, color: mv.color }];
return api(acc, 'game.php', { action: 'move', match_id: m.id, fen: g.fen(), move: JSON.stringify(moves), move_count: moves.length,
game_state: JSON.stringify({ last_move: { from: mv.from, to: mv.to, san: mv.san } }) });
}
try {
const A = await account('ZZZ-STANDIN A');
const B = await account('ZZZ-STANDIN B');
console.log('\ncasual chess: a bot plays for the player who has gone');
let m = await newMatch(A, B);
await api(A, 'game.php', { action: 'heartbeat', match_id: m.id });
await ping(m.id, B.id, 120); // B was at the board, then went quiet two minutes ago
let r = await api(A, 'game.php', { action: 'standin_move', match_id: m.id });
check('on my own turn the stand-in does nothing', r.standin === false && r.reason === 'your_turn', JSON.stringify(r));
check('my move lands as usual', (await play(A, m, 'e4')).success === true);
r = await api(A, 'game.php', { action: 'standin_move', match_id: m.id });
m = await row(m.id);
const last = Array.isArray(m.moves) ? m.moves[m.moves.length - 1] : null;
const g = new Chess(START); g.move('e4');
const legal = last && g.move({ from: last.from, to: last.to, promotion: last.promotion || undefined });
check('the server played the absent side\'s move', r.success === true && m.move_count === 2 && !!last?.standin, JSON.stringify(r) + ' ' + m.move_count);
check('...a legal move, and the stored position is the one it makes', !!legal && g.fen().split(' ').slice(0, 4).join(' ') === m.current_fen.split(' ').slice(0, 4).join(' '), `${g.fen()} vs ${m.current_fen}`);
check('I move again', (await play(A, m, new Chess(m.current_fen).moves()[0])).success === true);
await api(B, 'game.php', { action: 'heartbeat', match_id: m.id }); // B is back
r = await api(A, 'game.php', { action: 'standin_move', match_id: m.id });
check('once they ping again the stand-in stops', r.standin === false && r.reason === 'player_here', JSON.stringify(r));
r = await api(A, 'game.php', { action: 'complete', match_id: m.id, reason: 'abandon', result: 'win' });
check('"they left" is refused while they are here', r.success === false && r.refused === 'not_gone', JSON.stringify(r));
check('...and the game goes on', (await row(m.id)).status === 'in_progress');
console.log('\ncasual chess: a player still away at the end is not paid');
await ping(m.id, B.id, 120); // B has gone again
const before = (await sb(`profiles?id=eq.${B.id}&select=coins`))[0]?.coins ?? 0;
r = await api(A, 'game.php', { action: 'resign', match_id: m.id });
check('A resigns: the result stands', r.result === 'black_wins', JSON.stringify(r));
await new Promise(res => setTimeout(res, 800));
const after = (await sb(`profiles?id=eq.${B.id}&select=coins`))[0]?.coins ?? 0;
check('B won with the stand-in at the board and was paid nothing', after === before, `${before} -> ${after}`);
console.log('\ntournament chess: no stand-in, the tournament\'s wait');
const [tour] = await sb('el3ab_tournaments', { method: 'POST', body: {
name: 'ZZZ-STANDIN tournament', format: 'swiss', time_control: 'rapid_10_0', game_key: 'chess', status: 'in_progress',
starts_at: new Date().toISOString(), disconnect_grace_seconds: 300, disconnect_outcome: 'draw',
} });
made.tournaments.push(tour.id);
let tm = await newMatch(A, B, { tournament_id: tour.id, tournament_round: 1 });
await api(A, 'game.php', { action: 'heartbeat', match_id: tm.id });
await play(A, tm, 'd4');
await ping(tm.id, B.id, 120);
r = await api(A, 'game.php', { action: 'standin_move', match_id: tm.id });
check('a tournament game gets no stand-in', r.standin === false && r.reason === 'tournament', JSON.stringify(r));
r = await api(A, 'game.php', { action: 'complete', match_id: tm.id, reason: 'abandon', result: 'win' });
check('gone 2 minutes of a 5 minute wait: the claim is refused', r.success === false && r.refused === 'not_gone', JSON.stringify(r));
await ping(tm.id, B.id, 360);
r = await api(A, 'game.php', { action: 'complete', match_id: tm.id, reason: 'abandon', result: 'win' });
tm = await row(tm.id);
check('past the wait the tournament\'s rule applies: a draw', tm.status === 'completed' && tm.result === 'draw', JSON.stringify(r) + ' ' + tm.result);
} catch (e) {
check('ran without throwing', false, e.stack);
} finally {
for (const id of made.matches) { await sb(`match_presence?match_id=eq.${id}`, { method: 'DELETE' }); await sb(`mp_log?match_id=eq.${id}`, { method: 'DELETE' }).catch(() => {}); }
for (const id of made.tournaments) {
await sb(`matches?tournament_id=eq.${id}`, { method: 'DELETE' });
for (const t of ['tournament_pairings', 'tournament_rounds', 'tournament_registrations', 'tournament_standings']) await sb(`${t}?tournament_id=eq.${id}`, { method: 'DELETE' }).catch(() => {});
await sb(`el3ab_tournaments?id=eq.${id}`, { method: 'DELETE' });
}
for (const id of made.matches) await sb(`matches?id=eq.${id}`, { method: 'DELETE' });
for (const id of made.users) {
for (const p of [`economy_transactions?player_id=eq.${id}`, `rating_history?player_id=eq.${id}`, `player_game_ratings?player_id=eq.${id}`, `player_achievements?player_id=eq.${id}`, `mp_log?player_id=eq.${id}`, `profiles?id=eq.${id}`]) await sb(p, { method: 'DELETE' }).catch(() => {});
await fetch(`${SB}/auth/v1/admin/users/${id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
}
const left = await sb(`profiles?id=in.(${made.users.join(',')})&select=id`);
console.log(`\ncleanup: ${Array.isArray(left) ? left.length : '?'} profiles left`);
console.log(failures.length ? `${failures.length} FAILED` : 'all passed');
process.exit(failures.length ? 1 : 0);
}
...@@ -82,6 +82,8 @@ HOSTING=() ...@@ -82,6 +82,8 @@ HOSTING=()
for f in ../migrations/2026-10-27-hosting-*.sql; do for f in ../migrations/2026-10-27-hosting-*.sql; do
[ -f "$f" ] && [ "$f" != ../migrations/2026-10-27-hosting-schema.sql ] && HOSTING+=("$f") [ -f "$f" ] && [ "$f" != ../migrations/2026-10-27-hosting-schema.sql ] && HOSTING+=("$f")
done done
# What a tournament does when a player drops mid-game (columns the engine reads).
[ -f ../migrations/2026-10-27-disconnect-rules.sql ] && HOSTING+=(../migrations/2026-10-27-disconnect-rules.sql)
apply_hosting() { apply_hosting() {
local f local f
......
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