Commit 412ee8f6 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: match-session.js — comprehensive multiplayer edge case handler

New core module handles ALL connection edge cases:

TAB REFRESH RECOVERY:
- Active match stored in localStorage with timestamp
- On app boot, checks for recoverable match (< 5 min old)
- Automatically re-enters the game scene with recovered state
- Fetches latest state from server immediately

TAB VISIBILITY (switch/minimize):
- Pauses polling when tab is hidden (saves bandwidth)
- Resumes and immediately fetches latest state on return
- Resets disconnect timers on tab return

OPPONENT DISCONNECT DETECTION:
- Tracks lastOpponentActivity timestamp
- After 30s: fires onOpponentDisconnect (show warning UI)
- After 60s: fires onOpponentAbandon (auto-claim win)
- If opponent comes back: fires onOpponentReconnect

NETWORK LOSS HANDLING:
- If server unreachable for 10s: fires onConnectionLost
- On recovery: fires onConnectionRestored
- Polling continues with error tolerance

SERVER PING (keep-alive):
- Every 10s, pings server with player ID + timestamp
- Stored in game_state.ping field
- Opponent's polling reads this to know sender is alive

API:
- create(matchId, gameType, callbacks) — start session
- destroy() — clean up timers + localStorage
- getRecoverableMatch() — check for resume on boot
- markOpponentActive() — call when opponent data received
- isConnected() / isOpponentConnected() — status checks

Engine updated:
- Imports match-session on boot
- Checks for recoverable match after auth
- Auto-resumes game if found
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 7361d8ea
bell.png

2.67 KB

