/* Chess web client: a board for live play against Stockfish, a list of the signed-in user's stored games, and a replay viewer for any stored game. Plain JavaScript, one file, no framework: the host's CSP allows only same-origin scripts, no inline code and no eval. Sections: state · utilities · API · auth · board · live game · recent games on this device · My games drawer · replay · routing · keyboard · init. */ 'use strict'; // ---------------------------------------------------------------- state // The API base, from the page that loads this script: its element's data-api, // set by the embedded server from -web-api-url and by a host that publishes // the files itself. Without it, /chess, where a static host proxies the API. // Read while the script runs: document.currentScript is null in any callback. function apiBase(script) { const api = script?.dataset.api?.trim(); return api ? api.replace(/\/+$/, '') : '/chess'; } const API = apiBase(document.currentScript); const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; // Chess piece Unicode: all black pieces for better fill, white pawn due to // inability to override emoji variant display. const pieceMap = { 'p': '♙', 'r': '♜', 'n': '♞', 'b': '♝', 'q': '♛', 'k': '♚', 'P': '♙', 'R': '♜', 'N': '♞', 'B': '♝', 'Q': '♛', 'K': '♚' }; // How long a success message stays visible in a modal before it auto-closes const MODAL_SUCCESS_DISPLAY_MS = 700; const HEALTH_INTERVAL_MS = 10000; const GAMES_PAGE_SIZE = 20; const RECENT_GAMES_KEY = 'chess.recentGames'; const RECENT_GAMES_MAX = 20; const RECENT_GAMES_TTL_MS = 24 * 60 * 60 * 1000; // the server's anonymous-game retention // The live game played on this board. const live = { gameId: null, initialFen: null, fen: null, turn: 'w', state: null, termination: '', moves: [], san: [], lastMove: null, isPlayerWhite: true, level: 10, isLocked: false, // the computer is thinking polling: false, pollController: null, selectedSquare: null, actionBusy: false, // a draw or resign request is in flight newGameBusy: false, anonymous: true, // created without an account: listed under "On this device" }; const auth = { token: null, userId: null, username: null, busy: false, // login or registration in flight accountBusy: false, // logout or account deletion in flight }; // The stored game being replayed, if any. Separate from the live game: the // live game stays in memory and is shown again by exitReplay. const replay = { active: false, gameId: null, history: null, ply: 0, flipped: false, timer: null, // autoplay timeout speedMs: 1000, loading: null, // the game ID whose history is being fetched }; // The My games drawer. const games = { open: false, status: '', color: '', items: [], cursor: '', loading: false, loadedFor: null, // user ID the list was loaded for }; const ui = { healthTimer: null, storage: 'unknown', mail: 'unknown', // "ok" when the server emails password-reset links toastTimer: null, historyCache: new Map(), // gameId -> {etag, history} }; const $ = id => document.getElementById(id); // ------------------------------------------------------------ utilities // A short message under the board; 'error' for failures, 'info' for results // and answers, 'success' for copies and downloads. function toast(message, type = 'info', duration = 1800) { const el = $('toast'); el.textContent = message; el.classList.remove('error', 'info', 'success'); el.classList.add('show', type); if (ui.toastTimer) clearTimeout(ui.toastTimer); ui.toastTimer = setTimeout(() => { el.classList.remove('show'); ui.toastTimer = null; }, duration); } function flashErrorMessage(message, duration = 1800) { toast(message, 'error', duration); } // Show/clear a status line inside a modal. Distinct from toast, which is not // visible while a modal's backdrop is up. function setModalMessage(elementId, message, type = 'error') { const el = $(elementId); if (!el) return; el.textContent = message; el.classList.remove('error', 'success', 'hint'); el.classList.add('show', type); } function clearModalMessage(elementId) { const el = $(elementId); if (!el) return; el.textContent = ''; el.classList.remove('show', 'error', 'success', 'hint'); } function anyModalOpen() { return !!document.querySelector('.modal-overlay.show'); } // Copies text; the async clipboard API needs a secure context and, in a // frame, clipboard-write permission, so a hidden textarea is the fallback. async function copyText(text) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch { // fall through } try { const area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', ''); area.className = 'visually-hidden'; document.body.appendChild(area); area.select(); const ok = document.execCommand('copy'); area.remove(); return ok; } catch { return false; } } // Marks a tool button done for a moment after a copy or download. function markDone(button) { button.classList.add('done'); setTimeout(() => button.classList.remove('done'), 1500); } function formatDate(iso) { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } // A player's display name: the claimant's name snapshot, the engine and its // level, or "Anonymous". function playerLabel(player) { if (!player) return '?'; if (player.type === 2) return `Stockfish L${player.level ?? 0}`; return player.name || 'Anonymous'; } // describeResult names a finished game's outcome and how it was reached, from // a live state ("white wins") or a stored result ("white_wins"). function describeResult(state, termination) { const s = (state || '').replace('_', ' '); const winner = s === 'white wins' ? 'White' : 'Black'; const loser = s === 'white wins' ? 'Black' : 'White'; switch (termination) { case 'resignation': return `${loser} resigned — ${winner} wins`; case 'checkmate': return `Checkmate — ${winner} wins`; case 'stalemate': return 'Stalemate — draw'; case 'insufficient_material': return 'Draw — insufficient material'; case 'threefold_repetition': return 'Draw — threefold repetition'; case 'fifty_move_rule': return 'Draw — fifty-move rule'; case 'agreement': return 'Draw agreed'; } switch (s) { case 'white wins': case 'black wins': return `${winner} wins`; case 'stalemate': return 'Stalemate — draw'; case 'draw': return 'Draw'; } return 'Game over'; } // shortResult is the outcome in a few words, for the narrow turn strip. function shortResult(state, termination) { const winner = state === 'white wins' ? 'White' : 'Black'; switch (termination) { case 'resignation': return `${winner} wins · resigned`; case 'checkmate': return `${winner} wins · mate`; case 'stalemate': return 'Draw · stalemate'; case 'insufficient_material': return 'Draw · material'; case 'threefold_repetition': return 'Draw · repetition'; case 'fifty_move_rule': return 'Draw · 50 moves'; case 'agreement': return 'Draw · agreed'; } if (state === 'white wins' || state === 'black wins') return `${winner} wins`; return 'Draw'; } function terminationLabel(termination) { switch (termination) { case 'checkmate': return 'Checkmate'; case 'resignation': return 'Resignation'; case 'stalemate': return 'Stalemate'; case 'insufficient_material': return 'Insufficient material'; case 'threefold_repetition': return 'Threefold repetition'; case 'fifty_move_rule': return 'Fifty-move rule'; case 'agreement': return 'Agreed'; } return ''; } function isGameOver(state) { return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state); } function isPlayable(state) { return !isGameOver(state) && state !== 'stuck'; } // ------------------------------------------------------------------ API // Wrapper for authenticated requests function authFetch(url, options = {}) { if (auth.token) { options.headers = { ...options.headers, 'Authorization': `Bearer ${auth.token}` }; } return fetch(url, options); } function apiJSON(method, path, body) { const options = { method, headers: {} }; if (body !== undefined) { options.headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); } return authFetch(`${API}${path}`, options); } // Shared helper: safely parse error response regardless of Content-Type async function parseErrorResponse(response) { try { return await response.json(); } catch { return { error: `Server error (${response.status})`, details: null }; } } // Maps a failed request to a short message and the server indicator state. function handleApiError(action, error, response = null) { let serverStatus = 'degraded'; let statusMessage = 'Server error'; const isNetworkError = !response; if (isNetworkError) { statusMessage = 'Connection failed'; console.error(`Network error during ${action}:`, error); } else { const status = response.status; switch (status) { case 400: serverStatus = 'healthy'; if (action === 'undo') statusMessage = 'No moves to undo'; else if (action === 'move') statusMessage = 'Invalid move'; else statusMessage = 'Invalid request'; break; case 401: serverStatus = 'healthy'; statusMessage = 'Sign in again'; break; case 403: serverStatus = 'healthy'; if (action === 'move' || action === 'trigger computer move' || action === 'undo') { statusMessage = 'Side claimed by another player'; } else { statusMessage = 'Not authorized'; } break; case 404: serverStatus = 'healthy'; statusMessage = action === 'history' ? 'Stored game not found' : 'Game not found'; break; case 409: serverStatus = 'healthy'; statusMessage = 'Game changed; try again'; break; case 415: serverStatus = 'healthy'; statusMessage = 'Invalid content type'; break; case 429: serverStatus = 'degraded'; statusMessage = 'Rate limited'; break; case 503: serverStatus = 'degraded'; statusMessage = action === 'history' || action === 'games' || action === 'pgn' ? 'Stored games unavailable' : 'Service unavailable'; break; default: if (status >= 500) { serverStatus = 'degraded'; statusMessage = `Server error (${status})`; } else if (status >= 400) { serverStatus = 'healthy'; statusMessage = `Request failed (${status})`; } } console.error(`API error during ${action}: ${status} - ${statusMessage}`); } flashErrorMessage(statusMessage); if (isNetworkError || response.status >= 500) { setServerStatus(serverStatus, statusMessage); } else { setServerStatus('healthy'); } return { serverStatus, statusMessage, isNetworkError, isClientError: !!response && response.status >= 400 && response.status < 500, isServerError: !!response && response.status >= 500, }; } function setDot(id, prefix, status, text) { const dot = $(id); dot.dataset.status = status; const label = `${prefix}: ${text || status}`; dot.title = label; dot.setAttribute('aria-label', label); } function setServerStatus(status, message) { setDot('server-dot', 'Server', status, message); } function setStorageStatus(status) { ui.storage = status; setDot('storage-dot', 'Storage', status, status === 'ok' ? 'ok (games are stored)' : status === 'disabled' ? 'disabled (no stored games)' : status); updateLiveTools(); } function setMailStatus(status) { ui.mail = status; } // A neutral hint fills a modal's message slot until a result replaces it. function setModalHint(elementId, text) { const el = $(elementId); if (!el) return; el.textContent = text; el.classList.remove('error', 'success'); el.classList.add('show', 'hint'); } function startHealthCheck() { const checkHealth = async () => { try { const response = await fetch(`${API}/health`); if (response.ok) { const health = await response.json(); setServerStatus(health.status === 'healthy' ? 'healthy' : 'degraded'); setStorageStatus(health.storage || 'unknown'); setMailStatus(health.mail || 'disabled'); } else { handleApiError('health check', null, response); setStorageStatus('unknown'); } } catch (error) { handleApiError('health check', error); setStorageStatus('unknown'); } }; checkHealth(); ui.healthTimer = setInterval(checkHealth, HEALTH_INTERVAL_MS); } // ----------------------------------------------------------------- auth function restoreAuthSession() { let token = null; try { token = localStorage.getItem('authToken'); } catch { /* storage blocked */ } if (token) { auth.token = token; return validateSession(); } updateAuthButton(); return Promise.resolve(); } async function validateSession() { try { const response = await authFetch(`${API}/api/auth/me`); if (response.ok) { const user = await response.json(); setSignedIn(user.userId, user.username, auth.token); } else { clearAuthState(); } } catch { clearAuthState(); } } function setSignedIn(userId, username, token) { auth.token = token; auth.userId = userId; auth.username = username; try { localStorage.setItem('authToken', token); } catch { /* storage blocked */ } updateAuthButton(); if (games.open) refreshGamesForUser(); } function clearAuthState() { auth.token = null; auth.userId = null; auth.username = null; try { localStorage.removeItem('authToken'); } catch { /* storage blocked */ } updateAuthButton(); if (games.open) refreshGamesForUser(); } function updateAuthButton() { const button = $('auth-btn'); const signedIn = !!auth.token; button.dataset.status = signedIn ? 'authenticated' : 'anonymous'; $('auth-dot').dataset.status = signedIn ? 'authenticated' : 'anonymous'; $('auth-label').textContent = signedIn ? auth.username : 'Sign in'; button.title = signedIn ? `Signed in as ${auth.username}` : 'Sign in or register'; button.setAttribute('aria-label', button.title); } function handleAuthClick() { if (auth.token) showAccountModal(); else showAuthModal(); } // Account dialog, for a signed-in user: sign out, or delete the account. It // replaces a native confirm(), which a sandboxed embed may not be allowed. function showAccountModal() { $('account-username').textContent = auth.username || ''; $('delete-account').open = false; $('delete-password').value = ''; setModalHint('account-modal-message', 'Signing out keeps your games; deleting the account removes your name from them.'); $('account-overlay').classList.add('show'); document.removeEventListener('keydown', handleAccountKeydown); document.addEventListener('keydown', handleAccountKeydown); $('account-cancel-btn').focus(); } function hideAccountModal() { if (auth.accountBusy) return; $('account-overlay').classList.remove('show'); $('delete-password').value = ''; document.removeEventListener('keydown', handleAccountKeydown); } function setAccountBusy(busy) { auth.accountBusy = busy; for (const id of ['logout-btn', 'account-cancel-btn', 'delete-account-btn', 'delete-password']) { $(id).disabled = busy; } } function handleAccountKeydown(e) { if (auth.accountBusy) { if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); return; } if (e.key === 'Escape') { e.preventDefault(); hideAccountModal(); } else if (e.key === 'Enter' && document.activeElement?.id === 'delete-password') { e.preventDefault(); deleteAccount(); } } async function handleLogoutClick() { setAccountBusy(true); await handleLogout(); setAccountBusy(false); hideAccountModal(); } async function handleLogout() { if (auth.token) { try { await apiJSON('POST', '/api/auth/logout'); } catch { // Ignore - clear local state regardless } } clearAuthState(); } // The server re-checks the password, ends the session, clears the account's // claims and names from its games, then deletes it (DELETE /api/auth/me). async function deleteAccount() { if (auth.accountBusy) return; const password = $('delete-password').value; if (!password) { setModalMessage('account-modal-message', 'Enter your password to delete the account', 'error'); return; } setAccountBusy(true); clearModalMessage('account-modal-message'); let response; try { response = await apiJSON('DELETE', '/api/auth/me', { password }); } catch { setAccountBusy(false); setModalMessage('account-modal-message', 'Network error: the account was not deleted', 'error'); return; } if (response.status === 204) { const name = auth.username; clearAuthState(); setModalMessage('account-modal-message', `Account ${name} deleted`, 'success'); setTimeout(() => { setAccountBusy(false); hideAccountModal(); }, MODAL_SUCCESS_DISPLAY_MS * 2); return; } setAccountBusy(false); const data = await parseErrorResponse(response); let message; if (response.status === 401 && data.error === 'invalid credentials') { message = 'Wrong password'; } else if (response.status === 401) { // The token itself was refused: the session is already gone. clearAuthState(); message = 'Your session has ended. Sign in again to delete the account.'; } else if (response.status === 429) { message = 'Too many attempts. Wait a minute and try again.'; } else { message = data.error ? `Not deleted: ${data.error}` : `Server error (${response.status})`; } setModalMessage('account-modal-message', message, 'error'); } // Disables every interactive control in the auth modal at once while a // request is in flight (or its success message is showing): guards // re-entrancy from the Enter-key handler and stops edits mid-request. function setAuthModalBusy(busy) { auth.busy = busy; for (const id of ['login-submit-btn', 'register-submit-btn', 'auth-cancel-btn', 'auth-cancel-btn-2', 'forgot-btn', 'recover-submit-btn', 'recover-back-btn']) { $(id).disabled = busy; } document.querySelectorAll('.auth-tab').forEach(t => t.disabled = busy); document.querySelectorAll('.auth-form input').forEach(i => i.disabled = busy); } function showAuthModal() { $('auth-modal-overlay').classList.add('show'); switchAuthTab('login'); document.removeEventListener('keydown', handleAuthModalKeydown); document.addEventListener('keydown', handleAuthModalKeydown); } function hideAuthModal() { $('auth-modal-overlay').classList.remove('show'); document.querySelectorAll('.auth-form input').forEach(input => input.value = ''); document.removeEventListener('keydown', handleAuthModalKeydown); clearModalMessage('auth-modal-message'); setAuthModalBusy(false); } function handleAuthModalKeydown(e) { if (!$('auth-modal-overlay').classList.contains('show')) return; if (auth.busy) { if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); return; } if (e.key === 'Escape') { e.preventDefault(); hideAuthModal(); } else if (e.key === 'Enter') { e.preventDefault(); ({ login: handleLogin, register: handleRegister, recover: handleRecover })[activeAuthForm()](); } } // The auth modal shows one of three forms: sign in, register, or password // recovery (reached from the sign-in form, without a tab). function switchAuthTab(tab) { document.querySelectorAll('.auth-tab').forEach(t => { const active = t.dataset.tab === tab; t.classList.toggle('active', active); t.setAttribute('aria-selected', active ? 'true' : 'false'); }); document.querySelector('.auth-tabs').classList.toggle('hidden', tab === 'recover'); $('login-form').classList.toggle('hidden', tab !== 'login'); $('register-form').classList.toggle('hidden', tab !== 'register'); $('recover-form').classList.toggle('hidden', tab !== 'recover'); setModalHint('auth-modal-message', { login: 'Your games are kept under your account.', register: 'A username and a password; the email is optional and lets you reset a forgotten password.', recover: ui.mail === 'ok' ? 'The link in the email works for an hour.' : 'This server may not send email yet; the administrator can give you a reset link.', }[tab]); $({ login: 'login-identifier', register: 'register-username', recover: 'recover-identifier' }[tab]).focus(); } function activeAuthForm() { if (!$('login-form').classList.contains('hidden')) return 'login'; if (!$('register-form').classList.contains('hidden')) return 'register'; return 'recover'; } // Returns the focus to a field after a failed request: the request disabled // the fields, which drops the focus to the page, and a retyped password // would otherwise go nowhere. function refocus(id, select = true) { const el = $(id); if (!el) return; el.focus(); if (select && typeof el.select === 'function') el.select(); } async function submitAuth(path, body, failure, focusId) { setAuthModalBusy(true); clearModalMessage('auth-modal-message'); const fail = message => { setModalMessage('auth-modal-message', message, 'error'); setAuthModalBusy(false); refocus(focusId); return null; }; let response; try { response = await apiJSON('POST', path, body); } catch (error) { return fail(handleApiError(path, error).statusMessage); } if (!response.ok) { const err = await parseErrorResponse(response); if (response.status === 401) return fail('Wrong username or password'); if (response.status === 429) return fail('Too many attempts. Wait a minute and try again.'); return fail(err.details || err.error || failure); } try { return await response.json(); } catch (error) { console.error(`${path}: response OK but JSON parse failed:`, error); return fail('Unexpected response from server'); } } async function handleLogin() { if (auth.busy) return; const identifier = $('login-identifier').value.trim(); const password = $('login-password').value; if (!identifier || !password) { setModalMessage('auth-modal-message', 'Fill in both fields', 'error'); refocus(identifier ? 'login-password' : 'login-identifier', false); return; } const result = await submitAuth('/api/auth/login', { identifier, password }, 'Sign-in failed', 'login-password'); if (!result) return; setSignedIn(result.userId, result.username, result.token); setModalMessage('auth-modal-message', `Signed in as ${result.username}`, 'success'); setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS); } // The server's password rule, checked first for a quicker answer. function passwordProblem(password) { if (password.length < 8) return 'Password needs at least 8 characters'; if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) return 'Password needs a letter and a number'; return ''; } // Asks for a reset link. The server answers the same whether or not the // account exists, so the message is the same too. async function handleRecover() { if (auth.busy) return; const identifier = $('recover-identifier').value.trim(); if (!identifier) { setModalMessage('auth-modal-message', 'Enter your username or email', 'error'); refocus('recover-identifier', false); return; } setAuthModalBusy(true); clearModalMessage('auth-modal-message'); let response; try { response = await apiJSON('POST', '/api/auth/recover', { identifier }); } catch (error) { setModalMessage('auth-modal-message', handleApiError('recover', error).statusMessage, 'error'); setAuthModalBusy(false); refocus('recover-identifier'); return; } setAuthModalBusy(false); if (response.status === 202) { setModalMessage('auth-modal-message', 'If the account has an email address, a reset link is on its way. Check your inbox.', 'success'); $('recover-identifier').value = ''; $('recover-back-btn').focus(); return; } const err = await parseErrorResponse(response); let message = err.error || 'Could not send the link'; if (response.status === 429) message = 'Too many requests. Wait a minute and try again.'; else if (response.status === 503 && err.code === 'NOT_CONFIGURED') message = 'This server cannot send email; ask its administrator for a reset link.'; setModalMessage('auth-modal-message', message, 'error'); refocus('recover-identifier'); } // --- reset: the link in the recovery email opens the client with ?reset= const reset = { token: null, busy: false }; function openResetModal(token) { reset.token = token; reset.busy = false; $('reset-password').value = ''; $('reset-password-2').value = ''; clearModalMessage('reset-modal-message'); $('reset-overlay').classList.add('show'); document.removeEventListener('keydown', handleResetKeydown); document.addEventListener('keydown', handleResetKeydown); $('reset-password').focus(); } function closeResetModal() { if (reset.busy) return; reset.token = null; $('reset-overlay').classList.remove('show'); $('reset-password').value = ''; $('reset-password-2').value = ''; document.removeEventListener('keydown', handleResetKeydown); } function setResetBusy(busy) { reset.busy = busy; for (const id of ['reset-password', 'reset-password-2', 'reset-submit-btn', 'reset-cancel-btn']) { $(id).disabled = busy; } } function handleResetKeydown(e) { if (reset.busy) { if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); return; } if (e.key === 'Escape') { e.preventDefault(); closeResetModal(); } else if (e.key === 'Enter') { e.preventDefault(); submitReset(); } } async function submitReset() { if (reset.busy || !reset.token) return; const password = $('reset-password').value; const again = $('reset-password-2').value; const weak = passwordProblem(password); if (weak) { setModalMessage('reset-modal-message', weak, 'error'); refocus('reset-password'); return; } if (password !== again) { setModalMessage('reset-modal-message', 'The two passwords differ', 'error'); refocus('reset-password-2'); return; } setResetBusy(true); clearModalMessage('reset-modal-message'); let response; try { response = await apiJSON('POST', '/api/auth/reset', { token: reset.token, password }); } catch (error) { setResetBusy(false); setModalMessage('reset-modal-message', handleApiError('reset', error).statusMessage, 'error'); refocus('reset-password'); return; } if (!response.ok) { const err = await parseErrorResponse(response); setResetBusy(false); setModalMessage('reset-modal-message', response.status === 400 ? (err.details && err.error === 'weak password' ? err.details : 'This link is invalid or has expired. Ask for a new one from Sign in.') : (err.error || `Server error (${response.status})`), 'error'); refocus('reset-password'); return; } let result; try { result = await response.json(); } catch { setResetBusy(false); setModalMessage('reset-modal-message', 'Unexpected response from server', 'error'); return; } setSignedIn(result.userId, result.username, result.token); setModalMessage('reset-modal-message', `Password changed — signed in as ${result.username}`, 'success'); setTimeout(() => { setResetBusy(false); closeResetModal(); }, MODAL_SUCCESS_DISPLAY_MS * 2); } async function handleRegister() { if (auth.busy) return; const username = $('register-username').value.trim(); const email = $('register-email').value.trim(); const password = $('register-password').value; if (!username || !password) { setModalMessage('auth-modal-message', 'Username and password required', 'error'); return; } const weak = passwordProblem(password); if (weak) { setModalMessage('auth-modal-message', weak, 'error'); refocus('register-password'); return; } const body = { username, password }; if (email) body.email = email; const result = await submitAuth('/api/auth/register', body, 'Registration failed', 'register-password'); if (!result) return; setSignedIn(result.userId, result.username, result.token); setModalMessage('auth-modal-message', `Account created, welcome ${result.username}`, 'success'); setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS); } // ---------------------------------------------------------------- board const board = { flipped: false, squares: new Map(), // "e4" -> element }; // Builds the 64 squares in the given orientation. The live game and the // replay share one board; each sets its own orientation when it shows. function buildBoard(flipped) { board.flipped = flipped; board.squares.clear(); const boardEl = $('board'); boardEl.innerHTML = ''; const files = 'abcdefgh'; const ranks = '87654321'; const fileLabels = flipped ? [...files].reverse() : [...files]; const rankLabels = flipped ? [...ranks].reverse() : [...ranks]; $('board').parentElement.querySelector('.coordinates.files').replaceChildren( ...fileLabels.map(f => { const s = document.createElement('span'); s.textContent = f; return s; })); $('board').parentElement.querySelector('.coordinates.ranks').replaceChildren( ...rankLabels.map(r => { const s = document.createElement('span'); s.textContent = r; return s; })); for (let i = 0; i < 64; i++) { const row = Math.floor(i / 8); // 0 = top of the display const col = i % 8; const rank = flipped ? row : 7 - row; // 0 = rank 1 const file = flipped ? 7 - col : col; // 0 = file a const name = `${files[file]}${rank + 1}`; const square = document.createElement('div'); square.className = `square ${(rank + file) % 2 === 0 ? 'dark' : 'light'}`; square.dataset.square = name; square.setAttribute('role', 'gridcell'); square.setAttribute('aria-label', name); square.addEventListener('click', handleSquareClick); boardEl.appendChild(square); board.squares.set(name, square); } } // Shows a position; the browser never computes moves. opts.lastMove (UCI) // highlights a move's squares and opts.matedColor marks that king. function renderPosition(fen, opts = {}) { const fenBoard = (fen || START_FEN).split(' ')[0]; for (const s of board.squares.values()) { s.textContent = ''; s.classList.remove('white-piece', 'black-piece', 'mated-king', 'last-move-from', 'last-move-to', 'selected'); delete s.dataset.pieceColor; delete s.dataset.pieceType; s.setAttribute('aria-label', s.dataset.square); } let rank = 7, file = 0; for (const char of fenBoard) { if (char === '/') { rank--; file = 0; } else if (/\d/.test(char)) { file += parseInt(char, 10); } else { const name = `${String.fromCharCode(97 + file)}${rank + 1}`; const squareEl = board.squares.get(name); if (squareEl) { const pieceColor = (char === char.toUpperCase()) ? 'w' : 'b'; squareEl.textContent = pieceMap[char === 'P' ? 'P' : char.toLowerCase()] || ''; squareEl.classList.add(pieceColor === 'w' ? 'white-piece' : 'black-piece'); squareEl.dataset.pieceColor = pieceColor; squareEl.dataset.pieceType = char.toLowerCase(); squareEl.setAttribute('aria-label', `${name}: ${pieceColor === 'w' ? 'white' : 'black'} ${pieceName(char)}`); } file++; } } if (opts.lastMove && opts.lastMove.length >= 4) { board.squares.get(opts.lastMove.substring(0, 2))?.classList.add('last-move-from'); board.squares.get(opts.lastMove.substring(2, 4))?.classList.add('last-move-to'); } if (opts.matedColor) { for (const s of board.squares.values()) { if (s.dataset.pieceType === 'k' && s.dataset.pieceColor === opts.matedColor) { s.classList.add('mated-king'); } } } } function pieceName(char) { switch (char.toLowerCase()) { case 'p': return 'pawn'; case 'r': return 'rook'; case 'n': return 'knight'; case 'b': return 'bishop'; case 'q': return 'queen'; case 'k': return 'king'; } return ''; } // A thumbnail of a position for the games list: the same glyphs, 8x8 spans. function renderMiniBoard(container, fen) { container.innerHTML = ''; const rows = (fen || START_FEN).split(' ')[0].split('/'); for (let r = 0; r < 8; r++) { let file = 0; for (const char of rows[r] || '8') { if (/\d/.test(char)) { const n = parseInt(char, 10); for (let k = 0; k < n; k++) container.appendChild(miniSquare(r, file++, '')); } else { container.appendChild(miniSquare(r, file++, char)); } } while (file < 8) container.appendChild(miniSquare(r, file++, '')); } } function miniSquare(row, file, char) { const s = document.createElement('span'); const rank = 7 - row; s.className = (rank + file) % 2 === 0 ? 'dark' : 'light'; if (char) { s.textContent = pieceMap[char === 'P' ? 'P' : char.toLowerCase()] || ''; s.classList.add(char === char.toUpperCase() ? 'white-piece' : 'black-piece'); } return s; } function flashSquare(element, success = true) { if (!element) return; const className = success ? 'flash-green' : 'flash-red'; element.classList.add(className); setTimeout(() => element.classList.remove(className), 400); } // Fills a move grid: number, white move, black move per row. moves[i] is the // text of ply i+1; startsWithBlack when the first stored ply is Black's. // onClick, when given, makes each move a button that selects its ply. function renderMoveGrid(grid, moves, startsWithBlack, currentPly, onClick) { grid.innerHTML = ''; let moveNumber = startingMoveNumber(grid); let i = 0; const cell = (text, cls, ply) => { let el; if (onClick && ply !== undefined) { el = document.createElement('button'); el.type = 'button'; el.addEventListener('click', () => onClick(ply)); el.setAttribute('aria-label', `Move ${ply}: ${text}`); } else { el = document.createElement('div'); } el.className = cls; el.textContent = text; if (ply !== undefined) { el.dataset.ply = String(ply); if (ply === currentPly) el.setAttribute('aria-current', 'true'); } return el; }; if (startsWithBlack && moves.length > 0) { grid.appendChild(cell(`${moveNumber}.`, 'move-number')); grid.appendChild(cell('…', 'move-empty')); grid.appendChild(cell(moves[0], 'move move-black', 1)); i = 1; moveNumber++; } for (; i < moves.length; i += 2) { grid.appendChild(cell(`${moveNumber}.`, 'move-number')); grid.appendChild(cell(moves[i], 'move move-white', i + 1)); if (i + 1 < moves.length) { grid.appendChild(cell(moves[i + 1], 'move move-black', i + 2)); } else { grid.appendChild(cell('', 'move-empty')); } moveNumber++; } } // The first move number of a line comes from its initial FEN; a grid carries // it in data-start so a custom position numbers its moves as PGN does. function startingMoveNumber(grid) { const n = parseInt(grid.dataset.start || '1', 10); return Number.isFinite(n) && n > 0 ? n : 1; } function setGridStart(grid, initialFen) { const parts = (initialFen || START_FEN).split(' '); grid.dataset.start = parts[5] || '1'; return parts[1] === 'b'; } function scrollMoveIntoView(grid, ply) { const el = grid.querySelector(`[data-ply="${ply}"]`); if (el) el.scrollIntoView({ block: 'nearest' }); else grid.parentElement.scrollTop = 0; } // ------------------------------------------------------------ live game function playerColor() { return live.isPlayerWhite ? 'w' : 'b'; } function setNewGameModalBusy(busy) { live.newGameBusy = busy; for (const id of ['start-game-btn', 'cancel-btn', 'computer-level', 'search-time', 'starting-fen']) { $(id).disabled = busy; } document.querySelectorAll('input[name="player-color"]').forEach(r => r.disabled = busy); } function showNewGameModal() { if (replay.active) return; setModalHint('new-game-modal-message', 'Level 0 is a beginner, 20 is full strength; the search time is how long it thinks per move.'); $('modal-overlay').classList.add('show'); document.addEventListener('keydown', handleModalKeydown); $('start-game-btn').focus(); } function hideNewGameModal() { $('modal-overlay').classList.remove('show'); document.removeEventListener('keydown', handleModalKeydown); clearModalMessage('new-game-modal-message'); setNewGameModalBusy(false); } function handleModalKeydown(e) { if (!$('modal-overlay').classList.contains('show')) return; if (live.newGameBusy) { if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); return; } const typing = document.activeElement?.id === 'starting-fen'; switch (e.key) { case 'Enter': if (typing && e.shiftKey) return; e.preventDefault(); startNewGame(); break; case 'Escape': e.preventDefault(); hideNewGameModal(); break; case 'w': case 'W': if (typing) return; e.preventDefault(); document.querySelector('input[name="player-color"][value="white"]').checked = true; break; case 'b': case 'B': if (typing) return; e.preventDefault(); document.querySelector('input[name="player-color"][value="black"]').checked = true; break; case 'l': case 'L': if (typing) return; e.preventDefault(); $('computer-level').focus(); break; case 's': case 'S': if (typing) return; e.preventDefault(); $('search-time').focus(); break; } } async function startNewGame() { if (live.newGameBusy) return; const chosenColor = document.querySelector('input[name="player-color"]:checked').value; const computerLevel = parseInt($('computer-level').value, 10); const searchTime = parseInt($('search-time').value, 10); const startingFEN = $('starting-fen').value.trim(); const willBePlayerWhite = chosenColor === 'white'; const computer = { type: 2, level: computerLevel, searchTime }; const requestBody = { white: willBePlayerWhite ? { type: 1 } : computer, black: willBePlayerWhite ? computer : { type: 1 }, }; if (startingFEN && startingFEN !== START_FEN) requestBody.fen = startingFEN; setNewGameModalBusy(true); clearModalMessage('new-game-modal-message'); let response; try { response = await apiJSON('POST', '/api/games', requestBody); } catch (error) { const errorInfo = handleApiError('create game', error); setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error'); setNewGameModalBusy(false); return; } if (!response.ok) { const err = await parseErrorResponse(response); const info = handleApiError('create game', null, response); setModalMessage('new-game-modal-message', err.details || err.error || info.statusMessage, 'error'); setNewGameModalBusy(false); return; } let game; try { game = await response.json(); } catch (error) { console.error('Create game: response OK but JSON parse failed:', error); setModalMessage('new-game-modal-message', 'Unexpected response from server', 'error'); setNewGameModalBusy(false); return; } stopPolling(); live.isPlayerWhite = willBePlayerWhite; live.gameId = game.gameId; live.initialFen = game.fen; live.level = computerLevel; live.moves = []; live.san = []; live.isLocked = false; live.selectedSquare = null; live.anonymous = !auth.token; buildBoard(!live.isPlayerWhite); updateGameDisplay(game); const computerTurn = live.isPlayerWhite ? 'b' : 'w'; if (isPlayable(game.state) && game.turn === computerTurn) { triggerComputerMove(); } setModalMessage('new-game-modal-message', `Game started — you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success'); setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS); } function handleSquareClick(e) { if (replay.active) return; if (!live.gameId) { showNewGameModal(); return; } if (live.isLocked || !isPlayable(live.state)) return; const squareEl = e.currentTarget; const { square, pieceColor } = squareEl.dataset; if (live.turn !== playerColor()) return; if (live.selectedSquare) { const from = live.selectedSquare; board.squares.get(from)?.classList.remove('selected'); live.selectedSquare = null; if (from !== square) handleHumanMove(from, square); } else if (pieceColor === playerColor()) { live.selectedSquare = square; squareEl.classList.add('selected'); } else if (pieceColor) { flashErrorMessage('Not your piece'); flashSquare(squareEl, false); } } // A pawn stepping or capturing onto its last rank needs a promotion piece; UCI // requires the suffix (e7e8q), and the server rejects the bare move. Other // moves go to the server as-is, which remains the only legality check. function isPromotionMove(from, to) { const fromEl = board.squares.get(from); if (!fromEl || fromEl.dataset.pieceType !== 'p') return false; const fileDelta = Math.abs(from.charCodeAt(0) - to.charCodeAt(0)); if (fileDelta > 1) return false; const color = fromEl.dataset.pieceColor; return (color === 'w' && from[1] === '7' && to[1] === '8') || (color === 'b' && from[1] === '2' && to[1] === '1'); } // Resolves with 'q', 'r', 'b' or 'n', or null when cancelled. Keyboard: Q/R/B/N // pick, Escape cancels, Tab/Enter work on the focused button. let promotionResolve = null; function choosePromotion(color) { const overlay = $('promotion-overlay'); const choices = overlay.querySelector('.promotion-choices'); choices.classList.toggle('white', color === 'w'); choices.classList.toggle('black', color !== 'w'); overlay.classList.add('show'); document.addEventListener('keydown', handlePromotionKeydown); overlay.querySelector('.promotion-choice[data-piece="q"]').focus(); return new Promise(resolve => { promotionResolve = resolve; }); } function closePromotion(piece) { $('promotion-overlay').classList.remove('show'); document.removeEventListener('keydown', handlePromotionKeydown); const resolve = promotionResolve; promotionResolve = null; if (resolve) resolve(piece); } function handlePromotionKeydown(e) { const key = e.key.toLowerCase(); if (key === 'q' || key === 'r' || key === 'b' || key === 'n') { e.preventDefault(); closePromotion(key); } else if (e.key === 'Escape') { e.preventDefault(); closePromotion(null); } } async function handleHumanMove(from, to) { let move = from + to; const fromEl = board.squares.get(from); const toEl = board.squares.get(to); if (isPromotionMove(from, to)) { const piece = await choosePromotion(fromEl.dataset.pieceColor); if (!piece) return; move += piece; } try { const response = await apiJSON('POST', `/api/games/${live.gameId}/moves`, { move }); if (!response.ok) { if (response.status === 400) { const err = await parseErrorResponse(response); flashErrorMessage(err.error?.startsWith('promotion') ? 'Choose a promotion piece' : 'Invalid move'); flashSquare(fromEl, false); flashSquare(toEl, false); renderLive(); return; } handleApiError('move', null, response); return; } const game = await response.json(); flashSquare(fromEl, true); flashSquare(toEl, true); updateGameDisplay(game); if (isPlayable(game.state)) triggerComputerMove(); } catch (error) { handleApiError('move', error); } } async function triggerComputerMove() { lockBoard(); try { const response = await apiJSON('POST', `/api/games/${live.gameId}/moves`, { move: 'cccc' }); if (!response.ok) { handleApiError('trigger computer move', null, response); unlockBoard(); return; } startPolling(); } catch (error) { handleApiError('trigger computer move', error); unlockBoard(); } } function startPolling() { live.polling = true; pollOnce(); } function stopPolling() { live.polling = false; if (live.pollController) { live.pollController.abort(); live.pollController = null; } } async function pollOnce() { if (!live.polling || !live.gameId || replay.active) return; const moveCount = live.moves.length; live.pollController = new AbortController(); try { const response = await authFetch( `${API}/api/games/${live.gameId}?wait=true&moveCount=${moveCount}`, { signal: live.pollController.signal } ); if (!live.polling) return; if (!response.ok) { handleApiError('poll game state', null, response); if (response.status === 404) { stopPolling(); unlockBoard(); flashErrorMessage('Game no longer exists'); live.gameId = null; updateGameControls(); return; } setTimeout(pollOnce, 2000); return; } const game = await response.json(); setServerStatus('healthy'); if (game.state !== 'pending') { stopPolling(); updateGameDisplay(game); unlockBoard(); } else { pollOnce(); } } catch (error) { if (error.name === 'AbortError') return; handleApiError('poll game state', error); stopPolling(); unlockBoard(); } } function lockBoard() { live.isLocked = true; updateTurnStrip(); updateGameControls(); } function unlockBoard() { live.isLocked = false; updateTurnStrip(); updateGameControls(); } async function undoMoves() { if (live.isLocked || replay.active || !live.gameId) return; if (live.moves.length < 2) return; // A resignation or agreed draw stays on record when play continues. const conceded = isGameOver(live.state) && (live.termination === 'resignation' || live.termination === 'agreement'); try { const response = await apiJSON('POST', `/api/games/${live.gameId}/undo`, { count: 2 }); if (!response.ok) { handleApiError('undo', null, response); return; } const game = await response.json(); updateGameDisplay(game); if (game.state === 'stuck') { flashErrorMessage('Engine error — undo again or start a new game', 3000); } else if (conceded && game.concession) { const what = game.concession.termination === 'agreement' ? 'agreed draw' : 'resignation'; toast(`Play continues; the ${what} stays on record`, 'info', 3000); } } catch (error) { handleApiError('undo', error); } } async function offerDraw() { if (live.actionBusy || !live.gameId || replay.active) return; live.actionBusy = true; updateGameControls(); try { const response = await apiJSON('POST', `/api/games/${live.gameId}/draw`, { action: 'offer', color: playerColor() }); if (response.status === 409) { flashErrorMessage('Make a move before offering again', 2500); return; } if (!response.ok) { handleApiError('offer draw', null, response); return; } const game = await response.json(); if (game.drawOutcome === 'declined') toast('Draw declined', 'info', 2500); live.actionBusy = false; updateGameDisplay(game); } catch (error) { handleApiError('offer draw', error); } finally { live.actionBusy = false; updateGameControls(); } } function showResignModal() { if (!live.gameId || isGameOver(live.state) || replay.active) return; $('resign-overlay').classList.add('show'); document.addEventListener('keydown', handleResignKeydown); $('resign-cancel-btn').focus(); } function hideResignModal() { $('resign-overlay').classList.remove('show'); document.removeEventListener('keydown', handleResignKeydown); } function handleResignKeydown(e) { if (e.key === 'Escape') { e.preventDefault(); hideResignModal(); } } async function resignGame() { hideResignModal(); if (live.actionBusy || !live.gameId) return; live.actionBusy = true; updateGameControls(); try { const response = await apiJSON('POST', `/api/games/${live.gameId}/resign`, { color: playerColor() }); if (!response.ok) { handleApiError('resign', null, response); return; } const game = await response.json(); stopPolling(); // a computer move in flight is discarded by the server live.isLocked = false; live.actionBusy = false; updateGameDisplay(game); } catch (error) { handleApiError('resign', error); } finally { live.actionBusy = false; updateGameControls(); } } // Applies a game response: state, board (unless a replay is showing), move // list, controls, and a result message when the game just ended. function updateGameDisplay(game) { const wasOver = isGameOver(live.state); live.fen = game.fen; live.turn = game.turn; live.state = game.state; live.termination = game.termination || ''; live.moves = game.moves || []; live.san = Array.isArray(game.san) && game.san.length === live.moves.length ? game.san : []; live.lastMove = game.lastMove?.move || null; if (!replay.active) renderLive(); renderLiveMoves(); updateTurnStrip(); updateGameControls(); if (live.anonymous && live.gameId) recordRecentGame(game); if (!wasOver && isGameOver(game.state)) { toast(describeResult(game.state, game.termination), 'info', 4000); } } // Shows the live position on the shared board. function renderLive() { if (board.flipped !== !live.isPlayerWhite) buildBoard(!live.isPlayerWhite); const mated = (live.state === 'white wins' || live.state === 'black wins') && (live.termination || 'checkmate') === 'checkmate'; renderPosition(live.fen, { lastMove: live.lastMove, matedColor: mated ? (live.state === 'white wins' ? 'b' : 'w') : null, }); } function renderLiveMoves() { const grid = $('live-moves'); const startsWithBlack = setGridStart(grid, startingFenOfLive()); const texts = live.moves.map((uci, i) => live.san[i] || uci); renderMoveGrid(grid, texts, startsWithBlack, live.moves.length, null); const box = $('live-moves-box'); box.scrollTop = box.scrollHeight; } // The position the live game started from; the create response's FEN. function startingFenOfLive() { return live.initialFen || START_FEN; } function updateTurnStrip() { const dot = $('turn-dot'); const text = $('turn-text'); let status = 'idle', label = 'No game'; let title = ''; if (!live.gameId) { status = 'idle'; label = 'No game'; } else if (live.state === 'pending' || live.isLocked) { status = 'thinking'; label = 'Thinking…'; title = 'The computer is thinking'; } else if (isGameOver(live.state)) { status = live.state === 'white wins' ? 'white-wins' : live.state === 'black wins' ? 'black-wins' : live.state; label = shortResult(live.state, live.termination); title = describeResult(live.state, live.termination); } else if (live.state === 'stuck') { status = 'stuck'; label = 'Engine error'; title = 'Engine error — undo or start a new game'; } else { // The dot shows the side to move; the text says whose it is. status = live.turn === 'w' ? 'white' : 'black'; const side = live.turn === 'w' ? 'White' : 'Black'; label = live.turn === playerColor() ? 'Your move' : 'Computer'; title = `${side} to move: ${live.turn === playerColor() ? 'you' : 'the computer'}`; } dot.dataset.status = status; text.textContent = label; $('turn-strip').title = title || label; } // Draw needs a game in progress with the board free; resigning only needs // an unfinished game (also while the computer thinks or the engine is stuck). function updateGameControls() { const hasGame = !!live.gameId; const idle = !live.isLocked && !live.actionBusy; $('undo-btn').disabled = !hasGame || live.isLocked || live.moves.length < 2; $('draw-btn').disabled = !hasGame || !idle || live.state !== 'ongoing'; $('resign-btn').disabled = !hasGame || live.actionBusy || isGameOver(live.state); updateLiveTools(); } function updateLiveTools() { const hasGame = !!live.gameId; $('copy-fen-btn').disabled = !hasGame; $('copy-pgn-btn').disabled = !hasGame; $('replay-live-btn').disabled = !hasGame || ui.storage !== 'ok'; $('replay-live-btn').title = ui.storage === 'ok' ? 'Replay this game' : 'Replay needs stored games (storage is off)'; } async function copyLiveFen() { if (!live.fen) return; if (await copyText(live.fen)) { markDone($('copy-fen-btn')); toast('FEN copied', 'success'); } else { flashErrorMessage('Copy failed'); } } // Copies the stored game's PGN; without storage, the SAN (or UCI) line and // the current FEN as a plain-text fallback. async function copyLivePgn() { if (!live.gameId) return; let text = null; try { const response = await authFetch(`${API}/api/games/${live.gameId}/pgn`); if (response.ok) text = await response.text(); } catch { // fallback below } if (!text) text = localPgnText(); if (await copyText(text)) { markDone($('copy-pgn-btn')); toast('PGN copied', 'success'); } else { flashErrorMessage('Copy failed'); } } function localPgnText() { const startsWithBlack = startingFenOfLive().split(' ')[1] === 'b'; let number = parseInt(startingFenOfLive().split(' ')[5], 10) || 1; const parts = []; live.moves.forEach((uci, i) => { const text = live.san[i] || uci; const whiteMove = startsWithBlack ? i % 2 === 1 : i % 2 === 0; if (i === 0 && startsWithBlack) parts.push(`${number}...`); else if (whiteMove) parts.push(`${number}.`); parts.push(text); if (!whiteMove) number++; }); let pgn = parts.join(' '); if (live.fen) pgn += `\n\n[FEN "${live.fen}"]`; return pgn.trim(); } // --------------------------------------------- recent games on this device // Games created without an account are listed from localStorage, since the // server lists stored games per account only. The server deletes them 24 // hours after their last move, so entries expire on the same schedule. function loadRecentGames() { let list = []; try { list = JSON.parse(localStorage.getItem(RECENT_GAMES_KEY) || '[]'); } catch { list = []; } if (!Array.isArray(list)) list = []; const cutoff = Date.now() - RECENT_GAMES_TTL_MS; return list.filter(g => g && typeof g.gameId === 'string' && (g.lastActivity || 0) > cutoff); } function saveRecentGames(list) { try { localStorage.setItem(RECENT_GAMES_KEY, JSON.stringify(list.slice(0, RECENT_GAMES_MAX))); } catch { // storage blocked or full: the list is a convenience only } } function recordRecentGame(game) { const list = loadRecentGames(); const existing = list.find(g => g.gameId === game.gameId); const now = Date.now(); const entry = { gameId: game.gameId, startedAt: existing?.startedAt || now, lastActivity: now, initialFen: existing?.initialFen || startingFenOfLive(), playerColor: live.isPlayerWhite ? 'w' : 'b', level: live.level, moveCount: (game.moves || []).length, finalFen: game.fen, state: game.state, termination: game.termination || '', }; saveRecentGames([entry, ...list.filter(g => g.gameId !== game.gameId)]); } // A local entry in the shape of a stored-game summary, for the shared row. function recentToSummary(g) { const you = { type: 1, name: 'You' }; const engine = { type: 2, level: g.level }; const result = isGameOver(g.state) ? g.state.replace(' ', '_') : ''; return { gameId: g.gameId, initialFen: g.initialFen, finalFen: g.finalFen, result, pgnResult: pgnResultOf(result), termination: g.termination, startTimeUtc: new Date(g.startedAt).toISOString(), moveCount: g.moveCount, players: { white: g.playerColor === 'w' ? you : engine, black: g.playerColor === 'b' ? you : engine, }, local: true, }; } function pgnResultOf(result) { switch (result) { case 'white_wins': return '1-0'; case 'black_wins': return '0-1'; case 'draw': case 'stalemate': return '1/2-1/2'; } return '*'; } // ------------------------------------------------------- My games drawer function openGames({ push = true } = {}) { if (games.open) return; games.open = true; $('games-overlay').classList.add('show'); document.addEventListener('keydown', handleGamesKeydown); renderGamesSections(); if (auth.token) loadGames(true, games.loadedFor === auth.userId); if (push) pushURL(); $('games-close').focus(); } function closeGames({ push = true } = {}) { if (!games.open) return; games.open = false; $('games-overlay').classList.remove('show'); document.removeEventListener('keydown', handleGamesKeydown); if (push) pushURL(); $('games-btn').focus(); } function handleGamesKeydown(e) { if (e.key === 'Escape') { e.preventDefault(); closeGames(); } } // The account changed while the drawer is open: reload for the new user, or // fall back to the device list. function refreshGamesForUser() { games.items = []; games.cursor = ''; games.loadedFor = null; renderGamesSections(); if (auth.token) loadGames(true); } function renderGamesSections() { const signedIn = !!auth.token; $('games-signin-note').classList.toggle('hidden', signedIn); $('games-filters').classList.toggle('hidden', !signedIn); $('account-games-heading').textContent = 'Your games'; $('games-list').classList.toggle('hidden', !signedIn); $('games-import-btn').classList.toggle('hidden', !signedIn); $('games-more').classList.toggle('hidden', !signedIn || !games.cursor); if (!signedIn) $('games-status').textContent = ''; renderLocalGames(); } function setGamesFilter(kind, value) { document.querySelectorAll(`.seg[data-filter="${kind}"]`).forEach(b => { b.classList.toggle('active', b.dataset.value === value); }); games[kind] = value; loadGames(true); } // Loads the first page (reset) or the next one. keep leaves the current // rows in place until the fresh page arrives, for a reopened drawer. async function loadGames(reset, keep = false) { if (!auth.token || games.loading) return; if (reset) { games.cursor = ''; if (!keep) { games.items = []; renderGamesList(); } } games.loading = true; games.loadedFor = auth.userId; const status = $('games-status'); status.classList.remove('error'); status.textContent = 'Loading…'; $('games-more').disabled = true; const query = new URLSearchParams({ limit: String(GAMES_PAGE_SIZE) }); if (games.status) query.set('status', games.status); if (games.color) query.set('color', games.color); if (!reset && games.cursor) query.set('cursor', games.cursor); let response; try { response = await authFetch(`${API}/api/users/me/games?${query}`); } catch { games.loading = false; status.classList.add('error'); status.textContent = 'Connection failed. '; appendRetry(status, () => loadGames(reset)); $('games-more').disabled = false; return; } games.loading = false; $('games-more').disabled = false; if (response.status === 401) { clearAuthState(); return; } if (!response.ok) { const info = handleApiError('games', null, response); status.classList.add('error'); status.textContent = `${info.statusMessage}. `; appendRetry(status, () => loadGames(reset)); return; } let page; try { page = await response.json(); } catch { status.classList.add('error'); status.textContent = 'Unexpected response from server'; return; } games.items = reset ? (page.games || []) : games.items.concat(page.games || []); games.cursor = page.nextCursor || ''; status.textContent = ''; renderGamesList(); } function appendRetry(status, action) { const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'btn btn-ghost btn-sm'; retry.textContent = 'Retry'; retry.addEventListener('click', action); status.appendChild(retry); } function renderGamesList() { const list = $('games-list'); list.innerHTML = ''; for (const item of games.items) list.appendChild(renderGameRow(item)); const status = $('games-status'); if (!games.loading && games.items.length === 0 && auth.token) { status.classList.remove('error'); status.textContent = games.status || games.color ? 'No games match these filters.' : 'No stored games yet. Finish a move in a new game and it will appear here.'; } $('games-more').classList.toggle('hidden', !games.cursor); } function renderLocalGames() { const entries = loadRecentGames(); const section = $('local-games-section'); section.classList.toggle('hidden', entries.length === 0); const list = $('local-games-list'); list.innerHTML = ''; for (const entry of entries) list.appendChild(renderGameRow(recentToSummary(entry))); } // One game in the list: thumbnail, players, outcome, date, and a replay // affordance. The whole row is a button. function renderGameRow(summary) { const li = document.createElement('li'); li.className = 'game-item'; const row = document.createElement('button'); row.type = 'button'; row.className = 'game-row'; if (summary.gameId === live.gameId) row.classList.add('current'); row.dataset.gameId = summary.gameId; const mini = document.createElement('div'); mini.className = 'mini-board'; mini.setAttribute('aria-hidden', 'true'); renderMiniBoard(mini, summary.finalFen); const info = document.createElement('div'); info.className = 'game-info'; const players = document.createElement('div'); players.className = 'game-players'; players.textContent = `${sideLabel(summary.players?.white)} vs ${sideLabel(summary.players?.black)}`; const outcome = document.createElement('div'); outcome.className = 'game-outcome'; const token = document.createElement('span'); token.className = 'pgn-result'; const ongoing = !summary.result; token.textContent = ongoing ? '*' : (summary.pgnResult || pgnResultOf(summary.result)); outcome.appendChild(token); outcome.appendChild(document.createTextNode(' ')); outcome.classList.toggle('ongoing', ongoing); const moves = Math.ceil((summary.moveCount || 0) / 2); outcome.appendChild(document.createTextNode(ongoing ? `Ongoing · ${moves} move${moves === 1 ? '' : 's'}` : `${terminationLabel(summary.termination) || describeResult(summary.result, summary.termination)} · ${moves} move${moves === 1 ? '' : 's'}`)); const when = document.createElement('div'); when.className = 'game-when'; const notes = [formatDate(summary.startTimeUtc)]; if (summary.origin === 'imported') { const origin = document.createElement('span'); origin.className = 'game-origin'; origin.textContent = summary.event ? `imported · ${summary.event}` : 'imported'; when.append(document.createTextNode(notes[0] + ' · '), origin); } else { if (summary.gameId === live.gameId) notes.push('this game'); when.textContent = notes.join(' · '); } info.append(players, outcome, when); const go = document.createElement('span'); go.className = 'game-go'; go.setAttribute('aria-hidden', 'true'); go.innerHTML = ''; row.append(mini, info, go); row.setAttribute('aria-label', `Replay ${players.textContent}, ${outcome.textContent}, ${when.textContent}`); row.addEventListener('click', () => enterReplay(summary.gameId, summary.moveCount || 0)); li.appendChild(row); // Delete, with an in-row confirmation. const del = document.createElement('button'); del.type = 'button'; del.className = 'btn game-delete'; del.title = summary.local ? 'Forget this game on this device' : 'Delete this game'; del.setAttribute('aria-label', `${del.title}: ${players.textContent}`); del.innerHTML = ''; del.addEventListener('click', () => confirmDeleteRow(li, del, summary)); li.appendChild(del); return li; } // Replaces the row's delete button with Yes/No for one decision. function confirmDeleteRow(li, del, summary) { const confirm = document.createElement('div'); confirm.className = 'game-confirm'; confirm.setAttribute('role', 'group'); confirm.setAttribute('aria-label', 'Confirm deletion'); const yes = document.createElement('button'); yes.type = 'button'; yes.className = 'btn btn-danger'; yes.textContent = summary.local ? 'Forget' : 'Delete'; const no = document.createElement('button'); no.type = 'button'; no.className = 'btn btn-secondary'; no.textContent = 'Keep'; const restore = () => { confirm.replaceWith(del); del.focus(); }; no.addEventListener('click', restore); yes.addEventListener('click', async () => { yes.disabled = true; no.disabled = true; const ok = await deleteGame(summary); if (!ok) restore(); }); confirm.append(yes, no); del.replaceWith(confirm); yes.focus(); } // Deletes a game from the account list, or forgets a device entry. Returns // whether the row is gone. async function deleteGame(summary) { if (summary.local) { saveRecentGames(loadRecentGames().filter(g => g.gameId !== summary.gameId)); renderLocalGames(); toast('Game forgotten on this device', 'success'); return true; } let response; try { response = await apiJSON('DELETE', `/api/users/me/games/${summary.gameId}`); } catch (error) { handleApiError('delete game', error); return false; } if (response.status === 401) { clearAuthState(); return false; } if (!response.ok) { handleApiError('delete game', null, response); return false; } let outcome = 'game'; try { outcome = (await response.json()).removed || 'game'; } catch { /* the row is gone either way */ } games.items = games.items.filter(g => g.gameId !== summary.gameId); renderGamesList(); toast(outcome === 'claim' ? 'Removed from your games; the other player keeps it' : 'Game deleted', 'success', 2500); if (replay.gameId === summary.gameId && outcome === 'game') exitReplay({ push: false }); if (live.gameId === summary.gameId && outcome === 'game') dropLiveGame(); ui.historyCache.delete(summary.gameId); return true; } // The live game was deleted from storage: the board returns to no game. function dropLiveGame() { stopPolling(); live.gameId = null; live.fen = null; live.state = null; live.moves = []; live.san = []; live.lastMove = null; live.isLocked = false; live.selectedSquare = null; if (!replay.active) renderPosition(START_FEN); renderLiveMoves(); updateTurnStrip(); updateGameControls(); } // --- import: a PGN pasted or picked from a file, stored under the account const importState = { busy: false }; function openImportModal() { if (!auth.token) { showAuthModal(); return; } importState.busy = false; $('import-pgn').value = ''; $('import-file').value = ''; setModalHint('import-modal-message', 'One game: tags in brackets, then the moves. Comments and variations are dropped; every move is checked.'); $('import-overlay').classList.add('show'); document.removeEventListener('keydown', handleImportKeydown); document.addEventListener('keydown', handleImportKeydown); $('import-pgn').focus(); } function closeImportModal() { if (importState.busy) return; $('import-overlay').classList.remove('show'); document.removeEventListener('keydown', handleImportKeydown); } function handleImportKeydown(e) { if (e.key === 'Escape') { e.preventDefault(); closeImportModal(); } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submitImport(); } } function setImportBusy(busy) { importState.busy = busy; for (const id of ['import-pgn', 'import-file', 'import-submit-btn', 'import-cancel-btn']) $(id).disabled = busy; } // A picked file fills the text box; the size limit is the server's. function readImportFile() { const file = $('import-file').files?.[0]; if (!file) return; if (file.size > 65536) { setModalMessage('import-modal-message', 'That file is larger than 64 KiB; one game is a few kilobytes', 'error'); return; } const reader = new FileReader(); reader.onload = () => { $('import-pgn').value = String(reader.result || ''); setModalHint('import-modal-message', `${file.name} loaded; press Import.`); }; reader.onerror = () => setModalMessage('import-modal-message', 'Could not read the file', 'error'); reader.readAsText(file); } async function submitImport() { if (importState.busy) return; const pgn = $('import-pgn').value.trim(); if (!pgn) { setModalMessage('import-modal-message', 'Paste a PGN or pick a file', 'error'); $('import-pgn').focus(); return; } setImportBusy(true); clearModalMessage('import-modal-message'); let response; try { response = await apiJSON('POST', '/api/games/import', { pgn }); } catch (error) { setImportBusy(false); setModalMessage('import-modal-message', handleApiError('import', error).statusMessage, 'error'); return; } setImportBusy(false); if (response.status === 401) { clearAuthState(); setModalMessage('import-modal-message', 'Sign in again to import', 'error'); return; } if (!response.ok) { const err = await parseErrorResponse(response); setModalMessage('import-modal-message', response.status === 400 ? `Not a valid game: ${err.details || err.error}` : response.status === 429 ? 'Too many imports; wait a minute' : (err.error || `Server error (${response.status})`), 'error'); $('import-pgn').focus(); return; } let result; try { result = await response.json(); } catch { setModalMessage('import-modal-message', 'Unexpected response from server', 'error'); return; } closeImportModal(); toast(`Imported, ${Math.ceil((result.moveCount || 0) / 2)} moves`, 'success', 2500); games.cursor = ''; await loadGames(true, true); enterReplay(result.gameId, 0); } // A side's name in the list, "You" for the signed-in user's claim. function sideLabel(player) { if (player && player.type !== 2 && auth.userId && player.claimedBy === auth.userId) return 'You'; return playerLabel(player); } // --------------------------------------------------------------- replay function isUUID(s) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s || ''); } // Fetches a stored game's history, revalidating a cached copy with its ETag. async function fetchHistory(gameId) { const cached = ui.historyCache.get(gameId); const headers = {}; if (cached?.etag) headers['If-None-Match'] = cached.etag; const response = await authFetch(`${API}/api/games/${gameId}/history`, { headers }); if (response.status === 304 && cached) return cached.history; if (!response.ok) { const info = handleApiError('history', null, response); throw new Error(info.statusMessage); } const history = await response.json(); ui.historyCache.set(gameId, { etag: response.headers.get('ETag'), history }); return history; } // Opens a stored game at a ply. The live game is suspended: its polling // stops and its controls hide until exitReplay. async function enterReplay(gameId, ply = 0, { push = true } = {}) { if (!isUUID(gameId)) { flashErrorMessage('Invalid game ID'); return; } stopAutoplay(); if (!replay.active) { stopPolling(); replay.active = true; document.body.dataset.mode = 'replay'; for (const s of board.squares.values()) s.classList.remove('selected'); live.selectedSquare = null; } if (games.open) closeGames({ push: false }); replay.loading = gameId; replay.gameId = gameId; replay.history = null; replay.ply = 0; renderReplayLoading(); if (push) pushURL(); else replaceURL(); let history; try { history = await fetchHistory(gameId); } catch (error) { if (replay.loading !== gameId) return; // superseded replay.loading = null; renderReplayError(error.message || 'Could not load the game'); return; } if (replay.loading !== gameId || !replay.active) return; replay.loading = null; replay.history = history; replay.flipped = defaultFlip(history); buildBoard(replay.flipped); $('replay-flip').setAttribute('aria-pressed', replay.flipped ? 'true' : 'false'); renderReplayHead(); renderReplayMoves(); gotoPly(ply, { announce: false }); } // Black at the bottom when the viewer claimed Black, or when Black is the // only human side (a computer took White). function defaultFlip(history) { const white = history.players?.white, black = history.players?.black; if (auth.userId) { if (black?.claimedBy === auth.userId) return true; if (white?.claimedBy === auth.userId) return false; } if (history.gameId === live.gameId) return !live.isPlayerWhite; return white?.type === 2 && black?.type !== 2; } // The exit button's text, its short form for a phone's row, and its // accessible name for the row that shows the icon alone. function setReplayExitLabel(text, short) { $('replay-exit-label').textContent = text; $('replay-exit-short').textContent = short; $('replay-exit').setAttribute('aria-label', text); } function exitReplay({ push = true } = {}) { if (!replay.active) return; stopAutoplay(); replay.active = false; replay.loading = null; replay.gameId = null; replay.history = null; replay.ply = 0; document.body.dataset.mode = 'live'; buildBoard(!live.isPlayerWhite); if (live.gameId) renderLive(); else renderPosition(START_FEN); $('ply-announcer').textContent = ''; if (push) pushURL(); // A computer move may have completed meanwhile. if (live.gameId && live.isLocked) startPolling(); updateGameControls(); } function renderReplayLoading() { $('replay-score').textContent = ''; $('replay-term').textContent = ''; $('replay-ply').textContent = ''; $('replay-players').textContent = 'Loading game…'; $('replay-meta').textContent = replay.gameId || ''; const grid = $('replay-moves'); grid.innerHTML = ''; $('replay-moves-empty').textContent = 'Loading…'; setReplayNavEnabled(false); setReplayExitLabel(live.gameId ? 'Back to live game' : 'Close replay', live.gameId ? 'Live' : 'Close'); } function renderReplayError(message) { $('replay-players').textContent = message; $('replay-meta').textContent = replay.gameId || ''; $('replay-moves-empty').textContent = 'The stored game could not be loaded. It may have been deleted, or storage is off.'; renderPosition(START_FEN); setReplayNavEnabled(false); } function setReplayNavEnabled(enabled) { for (const id of ['replay-first', 'replay-prev', 'replay-play', 'replay-next', 'replay-last', 'replay-flip', 'replay-copy-fen', 'replay-copy-pgn', 'replay-download', 'replay-link', 'replay-speed']) { $(id).disabled = !enabled; } } function renderReplayHead() { const h = replay.history; const finished = !!h.result; // Score and termination apart: a large-font phone shows the score alone. $('replay-score').textContent = finished ? h.pgnResult : 'Ongoing'; $('replay-term').textContent = finished ? ` · ${terminationLabel(h.termination) || describeResult(h.result, h.termination)}` : ''; $('replay-players').textContent = `${sideLabel(h.players?.white)} vs ${sideLabel(h.players?.black)}`; $('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`; const plies = h.moves.length; const meta = [formatDate(h.startTimeUtc), `${plies} ${plies === 1 ? 'ply' : 'plies'}`]; if (h.origin === 'imported') meta.unshift(h.event || 'imported'); if (h.concession && h.concession.termination) { meta.push(h.concession.termination === 'agreement' ? `draw agreed at ply ${h.concession.ply}` : `resignation at ply ${h.concession.ply}`); } $('replay-meta').textContent = meta.join(' · '); setReplayExitLabel(live.gameId ? 'Back to live game' : 'Close replay', live.gameId ? 'Live' : 'Close'); setReplayNavEnabled(true); } function renderReplayMoves() { const h = replay.history; const grid = $('replay-moves'); const startsWithBlack = setGridStart(grid, h.initialFen); const texts = h.moves.map(m => m.san || m.moveUci); renderMoveGrid(grid, texts, startsWithBlack, replay.ply, ply => { stopAutoplay(); gotoPly(ply); }); $('replay-moves-empty').textContent = 'No moves were played.'; } // Shows the position after n plies; the stored FEN is assigned as is. function gotoPly(n, { announce = true } = {}) { const h = replay.history; if (!h) return; const last = h.moves.length; n = Math.max(0, Math.min(last, Number.isFinite(n) ? n : 0)); replay.ply = n; const fen = n === 0 ? h.initialFen : h.moves[n - 1].fenAfterMove; const lastMove = n > 0 ? h.moves[n - 1].moveUci : null; let matedColor = null; if (n === last && h.termination === 'checkmate') { matedColor = h.result === 'white_wins' ? 'b' : 'w'; } renderPosition(fen, { lastMove, matedColor }); const grid = $('replay-moves'); grid.querySelectorAll('[aria-current]').forEach(el => el.removeAttribute('aria-current')); const current = grid.querySelector(`[data-ply="${n}"]`); if (current) current.setAttribute('aria-current', 'true'); scrollMoveIntoView(grid, n); $('replay-ply').textContent = `${n} / ${last}`; $('replay-first').disabled = n === 0; $('replay-prev').disabled = n === 0; $('replay-next').disabled = n === last; $('replay-last').disabled = n === last; $('replay-play').disabled = last === 0; if (announce) $('ply-announcer').textContent = plyLabel(n); replaceURL(); } function plyLabel(n) { const h = replay.history; if (!h || n === 0) return 'Start position'; const move = h.moves[n - 1]; const startsWithBlack = (h.initialFen || START_FEN).split(' ')[1] === 'b'; const first = parseInt((h.initialFen || START_FEN).split(' ')[5], 10) || 1; const number = first + Math.floor((n - 1 + (startsWithBlack ? 1 : 0)) / 2); const dots = move.playerColor === 'b' ? '…' : '.'; return `Ply ${n} of ${h.moves.length}: ${number}${dots} ${move.san || move.moveUci}`; } function stepReplay(delta) { if (!replay.history) return; gotoPly(replay.ply + delta); } function toggleAutoplay() { if (replay.timer) { stopAutoplay(); return; } if (!replay.history || replay.history.moves.length === 0) return; if (replay.ply >= replay.history.moves.length) gotoPly(0); $('replay-play').setAttribute('aria-pressed', 'true'); $('replay-play').setAttribute('aria-label', 'Pause'); scheduleAutoplay(); } function scheduleAutoplay() { replay.timer = setTimeout(() => { replay.timer = null; if (!replay.active || !replay.history) return; gotoPly(replay.ply + 1); if (replay.ply >= replay.history.moves.length) { stopAutoplay(); return; } scheduleAutoplay(); }, replay.speedMs); } function stopAutoplay() { if (replay.timer) { clearTimeout(replay.timer); replay.timer = null; } $('replay-play').setAttribute('aria-pressed', 'false'); $('replay-play').setAttribute('aria-label', 'Autoplay'); } function flipReplayBoard() { if (!replay.history) return; replay.flipped = !replay.flipped; $('replay-flip').setAttribute('aria-pressed', replay.flipped ? 'true' : 'false'); buildBoard(replay.flipped); gotoPly(replay.ply, { announce: false }); } function replayFen() { const h = replay.history; return replay.ply === 0 ? h.initialFen : h.moves[replay.ply - 1].fenAfterMove; } function replayPgnPath() { const h = replay.history; const full = replay.ply === h.moves.length; return `/api/games/${replay.gameId}/pgn${full ? '' : `?ply=${replay.ply}`}`; } async function copyReplayFen() { if (!replay.history) return; if (await copyText(replayFen())) { markDone($('replay-copy-fen')); toast('FEN copied', 'success'); } else { flashErrorMessage('Copy failed'); } } async function fetchReplayPgn() { const response = await authFetch(`${API}${replayPgnPath()}`); if (!response.ok) { handleApiError('pgn', null, response); return null; } const disposition = response.headers.get('Content-Disposition') || ''; const match = /filename="([^"]+)"/.exec(disposition); return { text: await response.text(), filename: match ? match[1] : `chess-${replay.gameId.slice(0, 8)}.pgn` }; } async function copyReplayPgn() { if (!replay.history) return; let pgn; try { pgn = await fetchReplayPgn(); } catch (error) { handleApiError('pgn', error); return; } if (!pgn) return; if (await copyText(pgn.text)) { markDone($('replay-copy-pgn')); toast('PGN copied', 'success'); } else { flashErrorMessage('Copy failed'); } } // Saves the PGN through a temporary link to a blob: URL. In a sandboxed frame // this needs the allow-downloads flag on the host's iframe. async function downloadReplayPgn() { if (!replay.history) return; let pgn; try { pgn = await fetchReplayPgn(); } catch (error) { handleApiError('pgn', error); return; } if (!pgn) return; const blob = new Blob([pgn.text], { type: 'application/x-chess-pgn' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = pgn.filename; a.className = 'visually-hidden'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000); markDone($('replay-download')); toast(`Saved ${pgn.filename}`, 'success'); } async function copyReplayLink() { if (!replay.gameId) return; const link = `${location.origin}${location.pathname}?replay=${replay.gameId}&ply=${replay.ply}`; if (await copyText(link)) { markDone($('replay-link')); toast('Link copied', 'success'); } else { flashErrorMessage('Copy failed'); } } // -------------------------------------------------------------- routing // The URL names the view: ?replay=&ply= for a replay, with &games // while the drawer is open. Views push history entries; stepping through a // replay replaces the entry, so back and forward move between views. function urlForState() { const parts = []; if (replay.active && replay.gameId) { parts.push(`replay=${encodeURIComponent(replay.gameId)}`, `ply=${replay.ply}`); } if (games.open) parts.push('games'); return location.pathname + (parts.length ? `?${parts.join('&')}` : ''); } function pushURL() { try { if (urlForState() !== location.pathname + location.search) history.pushState({ chess: true }, '', urlForState()); } catch { /* history blocked */ } } function replaceURL() { try { history.replaceState({ chess: true }, '', urlForState()); } catch { /* history blocked */ } } // Brings the page to what the URL names, without touching history. async function applyURL() { const params = new URLSearchParams(location.search); const id = params.get('replay'); const ply = parseInt(params.get('ply') || '0', 10); const wantGames = params.has('games'); if (id && isUUID(id)) { if (!replay.active || replay.gameId !== id) { await enterReplay(id, ply, { push: false }); } else if (replay.history) { stopAutoplay(); gotoPly(ply, { announce: false }); } } else if (replay.active) { exitReplay({ push: false }); } if (wantGames && !games.open) openGames({ push: false }); else if (!wantGames && games.open) closeGames({ push: false }); } // ------------------------------------------------------------- keyboard function handleGlobalKeydown(e) { if (anyModalOpen()) return; const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (!replay.active || !replay.history) return; switch (e.key) { case 'ArrowLeft': e.preventDefault(); stopAutoplay(); stepReplay(-1); break; case 'ArrowRight': e.preventDefault(); stopAutoplay(); stepReplay(1); break; case 'Home': e.preventDefault(); stopAutoplay(); gotoPly(0); break; case 'End': e.preventDefault(); stopAutoplay(); gotoPly(replay.history.moves.length); break; case ' ': e.preventDefault(); toggleAutoplay(); break; case 'f': case 'F': e.preventDefault(); flipReplayBoard(); break; case 'Escape': e.preventDefault(); exitReplay(); break; } } // ----------------------------------------------------------------- init function bindEvents() { $('new-game-btn').addEventListener('click', showNewGameModal); $('undo-btn').addEventListener('click', undoMoves); $('draw-btn').addEventListener('click', offerDraw); $('resign-btn').addEventListener('click', showResignModal); $('resign-confirm-btn').addEventListener('click', resignGame); $('resign-cancel-btn').addEventListener('click', hideResignModal); $('start-game-btn').addEventListener('click', startNewGame); $('cancel-btn').addEventListener('click', hideNewGameModal); $('copy-fen-btn').addEventListener('click', copyLiveFen); $('copy-pgn-btn').addEventListener('click', copyLivePgn); $('replay-live-btn').addEventListener('click', () => { if (live.gameId) enterReplay(live.gameId, live.moves.length); }); const levelSlider = $('computer-level'); levelSlider.addEventListener('input', () => { $('level-value').textContent = levelSlider.value; }); const timeSlider = $('search-time'); timeSlider.addEventListener('input', () => { $('time-value').textContent = timeSlider.value; }); $('auth-btn').addEventListener('click', handleAuthClick); document.querySelectorAll('.auth-tab').forEach(tab => { tab.addEventListener('click', () => switchAuthTab(tab.dataset.tab)); }); $('login-submit-btn').addEventListener('click', handleLogin); $('register-submit-btn').addEventListener('click', handleRegister); $('forgot-btn').addEventListener('click', () => switchAuthTab('recover')); $('recover-back-btn').addEventListener('click', () => switchAuthTab('login')); $('recover-submit-btn').addEventListener('click', handleRecover); $('reset-submit-btn').addEventListener('click', submitReset); $('reset-cancel-btn').addEventListener('click', closeResetModal); $('auth-cancel-btn').addEventListener('click', hideAuthModal); $('auth-cancel-btn-2').addEventListener('click', hideAuthModal); $('logout-btn').addEventListener('click', handleLogoutClick); $('account-cancel-btn').addEventListener('click', hideAccountModal); $('delete-account-btn').addEventListener('click', deleteAccount); document.querySelectorAll('.promotion-choice').forEach(btn => { btn.addEventListener('click', () => closePromotion(btn.dataset.piece)); }); $('promotion-cancel-btn').addEventListener('click', () => closePromotion(null)); $('promotion-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closePromotion(null); }); // A click on a backdrop closes the dialog, unless a request is in flight. const backdrop = (id, close) => $(id).addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); }); backdrop('auth-modal-overlay', () => { if (!auth.busy) hideAuthModal(); }); backdrop('account-overlay', hideAccountModal); backdrop('modal-overlay', () => { if (!live.newGameBusy) hideNewGameModal(); }); backdrop('resign-overlay', hideResignModal); backdrop('reset-overlay', closeResetModal); backdrop('import-overlay', closeImportModal); $('games-import-btn').addEventListener('click', openImportModal); $('import-submit-btn').addEventListener('click', submitImport); $('import-cancel-btn').addEventListener('click', closeImportModal); $('import-file').addEventListener('change', readImportFile); $('games-btn').addEventListener('click', () => { if (games.open) closeGames(); else openGames(); }); $('games-close').addEventListener('click', () => closeGames()); $('games-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeGames(); }); $('games-more').addEventListener('click', () => loadGames(false)); document.querySelectorAll('.seg').forEach(seg => { seg.addEventListener('click', () => setGamesFilter(seg.dataset.filter, seg.dataset.value)); }); $('replay-first').addEventListener('click', () => { stopAutoplay(); gotoPly(0); }); $('replay-prev').addEventListener('click', () => { stopAutoplay(); stepReplay(-1); }); $('replay-next').addEventListener('click', () => { stopAutoplay(); stepReplay(1); }); $('replay-last').addEventListener('click', () => { stopAutoplay(); gotoPly(replay.history?.moves.length || 0); }); $('replay-play').addEventListener('click', toggleAutoplay); $('replay-speed').addEventListener('change', () => { replay.speedMs = parseInt($('replay-speed').value, 10) || 1000; if (replay.timer) { clearTimeout(replay.timer); replay.timer = null; scheduleAutoplay(); } }); $('replay-flip').addEventListener('click', flipReplayBoard); $('replay-copy-fen').addEventListener('click', copyReplayFen); $('replay-copy-pgn').addEventListener('click', copyReplayPgn); $('replay-download').addEventListener('click', downloadReplayPgn); $('replay-link').addEventListener('click', copyReplayLink); $('replay-exit').addEventListener('click', () => exitReplay()); document.addEventListener('keydown', handleGlobalKeydown); window.addEventListener('popstate', () => { applyURL(); }); } document.addEventListener('DOMContentLoaded', async () => { bindEvents(); buildBoard(false); renderPosition(START_FEN); updateTurnStrip(); updateGameControls(); startHealthCheck(); // A reset link: take the token off the address before anything else, so // it is neither kept in history nor shared with a copied link. const params = new URLSearchParams(location.search); const resetToken = params.get('reset'); if (resetToken !== null) { params.delete('reset'); const query = params.toString(); try { history.replaceState({ chess: true }, '', location.pathname + (query ? `?${query}` : '')); } catch { /* history blocked */ } } await restoreAuthSession(); await applyURL(); if (resetToken) openResetModal(resetToken); });