Commit 00bcf1a0 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: cascade 5 global multiplayer systems to all game endpoints

- domino-match.php: add can_player_queue anti-abuse, last_activity tracking,
  server-side turn timeout (30s), universal complete_match rewards,
  heartbeat + leave handlers
- game.php (chess): add heartbeat + leave handlers, last_activity on moves,
  replace homebrew auto-close with server turn timeout (120s),
  replace inline reward logic with universal complete_match RPC
- matchmaking.php: add can_player_queue cooldown check before queuing
- ludo.php (old API): add last_activity on moves, complete_match on end
- match-session.js: change ping from move-based to heartbeat action,
  handle 'abandoned' status in poll response
- match-ui.js: add showBotReplacement and showTurnTimedOut overlays
- engine.js: add domino endpoint mapping for match recovery
- domino/game.js: add heartbeat (10s interval), handle _turn_timed_out,
  update endMatch to send winners array to universal rewards
- domino/result.js: remove duplicate complete call for live games,
  use new winners-array format for bot games
- ludo/result.js: add rewards display (coins, XP, rating change)
- Move shared supabaseRpc helper to includes/supabase.php
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 2ce274f3
...@@ -23,6 +23,8 @@ switch ($action) { ...@@ -23,6 +23,8 @@ switch ($action) {
case 'get': handleGet($userId, $input); break; case 'get': handleGet($userId, $input); break;
case 'resign': handleResign($userId, $input); break; case 'resign': handleResign($userId, $input); break;
case 'complete': handleComplete($userId, $input); break; case 'complete': handleComplete($userId, $input); break;
case 'heartbeat': handleDominoHeartbeat($userId, $input); break;
case 'leave': handleDominoLeave($userId, $input); break;
default: jsonError('Invalid action'); default: jsonError('Invalid action');
} }
...@@ -60,6 +62,13 @@ function handleStart(string $userId, array $input): void { ...@@ -60,6 +62,13 @@ function handleStart(string $userId, array $input): void {
function handleQueue(string $userId, array $input): void { function handleQueue(string $userId, array $input): void {
$sdb = supabaseService(); $sdb = supabaseService();
// Anti-abuse: check if player can queue (cooldown after repeated abandons)
$canQueue = supabaseRpc('can_player_queue', ['p_player_id' => $userId]);
if (is_array($canQueue) && isset($canQueue['allowed']) && !$canQueue['allowed']) {
jsonResponse(['error' => 'cooldown', 'seconds_remaining' => $canQueue['seconds_remaining'] ?? 60]);
return;
}
$sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId]); $sdb->delete('domino_queue', ['user_id' => 'eq.' . $userId]);
$searchUrl = SUPABASE_REST . '/domino_queue' $searchUrl = SUPABASE_REST . '/domino_queue'
...@@ -198,6 +207,8 @@ function handleMove(string $userId, array $input): void { ...@@ -198,6 +207,8 @@ function handleMove(string $userId, array $input): void {
$update['result'] = $input['result']; $update['result'] = $input['result'];
} }
$update['last_activity'] = date('c');
if (!empty($update)) { if (!empty($update)) {
$update['updated_at'] = date('c'); $update['updated_at'] = date('c');
$sdb->update('domino_matches', $update, ['id' => 'eq.' . $matchId]); $sdb->update('domino_matches', $update, ['id' => 'eq.' . $matchId]);
...@@ -216,6 +227,28 @@ function handleGet(string $userId, array $input): void { ...@@ -216,6 +227,28 @@ function handleGet(string $userId, array $input): void {
$match = $matches[0]; $match = $matches[0];
// Server-side turn timeout enforcement (30s for domino)
if ($match['status'] === 'in_progress') {
$timeout = supabaseRpc('enforce_turn_timeout', [
'p_game_key' => 'domino',
'p_match_id' => $matchId,
'p_timeout_seconds' => 30
]);
if (!empty($timeout['timeout'])) {
$match['_turn_timed_out'] = true;
$match['_timeout_player'] = $timeout['player_index'] ?? null;
$match['_replace_with_bot'] = $timeout['replace_with_bot'] ?? false;
// Re-fetch after timeout update
$matches = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
if (!empty($matches) && !isset($matches['error'])) {
$match = $matches[0];
$match['_turn_timed_out'] = true;
$match['_timeout_player'] = $timeout['player_index'] ?? null;
$match['_replace_with_bot'] = $timeout['replace_with_bot'] ?? false;
}
}
}
$players = json_decode($match['players'] ?? '[]', true); $players = json_decode($match['players'] ?? '[]', true);
$playerIndex = array_search($userId, $players); $playerIndex = array_search($userId, $players);
$hands = json_decode($match['hands'] ?? '{}', true); $hands = json_decode($match['hands'] ?? '{}', true);
...@@ -263,113 +296,68 @@ function handleResign(string $userId, array $input): void { ...@@ -263,113 +296,68 @@ function handleResign(string $userId, array $input): void {
function handleComplete(string $userId, array $input): void { function handleComplete(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');
$winners = $input['winners'] ?? [];
$reason = $input['reason'] ?? 'normal';
$sdb = supabaseService(); $sdb = supabaseService();
$playerResult = $input['result'] ?? '';
$winnerId = $input['winner_id'] ?? null;
$scores = $input['scores'] ?? null;
$opponentRating = intval($input['opponent_rating'] ?? 1200);
$update = [ // Mark match as completed
$sdb->update('domino_matches', [
'status' => 'completed', 'status' => 'completed',
'result' => $playerResult, 'completed_at' => date('c'),
'result' => $reason,
'winners' => json_encode($winners),
'updated_at' => date('c') 'updated_at' => date('c')
]; ], ['id' => 'eq.' . $matchId]);
if ($winnerId) $update['winner_id'] = $winnerId;
if ($scores) $update['scores'] = is_string($scores) ? $scores : json_encode($scores);
$sdb->update('domino_matches', $update, ['id' => 'eq.' . $matchId]);
$outcome = 'loss';
if ($winnerId === $userId) $outcome = 'win';
elseif ($playerResult === 'draw') $outcome = 'draw';
elseif (strpos($playerResult, 'player') !== false) {
$match = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]);
if (!empty($match) && !isset($match['error'])) {
$players = json_decode($match[0]['players'] ?? '[]', true);
$playerIdx = array_search($userId, $players);
$winnerIdx = intval(str_replace(['player', '_wins'], '', $playerResult)) - 1;
if ($playerIdx === $winnerIdx) $outcome = 'win';
}
}
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'elo_domino,games_played,total_wins,total_draws,total_losses,win_streak,coins,xp,level', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) && !isset($profiles['error']) ? $profiles[0] : null;
$ratingChange = 0;
$coins = 10;
$xp = 15;
$newRating = 1200;
if ($profile) {
$playerRating = $profile['elo_domino'] ?? 1200;
$score = ($outcome === 'win') ? 1.0 : (($outcome === 'draw') ? 0.5 : 0.0);
$newRating = calculateElo($playerRating, $opponentRating, $score);
$ratingChange = $newRating - $playerRating;
$updates = ['elo_domino' => $newRating, 'games_played' => ($profile['games_played'] ?? 0) + 1];
if ($outcome === 'win') {
$updates['total_wins'] = ($profile['total_wins'] ?? 0) + 1;
$updates['win_streak'] = ($profile['win_streak'] ?? 0) + 1;
} elseif ($outcome === 'draw') {
$updates['total_draws'] = ($profile['total_draws'] ?? 0) + 1;
$updates['win_streak'] = 0;
} else {
$updates['total_losses'] = ($profile['total_losses'] ?? 0) + 1;
$updates['win_streak'] = 0;
}
$sdb->update('profiles', $updates, ['id' => 'eq.' . $userId]); // Call universal rewards function
$result = supabaseRpc('complete_match', [
'p_game_key' => 'domino',
'p_match_id' => $matchId,
'p_winners' => json_encode($winners),
'p_reason' => $reason
]);
$sdb->insert('rating_history', [ jsonResponse($result ?: ['success' => true]);
'player_id' => $userId, }
'game_key' => 'domino',
'time_control_type' => 'standard',
'rating_before' => $playerRating,
'rating_after' => $newRating,
'rating_change' => $ratingChange,
'match_id' => $matchId,
'result' => $outcome
]);
$rewardConfig = $sdb->get('reward_config', ['select' => 'key,value']); function handleDominoHeartbeat(string $userId, array $input): void {
$rewards = []; $matchId = $input['match_id'] ?? '';
if (is_array($rewardConfig) && !isset($rewardConfig['error'])) { if (!$matchId) jsonError('match_id required');
foreach ($rewardConfig as $r) $rewards[$r['key']] = intval($r['value']);
}
$coins = ($outcome === 'win') ? ($rewards['domino_win_coins'] ?? 50) : (($outcome === 'draw') ? ($rewards['domino_draw_coins'] ?? 20) : ($rewards['domino_loss_coins'] ?? 10));
$xp = ($outcome === 'win') ? ($rewards['domino_win_xp'] ?? 30) : (($outcome === 'draw') ? ($rewards['domino_draw_xp'] ?? 15) : ($rewards['domino_loss_xp'] ?? 10));
$sdb->rpc('award_coins', ['p_player_id' => $userId, 'p_amount' => $coins, 'p_reason' => 'Domino ' . $outcome]);
$currentCoins = ($profile['coins'] ?? 0) + $coins;
$sdb->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'game_reward',
'currency' => 'coins',
'amount' => $coins,
'balance_after' => $currentCoins,
'reason' => 'Domino ' . $outcome
]);
}
jsonResponse([ $result = supabaseRpc('match_heartbeat', [
'success' => true, 'p_game_key' => 'domino',
'result' => $outcome, 'p_match_id' => $matchId,
'rating_before' => $profile ? ($profile['elo_domino'] ?? 1200) : 1200, 'p_player_id' => $userId
'rating_after' => $newRating,
'rating_change' => $ratingChange,
'coins_earned' => $coins,
'xp_earned' => $xp
]); ]);
jsonResponse($result ?: ['status' => 'ok']);
} }
function calculateElo(int $playerRating, int $opponentRating, float $score): int { function handleDominoLeave(string $userId, array $input): void {
$k = 32; $matchId = $input['match_id'] ?? '';
if ($playerRating > 2400) $k = 16; if (!$matchId) jsonError('match_id required');
elseif ($playerRating > 2000) $k = 24; $playerIndex = $input['player_index'] ?? null;
$sdb = supabaseService();
$matches = $sdb->get('domino_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
$gameState = json_decode($matches[0]['game_state'] ?? '{}', true);
$gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
$gameState['bot_replaced'][] = [
'player_id' => $userId,
'player_index' => $playerIndex,
'replaced_at' => date('c'),
'permanent' => false
];
$sdb->update('domino_matches', [
'game_state' => json_encode($gameState),
'last_activity' => date('c')
], ['id' => 'eq.' . $matchId]);
$expected = 1.0 / (1.0 + pow(10, ($opponentRating - $playerRating) / 400.0)); jsonResponse(['success' => true, 'bot_replaced' => true]);
$newRating = round($playerRating + $k * ($score - $expected));
return max(100, $newRating);
} }
...@@ -36,6 +36,12 @@ switch ($action) { ...@@ -36,6 +36,12 @@ switch ($action) {
case 'get': case 'get':
handleGet($db, $userId, $input); handleGet($db, $userId, $input);
break; break;
case 'heartbeat':
handleChessHeartbeat($userId, $input);
break;
case 'leave':
handleChessLeave($userId, $input);
break;
case 'find-active-match': case 'find-active-match':
handleFindActiveMatch($userId, $input); handleFindActiveMatch($userId, $input);
break; break;
...@@ -51,23 +57,23 @@ function handleGet($db, string $userId, array $input): void { ...@@ -51,23 +57,23 @@ function handleGet($db, string $userId, array $input): void {
$match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null; $match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null;
if (!$match) jsonError('Match not found', 404); if (!$match) jsonError('Match not found', 404);
// Auto-close: if match in_progress but no activity for 10s from BOTH players, close it // Server-side turn timeout enforcement (chess uses time control, fallback 120s)
if ($match['status'] === 'in_progress' && !empty($match['game_state'])) { if ($match['status'] === 'in_progress') {
$gs = json_decode($match['game_state'], true); $timeout = supabaseRpc('enforce_turn_timeout', [
if ($gs && isset($gs['ping'])) { 'p_game_key' => 'chess',
$lastPing = $gs['t'] ?? 0; 'p_match_id' => $matchId,
$updatedAt = strtotime($match['updated_at'] ?? '2000-01-01'); 'p_timeout_seconds' => 120
$now = time(); ]);
// If match hasn't been updated in 10+ seconds AND no recent ping if (!empty($timeout['timeout'])) {
if (($now - $updatedAt) > 10 && ($now * 1000 - $lastPing) > 10000) { $match['_turn_timed_out'] = true;
// Check if THIS player is the only one active — don't close if we're here $match['_timeout_player'] = $timeout['player_index'] ?? null;
// Only close if updated_at is stale (means nobody is writing) $match['_replace_with_bot'] = $timeout['replace_with_bot'] ?? false;
if (($now - $updatedAt) > 30) { $matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
// Both players inactive for 30s — close match as abandoned if (!empty($matches) && !isset($matches['error'])) {
$sdb->update('matches', ['status' => 'completed', 'result' => 'aborted'], ['id' => 'eq.' . $matchId]); $match = $matches[0];
$match['status'] = 'completed'; $match['_turn_timed_out'] = true;
$match['result'] = 'aborted'; $match['_timeout_player'] = $timeout['player_index'] ?? null;
} $match['_replace_with_bot'] = $timeout['replace_with_bot'] ?? false;
} }
} }
} }
...@@ -103,7 +109,7 @@ function handleGameMove($db, string $userId, array $input): void { ...@@ -103,7 +109,7 @@ function handleGameMove($db, string $userId, array $input): void {
if (!$matchId) jsonError('match_id required'); if (!$matchId) jsonError('match_id required');
$sdb = supabaseService(); $sdb = supabaseService();
$update = ['updated_at' => date('c')]; $update = ['updated_at' => date('c'), 'last_activity' => date('c')];
if (!empty($input['fen'])) $update['current_fen'] = $input['fen']; if (!empty($input['fen'])) $update['current_fen'] = $input['fen'];
if (!empty($input['move'])) $update['moves'] = $input['move']; if (!empty($input['move'])) $update['moves'] = $input['move'];
...@@ -177,12 +183,10 @@ function handleComplete($db, string $userId, array $input): void { ...@@ -177,12 +183,10 @@ function handleComplete($db, string $userId, array $input): void {
$result = $input['result'] ?? ''; $result = $input['result'] ?? '';
$fen = $input['fen'] ?? ''; $fen = $input['fen'] ?? '';
$pgn = $input['pgn'] ?? ''; $pgn = $input['pgn'] ?? '';
$opponentRating = intval($input['opponent_rating'] ?? 1200); $winners = $input['winners'] ?? [];
$timeControl = $input['time_control'] ?? 'rapid_10_0'; $reason = $input['reason'] ?? 'normal';
if (!$matchId || !$result) { if (!$matchId) jsonError('match_id required');
jsonError('match_id and result are required');
}
$sdb = supabaseService(); $sdb = supabaseService();
$sdb->update('matches', [ $sdb->update('matches', [
...@@ -193,78 +197,25 @@ function handleComplete($db, string $userId, array $input): void { ...@@ -193,78 +197,25 @@ function handleComplete($db, string $userId, array $input): void {
'ended_at' => date('c') 'ended_at' => date('c')
], ['id' => 'eq.' . $matchId]); ], ['id' => 'eq.' . $matchId]);
// Calculate Elo rating change // Use universal rewards function
$ratingCol = getRatingColumn($timeControl); $rewardResult = supabaseRpc('complete_match', [
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => $ratingCol . ',games_played,total_wins,total_draws,total_losses,win_streak,coins,xp,level', 'limit' => 1]); 'p_game_key' => 'chess',
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : null; 'p_match_id' => $matchId,
'p_winners' => json_encode($winners ?: [$userId]),
if ($profile) { 'p_reason' => $reason
$playerRating = $profile[$ratingCol] ?? 1200; ]);
$score = ($result === 'win') ? 1.0 : (($result === 'draw') ? 0.5 : 0.0);
$newRating = calculateElo($playerRating, $opponentRating, $score);
$ratingChange = $newRating - $playerRating;
$updates = [$ratingCol => $newRating, 'games_played' => ($profile['games_played'] ?? 0) + 1];
if ($result === 'win') {
$updates['total_wins'] = ($profile['total_wins'] ?? 0) + 1;
$updates['win_streak'] = ($profile['win_streak'] ?? 0) + 1;
} elseif ($result === 'draw') {
$updates['total_draws'] = ($profile['total_draws'] ?? 0) + 1;
$updates['win_streak'] = 0;
} else {
$updates['total_losses'] = ($profile['total_losses'] ?? 0) + 1;
$updates['win_streak'] = 0;
}
$sdb->update('profiles', $updates, ['id' => 'eq.' . $userId]);
// Record rating history
$sdb->insert('rating_history', [
'player_id' => $userId,
'game_key' => 'chess',
'time_control_type' => getTimeControlType($timeControl),
'rating_before' => $playerRating,
'rating_after' => $newRating,
'rating_change' => $ratingChange,
'match_id' => $matchId,
'result' => $result
]);
// Grant coins from reward_config (admin-configurable)
$rewardConfig = $sdb->get('reward_config', ['select' => 'key,value']);
$rewards = [];
if (is_array($rewardConfig) && !isset($rewardConfig['error'])) {
foreach ($rewardConfig as $r) $rewards[$r['key']] = intval($r['value']);
}
$coins = ($result === 'win') ? ($rewards['chess_win_coins'] ?? 50) : (($result === 'draw') ? ($rewards['chess_draw_coins'] ?? 20) : ($rewards['chess_loss_coins'] ?? 10));
// Use atomic DB function to prevent race conditions
$sdb->rpc('award_coins', ['p_player_id' => $userId, 'p_amount' => $coins, 'p_reason' => 'Chess ' . $result]);
// Legacy insert kept for compatibility
$currentCoins = ($profile['coins'] ?? 0) + $coins;
$sdb->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'game_reward',
'currency' => 'coins',
'amount' => $coins,
'balance_after' => $currentCoins + $coins,
'reason' => 'Chess ' . $result
]);
// Tournament result reporting hook
reportTournamentResult($db, $matchId, $result, $userId);
// Check achievements after game completion // Tournament result reporting hook
checkGameAchievements($sdb, $userId, $updates, $newRating); reportTournamentResult($db, $matchId, $result, $userId);
jsonResponse(['success' => true, 'result' => $result, 'rating_before' => $playerRating, 'rating_after' => $newRating, 'rating_change' => $ratingChange, 'coins_earned' => $coins]); // Check achievements
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'games_played,total_wins,win_streak,elo_rapid', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : null;
if ($profile) {
checkGameAchievements($sdb, $userId, $profile, $profile['elo_rapid'] ?? 1200);
} }
// Tournament result reporting hook (fallback when no profile) jsonResponse($rewardResult ?: ['success' => true, 'result' => $result]);
reportTournamentResult($db, $matchId, $result, $userId);
jsonResponse(['success' => true, 'result' => $result]);
} }
function calculateElo(int $playerRating, int $opponentRating, float $score): int { function calculateElo(int $playerRating, int $opponentRating, float $score): int {
...@@ -386,6 +337,43 @@ function checkGameAchievements($sdb, string $userId, array $profileUpdates, int ...@@ -386,6 +337,43 @@ function checkGameAchievements($sdb, string $userId, array $profileUpdates, int
} }
} }
function handleChessHeartbeat(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$result = supabaseRpc('match_heartbeat', [
'p_game_key' => 'chess',
'p_match_id' => $matchId,
'p_player_id' => $userId
]);
jsonResponse($result ?: ['status' => 'ok']);
}
function handleChessLeave(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'white_player_id,black_player_id,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
$gameState = json_decode($matches[0]['game_state'] ?? '{}', true);
$gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
$gameState['bot_replaced'][] = [
'player_id' => $userId,
'replaced_at' => date('c'),
'permanent' => false
];
$sdb->update('matches', [
'game_state' => json_encode($gameState),
'last_activity' => date('c')
], ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true, 'bot_replaced' => true]);
}
function handleFindActiveMatch(string $userId, array $input): void { function handleFindActiveMatch(string $userId, array $input): void {
$playerId = $input['player_id'] ?? ($_GET['player_id'] ?? ''); $playerId = $input['player_id'] ?? ($_GET['player_id'] ?? '');
if (!$playerId) jsonError('player_id required'); if (!$playerId) jsonError('player_id required');
......
...@@ -271,19 +271,3 @@ function handleLudoLeave(string $userId, array $input): void { ...@@ -271,19 +271,3 @@ function handleLudoLeave(string $userId, array $input): void {
jsonResponse(['success' => true, 'bot_replaced' => true]); jsonResponse(['success' => true, 'bot_replaced' => true]);
} }
function supabaseRpc(string $functionName, array $params): ?array {
$url = SUPABASE_REST . '/rpc/' . $functionName;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
return json_decode($result, true);
}
...@@ -58,7 +58,7 @@ switch ($action) { ...@@ -58,7 +58,7 @@ switch ($action) {
$currentTurn = $input['current_turn'] ?? null; $currentTurn = $input['current_turn'] ?? null;
if (!$matchId) jsonError('match_id required'); if (!$matchId) jsonError('match_id required');
$update = []; $update = ['last_activity' => date('c')];
if ($positions !== null) $update['positions'] = json_encode($positions); if ($positions !== null) $update['positions'] = json_encode($positions);
if ($currentTurn !== null) $update['current_turn'] = $currentTurn; if ($currentTurn !== null) $update['current_turn'] = $currentTurn;
if (!empty($update)) $db->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]); if (!empty($update)) $db->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]);
...@@ -70,10 +70,19 @@ switch ($action) { ...@@ -70,10 +70,19 @@ switch ($action) {
$winners = $input['winners'] ?? []; $winners = $input['winners'] ?? [];
if (!$matchId) jsonError('match_id required'); if (!$matchId) jsonError('match_id required');
$db->update('ludo_matches', [ $sdb = supabaseService();
$sdb->update('ludo_matches', [
'status' => 'completed', 'status' => 'completed',
'winners' => json_encode($winners) 'winners' => json_encode($winners),
'completed_at' => date('c')
], ['id' => 'eq.' . $matchId]); ], ['id' => 'eq.' . $matchId]);
supabaseRpc('complete_match', [
'p_game_key' => 'ludo',
'p_match_id' => $matchId,
'p_winners' => json_encode($winners),
'p_reason' => 'normal'
]);
jsonResponse(['success' => true]); jsonResponse(['success' => true]);
break; break;
......
...@@ -34,6 +34,13 @@ function handleQueue($db, string $userId, array $input): void { ...@@ -34,6 +34,13 @@ function handleQueue($db, string $userId, array $input): void {
$gameKey = $input['game_key'] ?? 'chess'; $gameKey = $input['game_key'] ?? 'chess';
$timeControl = $input['time_control'] ?? 'rapid_10_0'; $timeControl = $input['time_control'] ?? 'rapid_10_0';
// Anti-abuse: check if player can queue (cooldown after repeated abandons)
$canQueue = supabaseRpc('can_player_queue', ['p_player_id' => $userId]);
if (is_array($canQueue) && isset($canQueue['allowed']) && !$canQueue['allowed']) {
jsonResponse(['error' => 'cooldown', 'seconds_remaining' => $canQueue['seconds_remaining'] ?? 60]);
return;
}
// Use service key to bypass RLS for matchmaking operations // Use service key to bypass RLS for matchmaking operations
$sdb = supabaseService(); $sdb = supabaseService();
......
...@@ -10,6 +10,23 @@ function supabaseService(): SupabaseClient { ...@@ -10,6 +10,23 @@ function supabaseService(): SupabaseClient {
return new SupabaseClient(null, true); return new SupabaseClient(null, true);
} }
function supabaseRpc(string $functionName, array $params = []): ?array {
$url = SUPABASE_REST . '/rpc/' . $functionName;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
return json_decode($result, true);
}
function supabaseAuth(string $method, string $endpoint, ?array $body = null, ?string $token = null): array { function supabaseAuth(string $method, string $endpoint, ?array $body = null, ?string $token = null): array {
$url = SUPABASE_AUTH . '/' . $endpoint; $url = SUPABASE_AUTH . '/' . $endpoint;
$ch = curl_init($url); $ch = curl_init($url);
......
...@@ -104,7 +104,13 @@ function startPolling() { ...@@ -104,7 +104,13 @@ function startPolling() {
currentSession.lastServerPing = Date.now(); currentSession.lastServerPing = Date.now();
currentSession.onConnectionRestored?.(); currentSession.onConnectionRestored?.();
// Notify about opponent's move // Handle server-detected abandonment
if (data.status === 'abandoned') {
currentSession.onOpponentAbandon?.();
return;
}
// Notify about opponent's move (includes _turn_timed_out flags)
currentSession.onOpponentMove?.(data); currentSession.onOpponentMove?.(data);
} catch (e) { } catch (e) {
...@@ -116,7 +122,7 @@ function startPolling() { ...@@ -116,7 +122,7 @@ function startPolling() {
}, POLL_INTERVAL); }, POLL_INTERVAL);
} }
// ===== PING (let server know we're still here) ===== // ===== HEARTBEAT (let server know we're still connected) =====
function startPinging() { function startPinging() {
if (!currentSession) return; if (!currentSession) return;
currentSession.pingTimer = setInterval(async () => { currentSession.pingTimer = setInterval(async () => {
...@@ -124,9 +130,8 @@ function startPinging() { ...@@ -124,9 +130,8 @@ function startPinging() {
try { try {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : 'game.php'; const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : 'game.php';
await net.post(endpoint, { await net.post(endpoint, {
action: 'move', action: 'heartbeat',
match_id: currentSession.matchId, match_id: currentSession.matchId
game_state: JSON.stringify({ ping: currentSession.myUserId, t: Date.now() })
}); });
} catch (e) {} } catch (e) {}
}, PING_INTERVAL); }, PING_INTERVAL);
......
...@@ -188,6 +188,23 @@ function showToast(message, color = '#f8fafc') { ...@@ -188,6 +188,23 @@ function showToast(message, color = '#f8fafc') {
}, 3000); }, 3000);
} }
// ========== BOT REPLACEMENT ==========
export function showBotReplacement() {
showToast('🤖 الخصم غادر — بوت يكمل بدلاً منه', '#FBBF24');
juice.hapticLight();
}
// ========== TURN TIMED OUT ==========
export function showTurnTimedOut(isMyTimeout) {
if (isMyTimeout) {
showToast('⏱️ انتهى وقتك! تم التمرير تلقائياً', '#EF4444');
juice.hapticMedium();
} else {
showToast('⏱️ انتهى وقت الخصم — دورك!', '#34D399');
juice.hapticLight();
}
}
// ========== CLEANUP ========== // ========== CLEANUP ==========
export function cleanup() { export function cleanup() {
hide(); hide();
......
...@@ -57,7 +57,7 @@ async function boot() { ...@@ -57,7 +57,7 @@ async function boot() {
if (recoverable) { if (recoverable) {
// Verify match is still running on server before reconnecting // Verify match is still running on server before reconnecting
try { try {
const endpoint = recoverable.gameType === 'ludo' ? 'ludo-match.php' : 'game.php'; const endpoint = recoverable.gameType === 'ludo' ? 'ludo-match.php' : recoverable.gameType === 'domino' ? 'domino-match.php' : 'game.php';
const res = await fetch(`/api/${endpoint}`, { const res = await fetch(`/api/${endpoint}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
...@@ -69,7 +69,7 @@ async function boot() { ...@@ -69,7 +69,7 @@ async function boot() {
const gameScene = recoverable.gameType === 'chess' ? 'chess-game' : recoverable.gameType + '-game'; const gameScene = recoverable.gameType === 'chess' ? 'chess-game' : recoverable.gameType + '-game';
scene.push(gameScene, { mode: 'live', matchId: recoverable.matchId, recovered: true }); scene.push(gameScene, { mode: 'live', matchId: recoverable.matchId, recovered: true });
} else { } else {
// Match ended or not found — clear recovery and go home // Match ended, abandoned, or not found — clear recovery and go home
localStorage.removeItem('el3ab_active_match'); localStorage.removeItem('el3ab_active_match');
scene.switchWorld(store.get('activeWorld') || 'play'); scene.switchWorld(store.get('activeWorld') || 'play');
} }
......
...@@ -192,6 +192,7 @@ function setupLiveMultiplayer(el, matchId) { ...@@ -192,6 +192,7 @@ function setupLiveMultiplayer(el, matchId) {
fetchOpponentProfile(el); fetchOpponentProfile(el);
mp.startDisconnectWatch?.(matchId, 'domino', 60000); mp.startDisconnectWatch?.(matchId, 'domino', 60000);
startHeartbeat();
if (state.myPlayerIndex === 0) { if (state.myPlayerIndex === 0) {
dealAndSyncToServer(el, matchId); dealAndSyncToServer(el, matchId);
...@@ -200,6 +201,15 @@ function setupLiveMultiplayer(el, matchId) { ...@@ -200,6 +201,15 @@ function setupLiveMultiplayer(el, matchId) {
} }
} }
let heartbeatTimer = null;
function startHeartbeat() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => {
if (!state.matchId || state.matchOver) { clearInterval(heartbeatTimer); return; }
net.post('domino-match.php', { action: 'heartbeat', match_id: state.matchId }).catch(() => {});
}, 10000);
}
async function dealAndSyncToServer(el, matchId) { async function dealAndSyncToServer(el, matchId) {
dealNewRound(); dealNewRound();
board.setChain(state.chain); board.setChain(state.chain);
...@@ -296,6 +306,17 @@ function handleLivePollData(el, data) { ...@@ -296,6 +306,17 @@ function handleLivePollData(el, data) {
const connDot = el.querySelector('#conn-dot'); const connDot = el.querySelector('#conn-dot');
if (connDot) connDot.style.background = '#4ade80'; if (connDot) connDot.style.background = '#4ade80';
// Handle server-side turn timeout
if (data._turn_timed_out) {
const timedOutPlayer = data._timeout_player;
if (timedOutPlayer !== state.myPlayerIndex) {
// Opponent timed out — auto-pass their turn
state.currentPlayer = state.myPlayerIndex;
updateUI(el);
refreshHand();
}
}
const gs = data.game_state ? (typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state) : {}; const gs = data.game_state ? (typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state) : {};
const myId = store.get('auth.userId'); const myId = store.get('auth.userId');
...@@ -839,15 +860,20 @@ function endMatch(el, result, reason) { ...@@ -839,15 +860,20 @@ function endMatch(el, result, reason) {
if (state.matchId && state.mode === 'live') { if (state.matchId && state.mode === 'live') {
const myId = store.get('auth.userId'); const myId = store.get('auth.userId');
const players = state.players;
const winners = result === 'win'
? [myId, players.find(p => p !== myId)]
: [players.find(p => p !== myId), myId];
net.post('domino-match.php', { net.post('domino-match.php', {
action: 'complete', action: 'complete',
match_id: state.matchId, match_id: state.matchId,
result: result === 'win' ? 'player' + (state.myPlayerIndex + 1) + '_wins' : 'player' + (2 - state.myPlayerIndex) + '_wins', winners,
winner_id: result === 'win' ? myId : undefined, reason
scores: JSON.stringify({ '0': state.matchScores[0], '1': state.matchScores[1] })
}).catch(() => {}); }).catch(() => {});
} }
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
setTimeout(() => { setTimeout(() => {
scene.exitGameMode(); scene.exitGameMode();
scene.replace('domino-result', { scene.replace('domino-result', {
......
...@@ -3,6 +3,7 @@ import * as bus from '../../../core/bus.js'; ...@@ -3,6 +3,7 @@ import * as bus from '../../../core/bus.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
...@@ -122,14 +123,14 @@ async function completeOnServer(el, matchId, result, mode) { ...@@ -122,14 +123,14 @@ async function completeOnServer(el, matchId, result, mode) {
let xp = fallbackXp; let xp = fallbackXp;
let ratingChange = fallbackRating; let ratingChange = fallbackRating;
if (matchId) { // For bot games, call complete with rewards; live games already called complete in game.js
if (matchId && mode === 'bot') {
try { try {
const data = await net.post('domino-match.php', { const data = await net.post('domino-match.php', {
action: 'complete', action: 'complete',
match_id: matchId, match_id: matchId,
result: result === 'win' ? 'player_wins' : result === 'draw' ? 'draw' : 'player_loses', winners: result === 'win' ? [store.get('auth.userId')] : [],
winner_id: result === 'win' ? (await net.post('domino-match.php', { action: 'get', match_id: matchId }))?.winner_id : undefined, reason: result === 'draw' ? 'draw' : 'normal'
opponent_rating: 1200
}); });
if (data && !data.error) { if (data && !data.error) {
......
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
...@@ -9,7 +10,7 @@ const PLACE_LABEL = ['المركز الأول', 'المركز الثاني', 'ا ...@@ -9,7 +10,7 @@ const PLACE_LABEL = ['المركز الأول', 'المركز الثاني', 'ا
const PLACE_COLOR = ['#FFD700', '#C0C0C0', '#CD7F32']; const PLACE_COLOR = ['#FFD700', '#C0C0C0', '#CD7F32'];
export function mountResult(el, params) { export function mountResult(el, params) {
const { result, place, resigned } = params; const { result, place, resigned, rewards } = params;
const isWin = result === 'win'; const isWin = result === 'win';
let content; let content;
...@@ -33,9 +34,27 @@ export function mountResult(el, params) { ...@@ -33,9 +34,27 @@ export function mountResult(el, params) {
`; `;
} }
const coins = rewards?.coins_earned ?? (isWin ? 50 : 10);
const xp = rewards?.xp_earned ?? 15;
const ratingChange = rewards?.rating_change ?? 0;
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-6);padding:var(--s-6);"> <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-6);padding:var(--s-6);">
${content} ${content}
<!-- Rewards -->
<div style="display:flex;gap:12px;margin-top:8px;">
<div style="display:flex;align-items:center;gap:4px;padding:8px 14px;background:rgba(251,191,36,0.1);border:1px solid rgba(251,191,36,0.2);border-radius:10px;">
<span style="font-size:16px;">${emoji('coin', '🪙', 16)}</span>
<span style="font-size:14px;font-weight:700;color:#fbbf24;">+${coins}</span>
</div>
<div style="display:flex;align-items:center;gap:4px;padding:8px 14px;background:rgba(139,92,246,0.1);border:1px solid rgba(139,92,246,0.2);border-radius:10px;">
<span style="font-size:16px;">${emoji('star', '⭐', 16)}</span>
<span style="font-size:14px;font-weight:700;color:#a78bfa;">+${xp} XP</span>
</div>
</div>
${ratingChange ? `<div style="font-size:14px;font-weight:600;color:${ratingChange > 0 ? '#4ade80' : '#fca5a5'};">التصنيف: ${ratingChange > 0 ? '+' : ''}${ratingChange}</div>` : ''}
<div style="display:flex;gap:var(--s-3);margin-top:var(--s-6);"> <div style="display:flex;gap:var(--s-3);margin-top:var(--s-6);">
<button class="btn btn-primary" id="btn-again">${t('game.rematch')}</button> <button class="btn btn-primary" id="btn-again">${t('game.rematch')}</button>
<button class="btn btn-secondary" id="btn-back">${t('game.back')}</button> <button class="btn btn-secondary" id="btn-back">${t('game.back')}</button>
...@@ -43,6 +62,14 @@ export function mountResult(el, params) { ...@@ -43,6 +62,14 @@ export function mountResult(el, params) {
</div> </div>
`; `;
if (isWin) {
juice.hapticSuccess?.();
setTimeout(() => juice.confetti?.(window.innerWidth / 2, window.innerHeight / 3, 40), 300);
}
bus.emit('coins:earned', { amount: coins });
bus.emit('xp:earned', { amount: xp });
el.querySelector('#btn-again').addEventListener('click', () => { el.querySelector('#btn-again').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.replace('ludo-game', { mode: 'bot' }); scene.replace('ludo-game', { mode: 'bot' });
......
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