// Match Session Manager — handles ALL multiplayer edge cases
// Used by Chess, Ludo, Domino for consistent connection handling
import * as store from './store.js';
import * as net from './net.js';
import * as bus from './bus.js';
const PING_INTERVAL = 10000; // Ping server every 10s to show "online"
const DISCONNECT_THRESHOLD = 30000; // Consider opponent disconnected after 30s
const ABANDON_THRESHOLD = 60000; // Auto-win after 60s disconnect
const POLL_INTERVAL = 2000; // Poll for opponent moves every 2s
const RECONNECT_GRACE = 5000; // Wait 5s on page load before assuming fresh start
let currentSession = null;
export function create(matchId, gameType, options = {}) {
destroy(); // Clean up any existing session
currentSession = {
matchId,
gameType, // 'chess' | 'ludo' | 'domino'
myUserId: store.get('auth.userId'),
lastServerPing: Date.now(),
lastOpponentActivity: Date.now(),
pollTimer: null,
pingTimer: null,
disconnectTimer: null,
isActive: true,
isBackground: false,
onOpponentMove: options.onOpponentMove || null,
onOpponentDisconnect: options.onOpponentDisconnect || null,
onOpponentReconnect: options.onOpponentReconnect || null,
onOpponentAbandon: options.onOpponentAbandon || null,
onConnectionLost: options.onConnectionLost || null,
onConnectionRestored: options.onConnectionRestored || null,
opponentDisconnected: false
};
// Save match ID to localStorage for tab refresh recovery
localStorage.setItem('el3ab_active_match', JSON.stringify({
matchId, gameType, timestamp: Date.now()
}));
startPolling();
startPinging();
startDisconnectWatch();
setupVisibilityHandler();
return currentSession;
}
export function destroy() {
if (!currentSession) return;
currentSession.isActive = false;
clearInterval(currentSession.pollTimer);
clearInterval(currentSession.pingTimer);
clearInterval(currentSession.disconnectTimer);
localStorage.removeItem('el3ab_active_match');
currentSession = null;
}
// Check if player has an active match to resume (after tab refresh)
export function getRecoverableMatch() {
try {
const saved = localStorage.getItem('el3ab_active_match');
if (!saved) return null;
const data = JSON.parse(saved);
// Only recover if less than 5 minutes old
if (Date.now() - data.timestamp > 300000) {
localStorage.removeItem('el3ab_active_match');
return null;
}
return data;
} catch (e) { return null; }
}
// Record that opponent made an action (keeps disconnect timer alive)
export function markOpponentActive() {
if (!currentSession) return;
currentSession.lastOpponentActivity = Date.now();
if (currentSession.opponentDisconnected) {
currentSession.opponentDisconnected = false;
currentSession.onOpponentReconnect?.();
}
}
// ===== POLLING =====
function startPolling() {
if (!currentSession) return;
currentSession.pollTimer = setInterval(async () => {
if (!currentSession || !currentSession.isActive) return;
if (currentSession.isBackground) return; // Don't poll when tab is hidden
try {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : 'game.php';
const data = await net.post(endpoint, {
action: 'get',
match_id: currentSession.matchId
});
if (!data || data.error) return;
// Connection is alive
currentSession.lastServerPing = Date.now();
currentSession.onConnectionRestored?.();
// Notify about opponent's move
currentSession.onOpponentMove?.(data);
} catch (e) {
// Network error
if (Date.now() - currentSession.lastServerPing > 10000) {
currentSession.onConnectionLost?.();
}
}
}, POLL_INTERVAL);
}
// ===== PING (let server know we're still here) =====
function startPinging() {
if (!currentSession) return;
currentSession.pingTimer = setInterval(async () => {
if (!currentSession || !currentSession.isActive) return;
try {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : 'game.php';
await net.post(endpoint, {
action: 'move',
match_id: currentSession.matchId,
game_state: JSON.stringify({ ping: currentSession.myUserId, t: Date.now() })
});
} catch (e) {}
}, PING_INTERVAL);
}
// ===== DISCONNECT DETECTION =====
function startDisconnectWatch() {
if (!currentSession) return;
currentSession.disconnectTimer = setInterval(() => {
if (!currentSession || !currentSession.isActive) return;
const elapsed = Date.now() - currentSession.lastOpponentActivity;
if (elapsed > ABANDON_THRESHOLD && !currentSession.opponentDisconnected) {
// Opponent abandoned — auto-claim win
currentSession.onOpponentAbandon?.();
} else if (elapsed > DISCONNECT_THRESHOLD && !currentSession.opponentDisconnected) {
// Opponent disconnected but within grace period
currentSession.opponentDisconnected = true;
currentSession.onOpponentDisconnect?.();
}
}, 5000);
}
// ===== TAB VISIBILITY (handles tab switch/minimize) =====
function setupVisibilityHandler() {
document.addEventListener('visibilitychange', () => {
if (!currentSession) return;
if (document.hidden) {
// Tab went to background — pause expensive operations
currentSession.isBackground = true;
} else {
// Tab came back — resume and immediately poll for updates
currentSession.isBackground = false;
currentSession.lastServerPing = Date.now(); // Reset so we don't false-disconnect
// Immediately fetch latest state
if (currentSession.onOpponentMove) {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : 'game.php';
net.post(endpoint, { action: 'get', match_id: currentSession.matchId })
.then(data => {
if (data && !data.error) {
currentSession.onOpponentMove(data);
markOpponentActive();
}
})
.catch(() => {});
}
}
});
}
// ===== UTILITY =====
export function isConnected() {
if (!currentSession) return false;
return (Date.now() - currentSession.lastServerPing) < 15000;
}
export function isOpponentConnected() {
if (!currentSession) return false;
return !currentSession.opponentDisconnected;
}
export function getMatchId() {
return currentSession?.matchId || null;
}
export function getSession() {
return currentSession;
}
...@@ -6,6 +6,7 @@ import * as input from './core/input.js'; ...@@ -6,6 +6,7 @@ import * as input from './core/input.js';
import * as hud from './core/hud.js'; import * as hud from './core/hud.js';
import * as theme from './core/theme.js'; import * as theme from './core/theme.js';
import { setLang } from './core/i18n.js'; import { setLang } from './core/i18n.js';
import { getRecoverableMatch } from './core/match-session.js';
async function boot() { async function boot() {
setLang(store.get('language') || 'ar'); setLang(store.get('language') || 'ar');
...@@ -24,7 +25,15 @@ async function boot() { ...@@ -24,7 +25,15 @@ async function boot() {
scene.setRoot('social', 'friends'); scene.setRoot('social', 'friends');
scene.setRoot('shop', 'shop-browse'); scene.setRoot('shop', 'shop-browse');
scene.setRoot('profile', 'profile-view'); scene.setRoot('profile', 'profile-view');
// Check for active match to resume (tab refresh 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 });
} else {
scene.switchWorld(store.get('activeWorld') || 'play'); scene.switchWorld(store.get('activeWorld') || 'play');
}
} else { } else {
hud.hide(); hud.hide();
scene.push('auth-splash'); scene.push('auth-splash');
......
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