Commit 2ce274f3 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: implement 5 global multiplayer systems (DB + PHP + client)

## System 1: Match Lifecycle Manager
- DB function `cleanup_stale_matches()` auto-abandons zombie matches
  (waiting >5min, in_progress >30min) across all games
- Cleaned 109 existing zombies (12 ludo, 36 domino, 61 chess)
- New `match-cleanup.php` endpoint to trigger periodically

## System 2: Universal Match Completion + Rewards
- DB function `complete_match(game_key, match_id, winners, reason)`
  awards coins/XP, updates stats, tracks ratings for ANY game
- Ludo now calls `action: 'complete'` on game end with ordered winners
- Uses `reward_config` table (ludo_win_coins=40, 2nd=20, 3rd=10, 4th=5)
- Tracks `abandon_count` on profiles for anti-abuse

## System 3: Server-Side Turn Timeout
- Added `last_activity` column + index on ludo/domino matches
- DB function `enforce_turn_timeout()` checked on every GET poll
- After 20s of inactivity: marks turn as timed out
- After 3 consecutive timeouts: flags for permanent bot replacement
- Client handles `_turn_timed_out` response from server

## System 4: Connection State Protocol
- DB function `match_heartbeat(game_key, match_id, player_id)`
- Tracks per-player last_ping in game_state.connections
- Returns other players' status: online/weak/disconnected/abandoned
- Client sends heartbeat every 10s during live matches
- Works across chess, ludo, domino

## System 5: Anti-Abuse + RLS Hardening
- RLS: ludo/domino UPDATE now requires player to be participant
- Unique index prevents duplicate queue entries per user
- DB function `can_player_queue()` enforces 5-min cooldown after
  3+ abandons in 24h (resets daily)
