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 = {
'profile.rejection_reason': 'السبب: {reason}',
'profile.membership_request': 'طلب انضمام',
'profile.no_org': 'لست عضواً في أي منظمة بعد',
'profile.unrated': 'غير مصنف',
'org.join_title': 'الانضمام لمنظمة',
'org.no_orgs': 'لا توجد منظمات متاحة حالياً',
'org.apply': 'تقديم طلب',
......@@ -1186,6 +1187,7 @@ const strings = {
'profile.rejection_reason': 'Reason: {reason}',
'profile.membership_request': 'Membership Request',
'profile.no_org': 'Not a member of any organization yet',
'profile.unrated': 'Unrated',
'org.join_title': 'Join Organization',
'org.no_orgs': 'No organizations available',
'org.apply': 'Apply',
......
......@@ -33,7 +33,8 @@ export function create(matchId, gameType, options = {}) {
onOpponentAbandon: options.onOpponentAbandon || null,
onConnectionLost: options.onConnectionLost || 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
......@@ -108,9 +109,12 @@ function startPolling() {
if (!data || data.error) return;
const wasDisconnected = (Date.now() - currentSession.lastServerPing) > 10000;
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') {
currentSession.onOpponentAbandon?.();
......@@ -120,7 +124,9 @@ function startPolling() {
currentSession.onOpponentMove?.(data);
} catch (e) {
if (Date.now() - currentSession.lastServerPing > 10000) {
if (!currentSession) return;
if (Date.now() - currentSession.lastServerPing > 10000 && !currentSession.connectionLost) {
currentSession.connectionLost = true;
currentSession.onConnectionLost?.();
}
} finally {
......
......@@ -7,8 +7,7 @@ import { t } from './i18n.js';
let modalEl = null;
let backdropEl = null;
let resolvePromise = null;
let modalQueue = [];
let resolveStack = []; // Each open modal gets its own resolve function
function ensureContainer() {
if (backdropEl) return;
......@@ -79,10 +78,10 @@ function animateOut() {
* Returns a Promise<boolean>
*/
export function confirm(message, options = {}) {
// If a modal is already open, dismiss it (resolve false) before showing new one
if (isOpen() && resolvePromise) {
resolvePromise(false);
resolvePromise = null;
// If a modal is already open, dismiss previous modals (resolve false)
while (resolveStack.length > 0) {
const prev = resolveStack.pop();
prev(false);
}
const {
......@@ -130,7 +129,12 @@ export function confirm(message, options = {}) {
animateIn();
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 cancelBtn = modalEl.querySelector('#modal-cancel');
......@@ -139,13 +143,13 @@ export function confirm(message, options = {}) {
audio.play('click');
juice.hapticLight?.();
await animateOut();
resolve(true);
myResolve(true);
});
cancelBtn.addEventListener('click', async () => {
audio.play('click');
await animateOut();
resolve(false);
myResolve(false);
});
// Backdrop tap = cancel
......@@ -153,7 +157,7 @@ export function confirm(message, options = {}) {
if (e.target === backdropEl) {
audio.play('click');
await animateOut();
resolve(false);
myResolve(false);
}
}, { once: true });
});
......
......@@ -27,12 +27,23 @@ export async function api(endpoint, options = {}) {
} finally {
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();
let data;
try {
data = JSON.parse(text);
} 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) {
......@@ -89,6 +100,8 @@ async function doRefresh() {
headers: { 'Content-Type': 'application/json' },
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();
if (data.access_token) {
store.set('auth.token', data.access_token);
......
......@@ -7,6 +7,7 @@ const sceneRegistry = {};
let currentWorld = 'play';
let container = null;
let isTransitioning = false;
let pendingPush = null; // queued navigation during transition
export function init() {
container = document.getElementById('scene-container');
......@@ -52,9 +53,14 @@ export function switchWorld(world, resetToRoot = false) {
}
export function push(sceneId, params = {}) {
if (isTransitioning) return;
if (!sceneRegistry[sceneId]) return;
// Queue navigation if a transition is in progress
if (isTransitioning) {
pendingPush = { sceneId, params };
return;
}
const stack = sceneStacks[currentWorld];
const prev = stack[stack.length - 1];
stack.push({ id: sceneId, params });
......@@ -103,10 +109,22 @@ function transitionTo(sceneId, params, prevId, isBack = false) {
const sceneEl = container.querySelector('.scene');
if (sceneEl) {
sceneEl.addEventListener('animationend', () => { isTransitioning = false; }, { once: true });
setTimeout(() => { isTransitioning = false; }, 400);
sceneEl.addEventListener('animationend', () => { finishTransition(); }, { once: true });
setTimeout(() => { finishTransition(); }, 400);
} 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) {
mode: gameState.mode,
botId: gameState.botId,
playerColor: gameState.playerColor,
timeControl: gameState.timeControl,
capturedByPlayer: gameState.capturedByPlayer,
capturedByOpponent: gameState.capturedByOpponent,
moveHistory: gameState.moveHistory,
......
......@@ -10,6 +10,10 @@ let pollTimer = null;
let matchId = null;
let currentGameKey = 'chess';
let lobbyState = 'waiting'; // waiting | ready | starting
let gameStarted = false;
let mounted = false;
let pollInFlight = false;
let startTimeout = null;
export function mountLobby(el, params = {}) {
matchId = params.matchId;
......@@ -20,8 +24,12 @@ export function mountLobby(el, params = {}) {
const friendProfile = params.friendProfile || {};
const isHost = params.isHost ?? true;
lobbyState = isHost ? 'waiting' : 'ready';
gameStarted = false;
mounted = true;
pollInFlight = false;
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 myAvatar = store.get('player.avatar_url');
......@@ -134,12 +142,15 @@ export function mountLobby(el, params = {}) {
if (statusEl) {
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) {
if (!matchId) return;
if (pollInFlight) return; // prevent overlapping poll requests
pollInFlight = true;
const gameKey = params.gameKey || 'chess';
const endpoint = gameKey === 'ludo' ? 'ludo-match.php' : gameKey === 'domino' ? 'domino-match.php' : 'game.php';
......@@ -166,21 +177,45 @@ async function pollMatchStatus(el, params) {
audio.play('reward');
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) {
// 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 (startTimeout) { clearTimeout(startTimeout); startTimeout = null; }
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') {
scene.replace('chess-game', {
mode: 'live',
matchId: params.matchId,
color: params.color,
color: color,
timeControl: params.timeControl,
isFriendly: true
});
......@@ -224,6 +259,8 @@ function formatTimeControl(tc) {
}
export function unmountLobby() {
mounted = false;
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (startTimeout) { clearTimeout(startTimeout); startTimeout = null; }
matchId = null;
}
......@@ -112,13 +112,17 @@ async function joinQueue(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';
let polling = false;
unsub = setInterval(async () => {
if (polling) return;
polling = true;
try {
const data = await net.post(endpoint, { action: 'status', game_key: params.game });
if (data.match_id) {
onMatchFound(data, params);
}
} catch (e) {}
polling = false;
}, 3000);
}
......
......@@ -296,16 +296,16 @@ function showGameMenu(menu, game) {
</button>
<!-- 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">
<span style="font-size:var(--gm-chip-icon-font);">${emoji('tournament_cup', '🏆', 18)}</span>
<span>Leaderboard</span>
</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>My Games</span>
</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>Puzzles</span>
</button>` : ''}
......
......@@ -66,9 +66,9 @@ async function mountOwnProfile(el) {
<div class="card">
<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);">
${renderRating(t('profile.chess_rapid'), player.elo_rapid)}
${renderRating(t('profile.chess_blitz'), player.elo_blitz)}
${renderRating(t('profile.chess_bullet'), player.elo_bullet)}
${renderRating(t('profile.chess_rapid'), player.elo_rapid, player.games_played)}
${renderRating(t('profile.chess_blitz'), player.elo_blitz, player.games_played)}
${renderRating(t('profile.chess_bullet'), player.elo_bullet, player.games_played)}
</div>
</div>
......@@ -229,9 +229,9 @@ async function mountOtherProfile(el, playerId) {
<div class="card">
<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);">
${renderRating(t('profile.chess_rapid'), player.elo_rapid)}
${renderRating(t('profile.chess_blitz'), player.elo_blitz)}
${renderRating(t('profile.chess_bullet'), player.elo_bullet)}
${renderRating(t('profile.chess_rapid'), player.elo_rapid, player.games_played)}
${renderRating(t('profile.chess_blitz'), player.elo_blitz, player.games_played)}
${renderRating(t('profile.chess_bullet'), player.elo_bullet, player.games_played)}
</div>
</div>
......@@ -476,12 +476,13 @@ function compressAvatar(file) {
});
}
function renderRating(label, rating) {
const display = rating ? rating : '—';
function renderRating(label, rating, gamesPlayed = 0) {
const isUnrated = !rating || (rating === 1200 && gamesPlayed === 0);
const display = isUnrated ? t('profile.unrated') : rating;
return `
<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: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>
`;
}
......
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