Commit c6478774 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: auto-reconnect on homepage + auto-close match after 30s inactivity

Player reconnection:
- On app boot, checks localStorage for active match
- Verifies match is still 'in_progress' on server before rejoining
- If match ended/aborted → clears recovery, goes to homepage
- If server unreachable → tries to rejoin anyway (optimistic)

Match auto-close:
- handleGet() checks updated_at timestamp on every poll
- If match hasn't been updated in 30+ seconds → both players inactive
- Server marks match as 'completed' with result 'aborted'
- Next player who polls sees status='completed' → game ends cleanly
- Prevents zombie matches lingering forever

Flow:
1. Both players disconnect → no pings → updated_at goes stale
2. After 30s, if either player comes back and polls → server closes match
3. Match shows as 'aborted' → player sees 'game ended' UI
4. If only one player comes back within 30s → they keep playing (match alive)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent d6be81de
......@@ -40,9 +40,32 @@ switch ($action) {
function handleGet($db, string $userId, array $input): void {
$matchId = $_GET['match_id'] ?? ($input['match_id'] ?? '');
if (!$matchId) jsonError('match_id required');
$matches = $db->get('matches', ['id' => 'eq.' . $matchId, 'limit' => 1]);
$sdb = supabaseService();
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
$match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null;
if (!$match) jsonError('Match not found', 404);
// Auto-close: if match in_progress but no activity for 10s from BOTH players, close it
if ($match['status'] === 'in_progress' && !empty($match['game_state'])) {
$gs = json_decode($match['game_state'], true);
if ($gs && isset($gs['ping'])) {
$lastPing = $gs['t'] ?? 0;
$updatedAt = strtotime($match['updated_at'] ?? '2000-01-01');
$now = time();
// If match hasn't been updated in 10+ seconds AND no recent ping
if (($now - $updatedAt) > 10 && ($now * 1000 - $lastPing) > 10000) {
// Check if THIS player is the only one active — don't close if we're here
// Only close if updated_at is stale (means nobody is writing)
if (($now - $updatedAt) > 30) {
// Both players inactive for 30s — close match as abandoned
$sdb->update('matches', ['status' => 'completed', 'result' => 'aborted'], ['id' => 'eq.' . $matchId]);
$match['status'] = 'completed';
$match['result'] = 'aborted';
}
}
}
}
jsonResponse($match);
}
......
......@@ -26,11 +26,32 @@ async function boot() {
scene.setRoot('shop', 'shop-browse');
scene.setRoot('profile', 'profile-view');
// Check for active match to resume (tab refresh recovery)
// Check for active match to resume (tab refresh / re-entry recovery)
const recoverable = getRecoverableMatch();
if (recoverable) {
const gameScene = recoverable.gameType === 'chess' ? 'chess-game' : recoverable.gameType + '-game';
scene.push(gameScene, { mode: 'live', matchId: recoverable.matchId, recovered: true });
// Verify match is still running on server before reconnecting
try {
const endpoint = recoverable.gameType === 'ludo' ? 'ludo-match.php' : 'game.php';
const res = await fetch(`/api/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ action: 'get', match_id: recoverable.matchId })
});
const matchData = await res.json();
if (matchData && !matchData.error && matchData.status === 'in_progress') {
// Match still running — rejoin
const gameScene = recoverable.gameType === 'chess' ? 'chess-game' : recoverable.gameType + '-game';
scene.push(gameScene, { mode: 'live', matchId: recoverable.matchId, recovered: true });
} else {
// Match ended or not found — clear recovery and go home
localStorage.removeItem('el3ab_active_match');
scene.switchWorld(store.get('activeWorld') || 'play');
}
} catch (e) {
// Network error — try to rejoin anyway
const gameScene = recoverable.gameType === 'chess' ? 'chess-game' : recoverable.gameType + '-game';
scene.push(gameScene, { mode: 'live', matchId: recoverable.matchId, recovered: true });
}
} else {
scene.switchWorld(store.get('activeWorld') || 'play');
}
......
......@@ -2,65 +2,75 @@
// 15x15 grid. Colors: Red(TL), Blue(TR), Green(BL), Yellow(BR)
// Path goes CLOCKWISE starting from Red's entry
// Player order: 0=Red(TL), 1=Blue(TR), 2=Yellow(BR), 3=Green(BL)
// This matches the reference image exactly
// Player order: 0=Red(TL), 1=Green(BL), 2=Yellow(BR), 3=Blue(TR)
// Standard Ludo: clockwise from Red → Green → Yellow → Blue
// The 52 shared squares going CLOCKWISE
// Starting from Red's column (top-middle going down)
// Starting from Red's entry (top-center going down)
export const SHARED_PATH = [
// Red's start column (top, going down) — squares 0-4
// Red's start: top-center going DOWN — squares 0-4
[6,1],[6,2],[6,3],[6,4],[6,5],
// Top-left row (going left) — squares 5-10
// Turn left across top of Green zone — squares 5-10
[5,6],[4,6],[3,6],[2,6],[1,6],[0,6],
// Left corner turn — square 11
// Left corner — square 11
[0,7],
// Bottom-left row (going right) — squares 12-17
[0,8],[1,8],[2,8],[3,8],[4,8],[5,8],
// Green's start column (left-bottom, going down) — squares 18-23
// Left side going down (toward Green's start) — squares 12-13
[0,8],[1,8],
// GREEN'S START (square 13): bottom-left going RIGHT — squares 13-17
[2,8],[3,8],[4,8],[5,8],
// Green's column going DOWN — squares 18-23
[6,9],[6,10],[6,11],[6,12],[6,13],[6,14],
// Bottom corner turn — square 24
// Bottom corner — square 24
[7,14],
// Bottom-right column (going up) — squares 25-30
[8,14],[8,13],[8,12],[8,11],[8,10],[8,9],
// Right-bottom row (going right) — squares 31-36
// Bottom going UP (toward Yellow's start) — squares 25-26
[8,14],[8,13],
// YELLOW'S START (square 26): bottom-right going UP — squares 26-30
[8,12],[8,11],[8,10],[8,9],
// Turn right across bottom of Blue zone — squares 31-36
[9,8],[10,8],[11,8],[12,8],[13,8],[14,8],
// Right corner turn — square 37
// Right corner — square 37
[14,7],
// Top-right row (going left) — squares 38-43
[14,6],[13,6],[12,6],[11,6],[10,6],[9,6],
// Blue's start column (right-top, going up) — squares 44-49
// Right side going up (toward Blue's start) — squares 38-39
[14,6],[13,6],
// BLUE'S START (square 39): top-right going LEFT — squares 39-43
[12,6],[11,6],[10,6],[9,6],
// Blue's column going UP — squares 44-49
[8,5],[8,4],[8,3],[8,2],[8,1],[8,0],
// Top corner turn — square 50
// Top corner — square 50
[7,0],
// Back toward red start — square 51
// Back to Red's home entry — square 51
[6,0],
];
// Starting squares for each player (where piece enters the shared path)
// Red enters at square 0, Blue at square 13, Yellow at square 26, Green at square 39
// Clockwise: Red=0, Green=13, Yellow=26, Blue=39
export const START_SQUARES = [0, 13, 26, 39];
// Home column paths (6 squares each, going toward center)
export const HOME_COLUMNS = [
[[7,1],[7,2],[7,3],[7,4],[7,5],[7,6]], // Red: center column going down
[[13,7],[12,7],[11,7],[10,7],[9,7],[8,7]], // Blue: center row going left
[[7,13],[7,12],[7,11],[7,10],[7,9],[7,8]], // Yellow: center column going up
[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7]], // Green: center row going right
[[7,1],[7,2],[7,3],[7,4],[7,5],[7,6]], // Red: top-center going down
[[1,7],[2,7],[3,7],[4,7],[5,7],[6,7]], // Green: left-center going right
[[7,13],[7,12],[7,11],[7,10],[7,9],[7,8]], // Yellow: bottom-center going up
[[13,7],[12,7],[11,7],[10,7],[9,7],[8,7]], // Blue: right-center going left
];
// The square BEFORE entering home column (last shared square before home)
export const HOME_ENTRY = [51, 12, 25, 38];
// The GLOBAL square just before entering home column
// Red: after square 51 [6,0] → enters home [7,1]
// Green: after square 11 [0,7] → enters home [1,7]
// Yellow: after square 24 [7,14] → enters home [7,13]
// Blue: after square 37 [14,7] → enters home [13,7]
export const HOME_ENTRY = [51, 11, 24, 37];
// Safe squares (marked with ★ on the board) — these are the entry squares + midpoints
// Safe squares — entry squares + midpoints between starts
export const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47];
// Home base piece positions (where 4 pieces sit before entering the board)
// Red(TL), Blue(TR), Yellow(BR), Green(BL)
// Order: Red(TL), Green(BL), Yellow(BR), Blue(TR)
export const HOME_BASES = [
[[1.5,1.5],[3.5,1.5],[1.5,3.5],[3.5,3.5]], // Red (top-left, zone 0-5)
[[10.5,1.5],[12.5,1.5],[10.5,3.5],[12.5,3.5]], // Blue (top-right, zone 9-14)
[[10.5,10.5],[12.5,10.5],[10.5,12.5],[12.5,12.5]], // Yellow (bottom-right, zone 9-14)
[[1.5,10.5],[3.5,10.5],[1.5,12.5],[3.5,12.5]], // Green (bottom-left, zone 0-5)
[[1.5,1.5],[3.5,1.5],[1.5,3.5],[3.5,3.5]], // Red (top-left)
[[1.5,10.5],[3.5,10.5],[1.5,12.5],[3.5,12.5]], // Green (bottom-left)
[[10.5,10.5],[12.5,10.5],[10.5,12.5],[12.5,12.5]], // Yellow (bottom-right)
[[10.5,1.5],[12.5,1.5],[10.5,3.5],[12.5,3.5]], // Blue (top-right)
];
// Convert grid [col,row] to pixel center
......
......@@ -4,8 +4,8 @@ const HOME_STRETCH = 6;
const START_POSITIONS = [0, 13, 26, 39];
const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47];
export const COLORS = ['red', 'blue', 'yellow', 'green'];
export const COLOR_CSS = ['#E53935', '#1E88E5', '#FDD835', '#43A047'];
export const COLORS = ['red', 'green', 'yellow', 'blue'];
export const COLOR_CSS = ['#E53935', '#43A047', '#FDD835', '#1E88E5'];
export function createGame(numPlayers = 4) {
const players = [];
......
......@@ -15,9 +15,9 @@ import * as net from '../../../core/net.js';
let game, validMoves, ctx, canvas, boardSize, cellSize;
let diceAnimating = false;
// Order: Red(TL), Blue(TR), Yellow(BR), Green(BL) — matches reference image
const COLORS = ['#E53935', '#1E88E5', '#FDD835', '#43A047'];
const COLORS_LIGHT = ['#EF9A9A', '#90CAF9', '#FFF59D', '#A5D6A7'];
// Order: Red(TL), Green(BL), Yellow(BR), Blue(TR) — clockwise from Red
const COLORS = ['#E53935', '#43A047', '#FDD835', '#1E88E5'];
const COLORS_LIGHT = ['#EF9A9A', '#A5D6A7', '#FFF59D', '#90CAF9'];
let PLAYER_NAMES = ['أنت', 'Bot 1', 'Bot 2', 'Bot 3'];
let livePoller = null;
let myPlayerIndex = 0;
......
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