Commit 7f51492e authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: chess multiplayer — lobby sync, session leaks, error handling

Phase 1 fixes:
- lobby.js: guest waits 2.5s before entering game (gives host time to detect)
- challenge.js: validate match_id exists before navigating to lobby
- match-session.js: remove visibilitychange listener on destroy (memory leak fix)
- match-session.js: only fire onConnectionRestored after actual disconnection
- net.js: handle non-JSON server responses gracefully (nginx 502 pages etc)
- result.js: pass actual timeControl to rematch instead of hardcoded rapid_10_0
- Remove dead chess/logic/live.js and ludo/logic/live.js (unused)

Fixes WTF #15, #19, #23, #24, #35, #37, #54, #150
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 95d17b00
...@@ -55,6 +55,9 @@ export function destroy() { ...@@ -55,6 +55,9 @@ export function destroy() {
clearInterval(currentSession.pollTimer); clearInterval(currentSession.pollTimer);
clearInterval(currentSession.pingTimer); clearInterval(currentSession.pingTimer);
clearInterval(currentSession.disconnectTimer); clearInterval(currentSession.disconnectTimer);
if (currentSession._visibilityHandler) {
document.removeEventListener('visibilitychange', currentSession._visibilityHandler);
}
localStorage.removeItem('el3ab_active_match'); localStorage.removeItem('el3ab_active_match');
currentSession = null; currentSession = null;
} }
...@@ -101,8 +104,9 @@ function startPolling() { ...@@ -101,8 +104,9 @@ function startPolling() {
if (!data || data.error) return; if (!data || data.error) return;
// Connection is alive // Connection is alive
const wasDisconnected = (Date.now() - currentSession.lastServerPing) > 10000;
currentSession.lastServerPing = Date.now(); currentSession.lastServerPing = Date.now();
currentSession.onConnectionRestored?.(); if (wasDisconnected) currentSession.onConnectionRestored?.();
// Handle server-detected abandonment // Handle server-detected abandonment
if (data.status === 'abandoned') { if (data.status === 'abandoned') {
...@@ -158,23 +162,20 @@ function startDisconnectWatch() { ...@@ -158,23 +162,20 @@ function startDisconnectWatch() {
// ===== TAB VISIBILITY (handles tab switch/minimize) ===== // ===== TAB VISIBILITY (handles tab switch/minimize) =====
function setupVisibilityHandler() { function setupVisibilityHandler() {
document.addEventListener('visibilitychange', () => { const handler = () => {
if (!currentSession) return; if (!currentSession) return;
if (document.hidden) { if (document.hidden) {
// Tab went to background — pause expensive operations
currentSession.isBackground = true; currentSession.isBackground = true;
} else { } else {
// Tab came back — resume and immediately poll for updates
currentSession.isBackground = false; currentSession.isBackground = false;
currentSession.lastServerPing = Date.now(); // Reset so we don't false-disconnect currentSession.lastServerPing = Date.now();
// Immediately fetch latest state
if (currentSession.onOpponentMove) { if (currentSession.onOpponentMove) {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : currentSession.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php'; const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : currentSession.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php';
net.post(endpoint, { action: 'get', match_id: currentSession.matchId }) net.post(endpoint, { action: 'get', match_id: currentSession.matchId })
.then(data => { .then(data => {
if (data && !data.error) { if (data && !data.error && currentSession) {
currentSession.onOpponentMove(data); currentSession.onOpponentMove(data);
markOpponentActive(); markOpponentActive();
} }
...@@ -182,7 +183,9 @@ function setupVisibilityHandler() { ...@@ -182,7 +183,9 @@ function setupVisibilityHandler() {
.catch(() => {}); .catch(() => {});
} }
} }
}); };
currentSession._visibilityHandler = handler;
document.addEventListener('visibilitychange', handler);
} }
// ===== UTILITY ===== // ===== UTILITY =====
......
...@@ -19,7 +19,13 @@ export async function api(endpoint, options = {}) { ...@@ -19,7 +19,13 @@ export async function api(endpoint, options = {}) {
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE}/${endpoint}`; const url = endpoint.startsWith('http') ? endpoint : `${API_BASE}/${endpoint}`;
const res = await fetch(url, config); const res = await fetch(url, config);
const data = await res.json(); const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
throw new ApiError('Server returned invalid response', res.status || 502);
}
if (!res.ok) { if (!res.ok) {
if (res.status === 401 && !options._retried) { if (res.status === 401 && !options._retried) {
......
// Chess Live Multiplayer — realtime game sync via Supabase
import * as realtime from '../../../core/realtime.js';
import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js';
let unsubMatch = null;
let unsubQueue = null;
let matchId = null;
let onMoveReceived = null;
let onGameStateChange = null;
let lastMoveCount = 0;
export function startMatchmaking(gameKey, timeControl, callbacks) {
const userId = store.get('auth.userId');
unsubQueue = realtime.subscribeQueue(userId, (change) => {
if (change.type === 'UPDATE' && change.new?.match_id) {
stopMatchmaking();
callbacks.onMatchFound({
matchId: change.new.match_id,
color: change.new.matched_with ? 'b' : 'w',
opponentId: change.new.matched_with
});
}
});
return net.post('matchmaking.php', {
action: 'queue',
game_key: gameKey,
time_control: timeControl
});
}
export function stopMatchmaking() {
if (unsubQueue) { unsubQueue(); unsubQueue = null; }
}
export function joinMatch(id, callbacks) {
matchId = id;
lastMoveCount = 0;
onMoveReceived = callbacks.onMove;
onGameStateChange = callbacks.onStateChange;
realtime.connect();
unsubMatch = realtime.subscribeMatch(id, (change) => {
if (change.type === 'UPDATE' && change.new) {
const match = change.new;
if (match.move_count > lastMoveCount) {
lastMoveCount = match.move_count;
if (onMoveReceived) onMoveReceived(match);
}
if (match.status !== 'in_progress' && onGameStateChange) {
onGameStateChange(match);
}
}
});
}
export function leaveMatch() {
if (unsubMatch) { unsubMatch(); unsubMatch = null; }
matchId = null;
}
export async function sendMove(fen, move, moveCount) {
if (!matchId) return;
lastMoveCount = moveCount;
return net.post('game.php', {
action: 'move',
match_id: matchId,
fen,
move: JSON.stringify(move),
move_count: moveCount
});
}
export async function sendResign() {
if (!matchId) return;
return net.post('game.php', { action: 'resign', match_id: matchId });
}
export async function sendDrawOffer() {
if (!matchId) return;
const gs = JSON.stringify({ draw_offer: store.get('auth.userId') });
return net.post('game.php', { action: 'move', match_id: matchId, game_state: gs });
}
export async function sendEmote(emoteKey) {
if (!matchId) return;
const gs = JSON.stringify({ emote: { key: emoteKey, from: store.get('auth.userId'), t: Date.now() } });
return net.post('game.php', { action: 'move', match_id: matchId, game_state: gs });
}
export function getMatchId() { return matchId; }
...@@ -132,7 +132,7 @@ export function mountResult(el, params) { ...@@ -132,7 +132,7 @@ export function mountResult(el, params) {
el.querySelector('#btn-rematch')?.addEventListener('click', () => { el.querySelector('#btn-rematch')?.addEventListener('click', () => {
audio.play('click'); audio.play('click');
juice.hapticLight(); juice.hapticLight();
scene.replace('chess-game', { mode, botId, timeControl: 'rapid_10_0' }); scene.replace('chess-game', { mode, botId, timeControl: params.timeControl || 'rapid_10_0' });
}); });
} }
......
// Ludo Live Multiplayer — realtime game sync via Supabase
import * as realtime from '../../../core/realtime.js';
import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js';
let unsubMatch = null;
let matchId = null;
let onUpdate = null;
export function joinMatch(id, callback) {
matchId = id;
onUpdate = callback;
realtime.connect();
unsubMatch = realtime.subscribeLudoMatch(id, (change) => {
if (change.type === 'UPDATE' && change.new) {
if (onUpdate) onUpdate(change.new);
}
});
}
export function leaveMatch() {
if (unsubMatch) { unsubMatch(); unsubMatch = null; }
matchId = null;
}
export async function sendRoll(diceValue, positions, currentTurn, moves) {
if (!matchId) return;
return net.post('ludo.php', {
action: 'move',
match_id: matchId,
positions: positions,
current_turn: currentTurn,
dice_value: diceValue,
moves: moves
});
}
export async function sendEnd(winners) {
if (!matchId) return;
return net.post('ludo.php', { action: 'end', match_id: matchId, winners });
}
export function getMatchId() { return matchId; }
...@@ -205,6 +205,7 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) { ...@@ -205,6 +205,7 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
}); });
if (res.error) { sendBtn.textContent = res.error; sendBtn.disabled = false; return; } if (res.error) { sendBtn.textContent = res.error; sendBtn.disabled = false; return; }
if (!res.match_id) { sendBtn.textContent = 'فشل إنشاء المباراة'; sendBtn.disabled = false; return; }
// Also send a chat message // Also send a chat message
net.post('chat.php', { net.post('chat.php', {
......
...@@ -128,8 +128,12 @@ export function mountLobby(el, params = {}) { ...@@ -128,8 +128,12 @@ export function mountLobby(el, params = {}) {
if (isHost) { if (isHost) {
pollTimer = setInterval(() => pollMatchStatus(el, params), 2000); pollTimer = setInterval(() => pollMatchStatus(el, params), 2000);
} else { } else {
// Guest already accepted — can start immediately // Guest: wait briefly so host can detect acceptance, then start
startGame(el, params); const statusEl = el.querySelector('#lobby-status');
if (statusEl) {
statusEl.innerHTML = `<div class="lobby-status-text" style="color:#34D399;">${emoji('check', '✓', 14)} تم القبول! جاري تجهيز المباراة...</div>`;
}
setTimeout(() => startGame(el, params), 2500);
} }
} }
......
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