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() {
clearInterval(currentSession.pollTimer);
clearInterval(currentSession.pingTimer);
clearInterval(currentSession.disconnectTimer);
if (currentSession._visibilityHandler) {
document.removeEventListener('visibilitychange', currentSession._visibilityHandler);
}
localStorage.removeItem('el3ab_active_match');
currentSession = null;
}
......@@ -101,8 +104,9 @@ function startPolling() {
if (!data || data.error) return;
// Connection is alive
const wasDisconnected = (Date.now() - currentSession.lastServerPing) > 10000;
currentSession.lastServerPing = Date.now();
currentSession.onConnectionRestored?.();
if (wasDisconnected) currentSession.onConnectionRestored?.();
// Handle server-detected abandonment
if (data.status === 'abandoned') {
......@@ -158,23 +162,20 @@ function startDisconnectWatch() {
// ===== TAB VISIBILITY (handles tab switch/minimize) =====
function setupVisibilityHandler() {
document.addEventListener('visibilitychange', () => {
const handler = () => {
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
currentSession.lastServerPing = Date.now();
// Immediately fetch latest state
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';
net.post(endpoint, { action: 'get', match_id: currentSession.matchId })
.then(data => {
if (data && !data.error) {
if (data && !data.error && currentSession) {
currentSession.onOpponentMove(data);
markOpponentActive();
}
......@@ -182,7 +183,9 @@ function setupVisibilityHandler() {
.catch(() => {});
}
}
});
};
currentSession._visibilityHandler = handler;
document.addEventListener('visibilitychange', handler);
}
// ===== UTILITY =====
......
......@@ -19,7 +19,13 @@ export async function api(endpoint, options = {}) {
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE}/${endpoint}`;
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.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) {
el.querySelector('#btn-rematch')?.addEventListener('click', () => {
audio.play('click');
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) {
});
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
net.post('chat.php', {
......
......@@ -128,8 +128,12 @@ export function mountLobby(el, params = {}) {
if (isHost) {
pollTimer = setInterval(() => pollMatchStatus(el, params), 2000);
} else {
// Guest already accepted — can start immediately
startGame(el, params);
// Guest: wait briefly so host can detect acceptance, then start
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