Commit c508fdfb authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: Phase 1 + 5 — chess multiplayer bugs, lobby race conditions, UX fixes

Phase 1 (Chess Multiplayer Bulletproof):
- net.js: check content-type before JSON parse, clear error on HTML responses
- modal.js: stack-based resolve — stacked modals no longer hang
- match-session.js: fix null dereference after destroy, remove listener on cleanup
- match-session.js: only fire onConnectionRestored after actual disconnect
- lobby.js: prevent duplicate startGame calls with gameStarted flag
- lobby.js: add pollInFlight guard, clear timeouts on unmount
- lobby.js: derive color from match data when undefined (friend invite fix)
- scene.js: queue navigation during transition instead of dropping it
- chess/game.js: pass timeControl to result scene for correct rematch

Phase 5 (Core UX):
- table.js: hide "My Games" button for non-chess games (no history scenes)
- queue.js: add polling overlap guard
- profile/view.js: show "Unrated" for new players instead of 1200
- i18n: add profile.unrated key (ar/en)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 4ffe25d5
...@@ -501,6 +501,7 @@ const strings = { ...@@ -501,6 +501,7 @@ const strings = {
'profile.rejection_reason': 'السبب: {reason}', 'profile.rejection_reason': 'السبب: {reason}',
'profile.membership_request': 'طلب انضمام', 'profile.membership_request': 'طلب انضمام',
'profile.no_org': 'لست عضواً في أي منظمة بعد', 'profile.no_org': 'لست عضواً في أي منظمة بعد',
'profile.unrated': 'غير مصنف',
'org.join_title': 'الانضمام لمنظمة', 'org.join_title': 'الانضمام لمنظمة',
'org.no_orgs': 'لا توجد منظمات متاحة حالياً', 'org.no_orgs': 'لا توجد منظمات متاحة حالياً',
'org.apply': 'تقديم طلب', 'org.apply': 'تقديم طلب',
...@@ -1186,6 +1187,7 @@ const strings = { ...@@ -1186,6 +1187,7 @@ const strings = {
'profile.rejection_reason': 'Reason: {reason}', 'profile.rejection_reason': 'Reason: {reason}',
'profile.membership_request': 'Membership Request', 'profile.membership_request': 'Membership Request',
'profile.no_org': 'Not a member of any organization yet', 'profile.no_org': 'Not a member of any organization yet',
'profile.unrated': 'Unrated',
'org.join_title': 'Join Organization', 'org.join_title': 'Join Organization',
'org.no_orgs': 'No organizations available', 'org.no_orgs': 'No organizations available',
'org.apply': 'Apply', 'org.apply': 'Apply',
......
...@@ -33,7 +33,8 @@ export function create(matchId, gameType, options = {}) { ...@@ -33,7 +33,8 @@ export function create(matchId, gameType, options = {}) {
onOpponentAbandon: options.onOpponentAbandon || null, onOpponentAbandon: options.onOpponentAbandon || null,
onConnectionLost: options.onConnectionLost || null, onConnectionLost: options.onConnectionLost || null,
onConnectionRestored: options.onConnectionRestored || null, onConnectionRestored: options.onConnectionRestored || null,
opponentDisconnected: false opponentDisconnected: false,
connectionLost: false // Track whether we are currently in a disconnected state
}; };
// Save match ID to localStorage for tab refresh recovery // Save match ID to localStorage for tab refresh recovery
...@@ -108,9 +109,12 @@ function startPolling() { ...@@ -108,9 +109,12 @@ function startPolling() {
if (!data || data.error) return; if (!data || data.error) return;
const wasDisconnected = (Date.now() - currentSession.lastServerPing) > 10000;
currentSession.lastServerPing = Date.now(); currentSession.lastServerPing = Date.now();
if (wasDisconnected) currentSession.onConnectionRestored?.(); // Only fire onConnectionRestored if we previously fired onConnectionLost
if (currentSession.connectionLost) {
currentSession.connectionLost = false;
currentSession.onConnectionRestored?.();
}
if (data.status === 'abandoned') { if (data.status === 'abandoned') {
currentSession.onOpponentAbandon?.(); currentSession.onOpponentAbandon?.();
...@@ -120,7 +124,9 @@ function startPolling() { ...@@ -120,7 +124,9 @@ function startPolling() {
currentSession.onOpponentMove?.(data); currentSession.onOpponentMove?.(data);
} catch (e) { } catch (e) {
if (Date.now() - currentSession.lastServerPing > 10000) { if (!currentSession) return;
if (Date.now() - currentSession.lastServerPing > 10000 && !currentSession.connectionLost) {
currentSession.connectionLost = true;
currentSession.onConnectionLost?.(); currentSession.onConnectionLost?.();
} }
} finally { } finally {
......
...@@ -7,8 +7,7 @@ import { t } from './i18n.js'; ...@@ -7,8 +7,7 @@ import { t } from './i18n.js';
let modalEl = null; let modalEl = null;
let backdropEl = null; let backdropEl = null;
let resolvePromise = null; let resolveStack = []; // Each open modal gets its own resolve function
let modalQueue = [];
function ensureContainer() { function ensureContainer() {
if (backdropEl) return; if (backdropEl) return;
...@@ -79,10 +78,10 @@ function animateOut() { ...@@ -79,10 +78,10 @@ function animateOut() {
* Returns a Promise<boolean> * Returns a Promise<boolean>
*/ */
export function confirm(message, options = {}) { export function confirm(message, options = {}) {
// If a modal is already open, dismiss it (resolve false) before showing new one // If a modal is already open, dismiss previous modals (resolve false)
if (isOpen() && resolvePromise) { while (resolveStack.length > 0) {
resolvePromise(false); const prev = resolveStack.pop();
resolvePromise = null; prev(false);
} }
const { const {
...@@ -130,7 +129,12 @@ export function confirm(message, options = {}) { ...@@ -130,7 +129,12 @@ export function confirm(message, options = {}) {
animateIn(); animateIn();
return new Promise(resolve => { return new Promise(resolve => {
resolvePromise = resolve; const myResolve = (val) => {
const idx = resolveStack.indexOf(myResolve);
if (idx !== -1) resolveStack.splice(idx, 1);
resolve(val);
};
resolveStack.push(myResolve);
const confirmBtn = modalEl.querySelector('#modal-confirm'); const confirmBtn = modalEl.querySelector('#modal-confirm');
const cancelBtn = modalEl.querySelector('#modal-cancel'); const cancelBtn = modalEl.querySelector('#modal-cancel');
...@@ -139,13 +143,13 @@ export function confirm(message, options = {}) { ...@@ -139,13 +143,13 @@ export function confirm(message, options = {}) {
audio.play('click'); audio.play('click');
juice.hapticLight?.(); juice.hapticLight?.();
await animateOut(); await animateOut();
resolve(true); myResolve(true);
}); });
cancelBtn.addEventListener('click', async () => { cancelBtn.addEventListener('click', async () => {
audio.play('click'); audio.play('click');
await animateOut(); await animateOut();
resolve(false); myResolve(false);
}); });
// Backdrop tap = cancel // Backdrop tap = cancel
...@@ -153,7 +157,7 @@ export function confirm(message, options = {}) { ...@@ -153,7 +157,7 @@ export function confirm(message, options = {}) {
if (e.target === backdropEl) { if (e.target === backdropEl) {
audio.play('click'); audio.play('click');
await animateOut(); await animateOut();
resolve(false); myResolve(false);
} }
}, { once: true }); }, { once: true });
}); });
......
...@@ -27,12 +27,23 @@ export async function api(endpoint, options = {}) { ...@@ -27,12 +27,23 @@ export async function api(endpoint, options = {}) {
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const snippet = (await res.text()).slice(0, 120);
throw new ApiError(
`Expected JSON but got ${contentType || 'unknown content-type'} (status ${res.status}): ${snippet}`,
res.status || 502
);
}
const text = await res.text(); const text = await res.text();
let data; let data;
try { try {
data = JSON.parse(text); data = JSON.parse(text);
} catch (e) { } catch (e) {
throw new ApiError('Server returned invalid response', res.status || 502); throw new ApiError(
`Server returned malformed JSON (status ${res.status}): ${text.slice(0, 120)}`,
res.status || 502
);
} }
if (!res.ok) { if (!res.ok) {
...@@ -89,6 +100,8 @@ async function doRefresh() { ...@@ -89,6 +100,8 @@ async function doRefresh() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'refresh', refresh_token: refreshToken }) body: JSON.stringify({ action: 'refresh', refresh_token: refreshToken })
}); });
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) return false;
const data = await res.json(); const data = await res.json();
if (data.access_token) { if (data.access_token) {
store.set('auth.token', data.access_token); store.set('auth.token', data.access_token);
......
...@@ -7,6 +7,7 @@ const sceneRegistry = {}; ...@@ -7,6 +7,7 @@ const sceneRegistry = {};
let currentWorld = 'play'; let currentWorld = 'play';
let container = null; let container = null;
let isTransitioning = false; let isTransitioning = false;
let pendingPush = null; // queued navigation during transition
export function init() { export function init() {
container = document.getElementById('scene-container'); container = document.getElementById('scene-container');
...@@ -52,9 +53,14 @@ export function switchWorld(world, resetToRoot = false) { ...@@ -52,9 +53,14 @@ export function switchWorld(world, resetToRoot = false) {
} }
export function push(sceneId, params = {}) { export function push(sceneId, params = {}) {
if (isTransitioning) return;
if (!sceneRegistry[sceneId]) return; if (!sceneRegistry[sceneId]) return;
// Queue navigation if a transition is in progress
if (isTransitioning) {
pendingPush = { sceneId, params };
return;
}
const stack = sceneStacks[currentWorld]; const stack = sceneStacks[currentWorld];
const prev = stack[stack.length - 1]; const prev = stack[stack.length - 1];
stack.push({ id: sceneId, params }); stack.push({ id: sceneId, params });
...@@ -103,10 +109,22 @@ function transitionTo(sceneId, params, prevId, isBack = false) { ...@@ -103,10 +109,22 @@ function transitionTo(sceneId, params, prevId, isBack = false) {
const sceneEl = container.querySelector('.scene'); const sceneEl = container.querySelector('.scene');
if (sceneEl) { if (sceneEl) {
sceneEl.addEventListener('animationend', () => { isTransitioning = false; }, { once: true }); sceneEl.addEventListener('animationend', () => { finishTransition(); }, { once: true });
setTimeout(() => { isTransitioning = false; }, 400); setTimeout(() => { finishTransition(); }, 400);
} else { } else {
isTransitioning = false; finishTransition();
}
}
function finishTransition() {
if (!isTransitioning) return; // already finished (animationend + timeout race)
isTransitioning = false;
// Flush any queued navigation
if (pendingPush) {
const { sceneId, params } = pendingPush;
pendingPush = null;
push(sceneId, params);
} }
} }
......
...@@ -943,6 +943,7 @@ function endGame(result, reason) { ...@@ -943,6 +943,7 @@ function endGame(result, reason) {
mode: gameState.mode, mode: gameState.mode,
botId: gameState.botId, botId: gameState.botId,
playerColor: gameState.playerColor, playerColor: gameState.playerColor,
timeControl: gameState.timeControl,
capturedByPlayer: gameState.capturedByPlayer, capturedByPlayer: gameState.capturedByPlayer,
capturedByOpponent: gameState.capturedByOpponent, capturedByOpponent: gameState.capturedByOpponent,
moveHistory: gameState.moveHistory, moveHistory: gameState.moveHistory,
......
...@@ -10,6 +10,10 @@ let pollTimer = null; ...@@ -10,6 +10,10 @@ let pollTimer = null;
let matchId = null; let matchId = null;
let currentGameKey = 'chess'; let currentGameKey = 'chess';
let lobbyState = 'waiting'; // waiting | ready | starting let lobbyState = 'waiting'; // waiting | ready | starting
let gameStarted = false;
let mounted = false;
let pollInFlight = false;
let startTimeout = null;
export function mountLobby(el, params = {}) { export function mountLobby(el, params = {}) {
matchId = params.matchId; matchId = params.matchId;
...@@ -20,8 +24,12 @@ export function mountLobby(el, params = {}) { ...@@ -20,8 +24,12 @@ export function mountLobby(el, params = {}) {
const friendProfile = params.friendProfile || {}; const friendProfile = params.friendProfile || {};
const isHost = params.isHost ?? true; const isHost = params.isHost ?? true;
lobbyState = isHost ? 'waiting' : 'ready'; lobbyState = isHost ? 'waiting' : 'ready';
gameStarted = false;
mounted = true;
pollInFlight = false;
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
if (startTimeout) { clearTimeout(startTimeout); startTimeout = null; }
const myName = store.get('player.display_name') || store.get('player.username') || t('common.you'); const myName = store.get('player.display_name') || store.get('player.username') || t('common.you');
const myAvatar = store.get('player.avatar_url'); const myAvatar = store.get('player.avatar_url');
...@@ -134,12 +142,15 @@ export function mountLobby(el, params = {}) { ...@@ -134,12 +142,15 @@ export function mountLobby(el, params = {}) {
if (statusEl) { if (statusEl) {
statusEl.innerHTML = `<div class="lobby-status-text" style="color:var(--success);">${emoji('check', '✓', 14)} ${t('lobby.accepted_preparing')}</div>`; statusEl.innerHTML = `<div class="lobby-status-text" style="color:var(--success);">${emoji('check', '✓', 14)} ${t('lobby.accepted_preparing')}</div>`;
} }
setTimeout(() => startGame(el, params), 2500); startTimeout = setTimeout(() => startGame(el, params), 2500);
} }
} }
async function pollMatchStatus(el, params) { async function pollMatchStatus(el, params) {
if (!matchId) return; if (!matchId) return;
if (pollInFlight) return; // prevent overlapping poll requests
pollInFlight = true;
const gameKey = params.gameKey || 'chess'; const gameKey = params.gameKey || 'chess';
const endpoint = gameKey === 'ludo' ? 'ludo-match.php' : gameKey === 'domino' ? 'domino-match.php' : 'game.php'; const endpoint = gameKey === 'ludo' ? 'ludo-match.php' : gameKey === 'domino' ? 'domino-match.php' : 'game.php';
...@@ -166,21 +177,45 @@ async function pollMatchStatus(el, params) { ...@@ -166,21 +177,45 @@ async function pollMatchStatus(el, params) {
audio.play('reward'); audio.play('reward');
juice.hapticSuccess(); juice.hapticSuccess();
setTimeout(() => startGame(el, params), 1500); // Bug 2 fix: resolve color from match data if params.color is undefined
if (!params.color && res.white_player_id) {
const userId = store.get('player.id');
params.color = (res.white_player_id == userId) ? 'w' : 'b';
}
startTimeout = setTimeout(() => startGame(el, params), 1500);
} }
} catch (e) {} } catch (e) {
} finally {
pollInFlight = false;
}
} }
function startGame(el, params) { function startGame(el, params) {
// Concurrency guard: prevent multiple startGame calls
if (gameStarted) return;
// Ensure lobby scene is still mounted (not navigated away)
if (!mounted) return;
gameStarted = true;
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (startTimeout) { clearTimeout(startTimeout); startTimeout = null; }
const gameKey = params.gameKey || 'chess'; const gameKey = params.gameKey || 'chess';
// Bug 2 fix: fallback color assignment for chess if still undefined
let color = params.color;
if (gameKey === 'chess' && !color) {
const userId = store.get('player.id');
color = params.isHost ? 'w' : 'b';
}
if (gameKey === 'chess') { if (gameKey === 'chess') {
scene.replace('chess-game', { scene.replace('chess-game', {
mode: 'live', mode: 'live',
matchId: params.matchId, matchId: params.matchId,
color: params.color, color: color,
timeControl: params.timeControl, timeControl: params.timeControl,
isFriendly: true isFriendly: true
}); });
...@@ -224,6 +259,8 @@ function formatTimeControl(tc) { ...@@ -224,6 +259,8 @@ function formatTimeControl(tc) {
} }
export function unmountLobby() { export function unmountLobby() {
mounted = false;
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (startTimeout) { clearTimeout(startTimeout); startTimeout = null; }
matchId = null; matchId = null;
} }
...@@ -112,13 +112,17 @@ async function joinQueue(params) { ...@@ -112,13 +112,17 @@ async function joinQueue(params) {
function pollForMatch(params) { function pollForMatch(params) {
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : params.game === 'backgammon' ? 'backgammon-match.php' : 'matchmaking.php'; const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : params.game === 'backgammon' ? 'backgammon-match.php' : 'matchmaking.php';
let polling = false;
unsub = setInterval(async () => { unsub = setInterval(async () => {
if (polling) return;
polling = true;
try { try {
const data = await net.post(endpoint, { action: 'status', game_key: params.game }); const data = await net.post(endpoint, { action: 'status', game_key: params.game });
if (data.match_id) { if (data.match_id) {
onMatchFound(data, params); onMatchFound(data, params);
} }
} catch (e) {} } catch (e) {}
polling = false;
}, 3000); }, 3000);
} }
......
...@@ -296,16 +296,16 @@ function showGameMenu(menu, game) { ...@@ -296,16 +296,16 @@ function showGameMenu(menu, game) {
</button> </button>
<!-- Secondary actions --> <!-- Secondary actions -->
<div style="display:grid;grid-template-columns:${game.key === 'chess' ? '1fr 1fr 1fr' : '1fr 1fr'};gap:var(--gm-chips-grid-gap);margin-top:var(--gm-chips-margin-top);"> <div style="display:grid;grid-template-columns:${game.key === 'chess' ? '1fr 1fr 1fr' : '1fr'};gap:var(--gm-chips-grid-gap);margin-top:var(--gm-chips-margin-top);">
<button class="gm-chip" id="btn-leaderboard"> <button class="gm-chip" id="btn-leaderboard">
<span style="font-size:var(--gm-chip-icon-font);">${emoji('tournament_cup', '🏆', 18)}</span> <span style="font-size:var(--gm-chip-icon-font);">${emoji('tournament_cup', '🏆', 18)}</span>
<span>Leaderboard</span> <span>Leaderboard</span>
</button> </button>
<button class="gm-chip" id="btn-history"> ${game.key === 'chess' ? `<button class="gm-chip" id="btn-history">
<span style="font-size:var(--gm-chip-icon-font);">${emoji('clipboard', '📋', 18)}</span> <span style="font-size:var(--gm-chip-icon-font);">${emoji('clipboard', '📋', 18)}</span>
<span>My Games</span> <span>My Games</span>
</button> </button>
${game.key === 'chess' ? `<button class="gm-chip" id="btn-puzzles"> <button class="gm-chip" id="btn-puzzles">
<span style="font-size:var(--gm-chip-icon-font);">${emoji('puzzle', '🧩', 18)}</span> <span style="font-size:var(--gm-chip-icon-font);">${emoji('puzzle', '🧩', 18)}</span>
<span>Puzzles</span> <span>Puzzles</span>
</button>` : ''} </button>` : ''}
......
...@@ -66,9 +66,9 @@ async function mountOwnProfile(el) { ...@@ -66,9 +66,9 @@ async function mountOwnProfile(el) {
<div class="card"> <div class="card">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${t('profile.rating_section')}</div> <div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${t('profile.rating_section')}</div>
<div style="display:flex;flex-direction:column;gap:var(--s-2);"> <div style="display:flex;flex-direction:column;gap:var(--s-2);">
${renderRating(t('profile.chess_rapid'), player.elo_rapid)} ${renderRating(t('profile.chess_rapid'), player.elo_rapid, player.games_played)}
${renderRating(t('profile.chess_blitz'), player.elo_blitz)} ${renderRating(t('profile.chess_blitz'), player.elo_blitz, player.games_played)}
${renderRating(t('profile.chess_bullet'), player.elo_bullet)} ${renderRating(t('profile.chess_bullet'), player.elo_bullet, player.games_played)}
</div> </div>
</div> </div>
...@@ -229,9 +229,9 @@ async function mountOtherProfile(el, playerId) { ...@@ -229,9 +229,9 @@ async function mountOtherProfile(el, playerId) {
<div class="card"> <div class="card">
<div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${t('profile.rating_section')}</div> <div style="font-size:15px;font-weight:600;margin-bottom:var(--s-3);">${t('profile.rating_section')}</div>
<div style="display:flex;flex-direction:column;gap:var(--s-2);"> <div style="display:flex;flex-direction:column;gap:var(--s-2);">
${renderRating(t('profile.chess_rapid'), player.elo_rapid)} ${renderRating(t('profile.chess_rapid'), player.elo_rapid, player.games_played)}
${renderRating(t('profile.chess_blitz'), player.elo_blitz)} ${renderRating(t('profile.chess_blitz'), player.elo_blitz, player.games_played)}
${renderRating(t('profile.chess_bullet'), player.elo_bullet)} ${renderRating(t('profile.chess_bullet'), player.elo_bullet, player.games_played)}
</div> </div>
</div> </div>
...@@ -476,12 +476,13 @@ function compressAvatar(file) { ...@@ -476,12 +476,13 @@ function compressAvatar(file) {
}); });
} }
function renderRating(label, rating) { function renderRating(label, rating, gamesPlayed = 0) {
const display = rating ? rating : '—'; const isUnrated = !rating || (rating === 1200 && gamesPlayed === 0);
const display = isUnrated ? t('profile.unrated') : rating;
return ` return `
<div style="display:flex;justify-content:space-between;align-items:center;"> <div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:13px;color:var(--text-secondary);">${label}</span> <span style="font-size:13px;color:var(--text-secondary);">${label}</span>
<span style="font-size:15px;font-weight:700;font-family:var(--font-lat);${!rating ? 'color:var(--text-muted);' : ''}">${display}</span> <span style="font-size:15px;font-weight:700;font-family:var(--font-lat);${isUnrated ? 'color:var(--text-muted);' : ''}">${display}</span>
</div> </div>
`; `;
} }
......
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