- `handleLudoLeave` now tracks bot replacement as reversible (30s grace)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 2210bb63
...@@ -20,6 +20,9 @@ switch ($action) { ...@@ -20,6 +20,9 @@ switch ($action) {
case 'status': handleLudoStatus($userId); break; case 'status': handleLudoStatus($userId); break;
case 'move': handleLudoMove($userId, $input); break; case 'move': handleLudoMove($userId, $input); break;
case 'get': handleLudoGet($userId, $input); break; case 'get': handleLudoGet($userId, $input); break;
case 'complete': handleLudoComplete($userId, $input); break;
case 'heartbeat': handleHeartbeat($userId, $input); break;
case 'leave': handleLudoLeave($userId, $input); break;
default: jsonError('Invalid action'); default: jsonError('Invalid action');
} }
...@@ -145,11 +148,16 @@ function handleLudoMove(string $userId, array $input): void { ...@@ -145,11 +148,16 @@ function handleLudoMove(string $userId, array $input): void {
if (isset($input['dice_value'])) $update['dice_value'] = intval($input['dice_value']); if (isset($input['dice_value'])) $update['dice_value'] = intval($input['dice_value']);
if (isset($input['game_state'])) { if (isset($input['game_state'])) {
$gs = $input['game_state']; $gs = $input['game_state'];
$update['game_state'] = is_string($gs) ? $gs : json_encode($gs); if (is_string($gs)) $gs = json_decode($gs, true);
$gs['turn_started_at'] = date('c');
$gs['last_mover'] = $userId;
$update['game_state'] = json_encode($gs);
} }
if (isset($input['winners'])) $update['winners'] = json_encode($input['winners']); if (isset($input['winners'])) $update['winners'] = json_encode($input['winners']);
if (isset($input['status'])) $update['status'] = $input['status']; if (isset($input['status'])) $update['status'] = $input['status'];
$update['last_activity'] = date('c');
if (!empty($update)) { if (!empty($update)) {
$sdb->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]); $sdb->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]);
} }
...@@ -164,5 +172,118 @@ function handleLudoGet(string $userId, array $input): void { ...@@ -164,5 +172,118 @@ function handleLudoGet(string $userId, array $input): void {
$matches = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]); $matches = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found', 404); if (empty($matches) || isset($matches['error'])) jsonError('Not found', 404);
jsonResponse($matches[0]);
$match = $matches[0];
// Server-side turn timeout enforcement (20s)
if ($match['status'] === 'in_progress') {
$timeout = supabaseRpc('enforce_turn_timeout', [
'p_game_key' => 'ludo',
'p_match_id' => $matchId,
'p_timeout_seconds' => 20
]);
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('ludo_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;
}
}
}
jsonResponse($match);
}
function handleLudoComplete(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$winners = $input['winners'] ?? [];
$reason = $input['reason'] ?? 'normal';
$sdb = supabaseService();
// Mark match as completed
$sdb->update('ludo_matches', [
'status' => 'completed',
'completed_at' => date('c'),
'result' => $reason,
'winners' => json_encode($winners)
], ['id' => 'eq.' . $matchId]);
// Call universal rewards function
$result = supabaseRpc('complete_match', [
'p_game_key' => 'ludo',
'p_match_id' => $matchId,
'p_winners' => json_encode($winners),
'p_reason' => $reason
]);
jsonResponse($result ?: ['success' => true]);
}
function handleHeartbeat(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$result = supabaseRpc('match_heartbeat', [
'p_game_key' => 'ludo',
'p_match_id' => $matchId,
'p_player_id' => $userId
]);
jsonResponse($result ?: ['status' => 'ok']);
}
function handleLudoLeave(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$playerIndex = $input['player_index'] ?? null;
$sdb = supabaseService();
// Get current match state
$matches = $sdb->get('ludo_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
$players = json_decode($matches[0]['players'] ?? '[]', true);
$gameState = json_decode($matches[0]['game_state'] ?? '{}', true);
// Mark player as bot-replaced in game_state (reversible within 30s)
$gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
$gameState['bot_replaced'][] = [
'player_id' => $userId,
'player_index' => $playerIndex,
'replaced_at' => date('c'),
'permanent' => false
];
$sdb->update('ludo_matches', [
'game_state' => json_encode($gameState),
'last_activity' => date('c')
], ['id' => 'eq.' . $matchId]);
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);
} }
<?php
/**
* Match Lifecycle Cleanup — call periodically to clean zombie matches.
* Can be triggered by: admin panel load, external cron, or client on app boot.
*/
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
$url = SUPABASE_REST . '/rpc/cleanup_stale_matches';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, '{}');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
echo $result;
} else {
http_response_code(500);
echo json_encode(['error' => 'Cleanup failed', 'http_code' => $httpCode]);
}
...@@ -155,6 +155,7 @@ export function mountGame(el, params) { ...@@ -155,6 +155,7 @@ export function mountGame(el, params) {
} }
drawBoard(); drawBoard();
startRenderLoop(); startRenderLoop();
startHeartbeat();
updatePanels(el); updatePanels(el);
el.querySelector('#roll-btn').addEventListener('click', () => handleRoll(el)); el.querySelector('#roll-btn').addEventListener('click', () => handleRoll(el));
el.querySelector('#exit-btn').addEventListener('click', () => handleExit(el)); el.querySelector('#exit-btn').addEventListener('click', () => handleExit(el));
...@@ -464,6 +465,14 @@ function startLudoPolling(el) { ...@@ -464,6 +465,14 @@ function startLudoPolling(el) {
const data = await net.post('ludo-match.php', { action: 'get', match_id: matchId }); const data = await net.post('ludo-match.php', { action: 'get', match_id: matchId });
if (!data || data.error) return; if (!data || data.error) return;
// Server detected turn timeout — handle bot replacement
if (data._turn_timed_out) {
const timedOutPlayer = data._timeout_player;
if (data._replace_with_bot && timedOutPlayer != null) {
PLAYER_NAMES[timedOutPlayer] = 'Bot ' + timedOutPlayer;
}
}
// Parse game_state to get turn_count // Parse game_state to get turn_count
let gs = {}; let gs = {};
try { gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : (data.game_state || {}); } catch(e) {} try { gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : (data.game_state || {}); } catch(e) {}
...@@ -518,6 +527,15 @@ function startLudoPolling(el) { ...@@ -518,6 +527,15 @@ function startLudoPolling(el) {
}, 1500); }, 1500);
} }
// Heartbeat: ping server every 10s to track connection state
function startHeartbeat() {
if (!matchId || game.mode !== 'live') return;
const hb = setInterval(() => {
if (game.gameOver) { clearInterval(hb); return; }
net.post('ludo-match.php', { action: 'heartbeat', match_id: matchId }).catch(() => {});
}, 10000);
}
// ===== OPPONENT POPUP ===== // ===== OPPONENT POPUP =====
function showOpponentPopup(el, profile) { function showOpponentPopup(el, profile) {
const existing = document.getElementById('ludo-opp-popup'); const existing = document.getElementById('ludo-opp-popup');
...@@ -1152,11 +1170,30 @@ function endGame(el) { ...@@ -1152,11 +1170,30 @@ function endGame(el) {
if (matchId) matchLive.session?.destroy?.(); if (matchId) matchLive.session?.destroy?.();
const myRank = game.winners.indexOf(myPlayerIndex); const myRank = game.winners.indexOf(myPlayerIndex);
// Ranks 0,1,2 = 1st,2nd,3rd place (podium). Rank 3 = 4th (loser)
const isLoser = myRank === 3 || myRank === -1; const isLoser = myRank === 3 || myRank === -1;
const result = isLoser ? 'loss' : 'win'; const result = isLoser ? 'loss' : 'win';
const place = myRank >= 0 ? myRank + 1 : 4; const place = myRank >= 0 ? myRank + 1 : 4;
// Build ordered player IDs for rewards (1st → last)
const players = game.humanPlayers ? PLAYER_NAMES : ['bot_0','bot_1','bot_2','bot_3'];
const orderedWinners = game.winners.map(idx => {
const name = PLAYER_NAMES[idx];
if (name && name.startsWith('Bot')) return 'bot_' + idx;
return store.get('player')?.id || 'unknown';
});
// Call server for rewards (fire and forget for bots, await for live)
if (matchId || game.mode === 'live') {
net.post('ludo-match.php', {
action: 'complete',
match_id: matchId,
winners: orderedWinners,
reason: 'normal'
}).then(data => {
if (data?.rewards) bus.emit('rewards:received', data.rewards);
}).catch(() => {});
}
if (!isLoser) { if (!isLoser) {
juice.confetti(window.innerWidth / 2, window.innerHeight / 3, 50); juice.confetti(window.innerWidth / 2, window.innerHeight / 3, 50);
juice.starBurst(window.innerWidth / 2, window.innerHeight / 3, 15); juice.starBurst(window.innerWidth / 2, window.innerHeight / 3, 15);
......
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