/* ═══════════════════════════════════════════════════ METROPOLIS CHESS CLUB — Interactive Board Client ═══════════════════════════════════════════════════ */ const socket = io(); // ── Piece image map ────────────────────────────────────────────────────────── const PIECE_IMG = { K:'/static/pieces/wK.svg', Q:'/static/pieces/wQ.svg', R:'/static/pieces/wR.svg', B:'/static/pieces/wB.svg', N:'/static/pieces/wN.svg', P:'/static/pieces/wP.svg', k:'/static/pieces/bK.svg', q:'/static/pieces/bQ.svg', r:'/static/pieces/bR.svg', b:'/static/pieces/bB.svg', n:'/static/pieces/bN.svg', p:'/static/pieces/bP.svg', }; const FILES = 'abcdefgh'; const RANKS = '87654321'; // index 0 = rank 8 (top when unflipped) // Piece sort order for captured pieces display (high value first) const CAPTURED_ORDER_WHITE = ['Q','R','B','N','P']; const CAPTURED_ORDER_BLACK = ['q','r','b','n','p']; const STARTING_COUNTS = {K:1,Q:1,R:2,B:2,N:2,P:8,k:1,q:1,r:2,b:2,n:2,p:8}; // ── App state ──────────────────────────────────────────────────────────────── const G = { mode: 'vs_viktor', depth: 3, playerName: '', player2Name: 'Black', fen: null, legalMovesUci: [], pieces: {}, selectedSq: null, lastMove: null, isCheck: false, currentPlayerSide:'white', boardFlipped: false, isPlayerTurn: true, gameOver: false, waitingMove: false, startTime: null, moveCount: 0, msgCount: 0, }; // ── Drag state ─────────────────────────────────────────────────────────────── let potentialDrag = null; // { sq, x, y } — awaiting threshold let drag = null; // { sq, ghost } — dragging in progress /* ════════════════════════════════════════ SCREEN MANAGEMENT ════════════════════════════════════════ */ function showScreen(id) { document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); document.getElementById(id).classList.add('active'); document.getElementById('mute-btn-global').classList.toggle('in-game', id === 'game-screen'); } /* ════════════════════════════════════════ SETUP SCREEN ════════════════════════════════════════ */ document.querySelectorAll('.mode-btn:not([disabled])').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); G.mode = btn.dataset.mode; const isViktor = G.mode === 'vs_viktor'; document.getElementById('difficulty-row').style.display = isViktor ? '' : 'none'; const p2 = document.getElementById('player2-name'); const p2lbl = document.getElementById('player2-label'); if (G.mode === 'vs_human') { p2.style.display = p2lbl.style.display = ''; document.getElementById('name-label').textContent = 'White player name'; document.getElementById('player-name').placeholder = 'White'; document.getElementById('viktor-quote').style.display = 'none'; } else { p2.style.display = p2lbl.style.display = 'none'; document.getElementById('name-label').textContent = 'Your name'; document.getElementById('player-name').placeholder = 'Enter your name'; document.getElementById('viktor-quote').style.display = ''; } }); }); document.querySelectorAll('.diff-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.diff-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); G.depth = +btn.dataset.depth; }); }); /* ════════════════════════════════════════ FEN PARSER ════════════════════════════════════════ */ function parseFen(fen) { const pieces = {}; const rows = fen.split(' ')[0].split('/'); for (let ri = 0; ri < 8; ri++) { let fi = 0; for (const ch of rows[ri]) { if (isNaN(ch)) { pieces[FILES[fi] + RANKS[ri]] = ch; fi++; } else fi += +ch; } } return pieces; } /* ════════════════════════════════════════ BOARD RENDERING ════════════════════════════════════════ */ function renderBoard() { const boardEl = document.getElementById('chess-board'); boardEl.innerHTML = ''; // Always render from white's perspective. CSS rotateZ(180deg) on #chess-board // handles the visual flip for black — no DOM reordering needed. const flip = false; const checkKing = G.isCheck ? (G.currentPlayerSide === 'white' ? 'K' : 'k') : null; for (let ri = 0; ri < 8; ri++) { for (let fi = 0; fi < 8; fi++) { const actualRi = flip ? 7 - ri : ri; const actualFi = flip ? 7 - fi : fi; const sq = FILES[actualFi] + RANKS[actualRi]; const isLight = (actualFi + actualRi) % 2 === 0; const piece = G.pieces[sq]; const div = document.createElement('div'); div.className = 'sq ' + (isLight ? 'light' : 'dark'); div.dataset.sq = sq; if (G.lastMove && (sq === G.lastMove.from || sq === G.lastMove.to)) div.classList.add('last-move'); if (sq === G.selectedSq) div.classList.add('selected'); if (checkKing && piece === checkKing) div.classList.add('in-check'); if (G.selectedSq) { const legal = G.legalMovesUci.some( m => m.startsWith(G.selectedSq) && m.slice(2,4) === sq ); if (legal) div.classList.add(piece ? 'legal-cap' : 'legal-empty'); } if (!G.gameOver && !G.waitingMove && isOwnPiece(piece)) div.classList.add('own-piece'); if (drag && drag.sq === sq) div.classList.add('drag-source'); // Rank/file coord labels if (actualFi === 0) { const r = document.createElement('span'); r.className = 'sq-coord rank'; r.textContent = RANKS[actualRi]; div.appendChild(r); } if (actualRi === 7) { const f = document.createElement('span'); f.className = 'sq-coord file'; f.textContent = FILES[actualFi]; div.appendChild(f); } if (piece) { const img = document.createElement('img'); img.className = 'piece'; img.src = PIECE_IMG[piece]; img.alt = piece; img.draggable = false; div.appendChild(img); } boardEl.appendChild(div); } } } /* ════════════════════════════════════════ CAPTURED PIECES ════════════════════════════════════════ */ function computeCaptured(pieces) { const current = {}; for (const p of Object.values(pieces)) current[p] = (current[p] || 0) + 1; const captured = {}; for (const [p, count] of Object.entries(STARTING_COUNTS)) { if (p === 'K' || p === 'k') continue; const diff = count - (current[p] || 0); if (diff > 0) captured[p] = diff; } return captured; } function updateCapturedDisplay() { const captured = computeCaptured(G.pieces); // Top bar shows white pieces captured (taken by black/Viktor) const topEl = document.getElementById('top-captured'); const botEl = document.getElementById('bottom-captured'); if (!topEl || !botEl) return; function renderCaptures(el, order) { el.innerHTML = ''; for (const p of order) { const count = captured[p] || 0; for (let i = 0; i < count; i++) { const img = document.createElement('img'); img.className = 'captured-piece'; img.src = PIECE_IMG[p]; img.alt = p; el.appendChild(img); } } } renderCaptures(topEl, CAPTURED_ORDER_WHITE); // white pieces lost (black captured them) renderCaptures(botEl, CAPTURED_ORDER_BLACK); // black pieces lost (white captured them) } /* ════════════════════════════════════════ MOVE FLASH ════════════════════════════════════════ */ function flashSquare(sq) { const el = document.querySelector(`.sq[data-sq="${sq}"]`); if (!el) return; const overlay = document.createElement('div'); overlay.className = 'flash-overlay'; el.appendChild(overlay); overlay.addEventListener('animationend', () => overlay.remove()); } function flashMove(from, to) { flashSquare(from); flashSquare(to); } function isOwnPiece(piece) { if (!piece) return false; if (G.mode === 'vs_human') return G.currentPlayerSide === 'white' ? piece === piece.toUpperCase() : piece === piece.toLowerCase(); return piece === piece.toUpperCase(); // player is always white vs Viktor } function updateBoard(boardData, animateTo = null) { G.fen = boardData.fen; G.legalMovesUci = boardData.legal_moves_uci || []; G.isCheck = boardData.is_check || false; G.moveCount = boardData.move_count || 0; G.pieces = parseFen(G.fen); G.currentPlayerSide = G.fen.split(' ')[1] === 'w' ? 'white' : 'black'; if (G.mode === 'vs_human') { G.boardFlipped = G.currentPlayerSide === 'black'; // Defer the class toggle one frame so renderBoard() finishes first. // The transition then fires on already-existing elements, not fresh ones, // preventing flicker on re-renders during black's turn. const _bf = G.boardFlipped; requestAnimationFrame(() => document.getElementById('chess-board').classList.toggle('board-black-turn', _bf) ); } if (boardData.last_move) G.lastMove = { from: boardData.last_move.slice(0,2), to: boardData.last_move.slice(2,4) }; if (G.mode === 'vs_viktor') G.isPlayerTurn = G.currentPlayerSide === 'white'; renderBoard(); updateCapturedDisplay(); if (animateTo) { const pieceEl = document.querySelector(`.sq[data-sq="${animateTo}"] .piece`); if (pieceEl) { pieceEl.classList.remove('land'); void pieceEl.offsetWidth; pieceEl.classList.add('land'); } } updateStatus(boardData); updatePlayerBars(boardData); updateMoveLog(boardData.move_history_san || []); document.getElementById('move-counter').textContent = `Move ${G.moveCount}`; document.getElementById('stat-moves').textContent = G.moveCount; document.getElementById('phase-badge').textContent = boardData.phase ? capitalize(boardData.phase) : 'Opening'; } function updateStatus(boardData) { const turnEl = document.getElementById('turn-status'); const checkEl = document.getElementById('check-badge'); if (boardData.is_checkmate) { turnEl.textContent = 'Checkmate'; checkEl.textContent = 'CHECKMATE'; checkEl.className = 'check-badge is-checkmate'; checkEl.style.display = ''; } else if (boardData.is_stalemate) { turnEl.textContent = 'Stalemate — Draw'; checkEl.style.display = 'none'; } else if (boardData.is_check) { turnEl.textContent = `${capitalize(G.currentPlayerSide)} · CHECK!`; checkEl.textContent = 'CHECK'; checkEl.className = 'check-badge'; checkEl.style.display = ''; Audio.playCheck(); } else { checkEl.style.display = 'none'; checkEl.className = 'check-badge'; if (G.waitingMove) { turnEl.textContent = G.mode === 'vs_viktor' ? 'Viktor is thinking...' : 'Processing...'; } else if (G.mode === 'vs_human') { const name = G.currentPlayerSide === 'white' ? G.playerName : G.player2Name; turnEl.textContent = `${name}'s turn`; } else { turnEl.textContent = G.isPlayerTurn ? 'White · Your turn' : 'Black · Viktor'; } } } function updatePlayerBars(boardData) { if (G.mode !== 'vs_human') return; const top = document.getElementById('top-name'); const bottom = document.getElementById('bottom-name'); const topSub = document.getElementById('top-sub'); if (!G.boardFlipped) { top.textContent = G.player2Name; bottom.textContent = G.playerName; topSub.textContent = 'Black'; } else { top.textContent = G.playerName; bottom.textContent = G.player2Name; topSub.textContent = 'White'; } } function updateMoveLog(sanMoves) { const log = document.getElementById('move-log'); log.innerHTML = ''; const recent = 4; sanMoves.forEach((mv, i) => { const pill = document.createElement('span'); pill.className = 'mpill' + (i >= sanMoves.length - recent ? ' recent' : ''); const moveNum = Math.floor(i / 2) + 1; const prefix = i % 2 === 0 ? `${moveNum}.` : ''; pill.textContent = prefix ? `${prefix}${mv}` : mv; log.appendChild(pill); }); log.scrollLeft = log.scrollWidth; } /* ════════════════════════════════════════ SQUARE HIT-DETECTION Uses elementFromPoint so hit boxes match what the user actually sees, even with perspective/rotateX applied to the board. Piece images have pointer-events:none so they're transparent to this. ════════════════════════════════════════ */ function squareAtPoint(clientX, clientY) { const el = document.elementFromPoint(clientX, clientY); if (el) { const sqEl = el.closest('.sq[data-sq]'); if (sqEl) return sqEl.dataset.sq; } // Fallback: linear bounding-rect mapping (handles edge/border area) const boardEl = document.getElementById('chess-board'); if (!boardEl) return null; const rect = boardEl.getBoundingClientRect(); const x = (clientX - rect.left) / rect.width; const y = (clientY - rect.top) / rect.height; if (x < 0 || x > 1 || y < 0 || y > 1) return null; const fi = Math.min(7, Math.floor(x * 8)); const ri = Math.min(7, Math.floor(y * 8)); const actualFi = G.boardFlipped ? 7 - fi : fi; const actualRi = G.boardFlipped ? 7 - ri : ri; return FILES[actualFi] + RANKS[actualRi]; } /* ════════════════════════════════════════ DRAG-AND-DROP ════════════════════════════════════════ */ function startDrag(sq, clientX, clientY) { const piece = G.pieces[sq]; if (!piece) return; G.selectedSq = sq; const ghost = document.createElement('img'); ghost.src = PIECE_IMG[piece]; ghost.className = 'piece-ghost'; ghost.style.left = clientX + 'px'; ghost.style.top = clientY + 'px'; document.body.appendChild(ghost); drag = { sq, ghost }; renderBoard(); } function moveDrag(clientX, clientY) { if (!drag) return; drag.ghost.style.left = clientX + 'px'; drag.ghost.style.top = clientY + 'px'; } function endDrag(clientX, clientY) { if (!drag) return; const targetSq = squareAtPoint(clientX, clientY); drag.ghost.remove(); const sourceSq = drag.sq; drag = null; if (targetSq && targetSq !== sourceSq) { const prefix = sourceSq + targetSq; const matches = G.legalMovesUci.filter(m => m.startsWith(prefix.slice(0,4))); if (matches.length > 0) { G.selectedSq = null; if (isPromotionMove(sourceSq, targetSq)) { showPromoDialog(sourceSq, targetSq, moveUci => executeMove(moveUci)); } else { const move = matches.find(m => m.length === 4) || matches[0]; executeMove(move); } return; } } G.selectedSq = null; renderBoard(); } // ── Mouse event wiring ────────────────────────────────────────────────────── document.getElementById('chess-board').addEventListener('mousedown', e => { if (e.button !== 0 || G.gameOver || G.waitingMove) return; const sq = squareAtPoint(e.clientX, e.clientY); if (!sq) return; const piece = G.pieces[sq]; if (isOwnPiece(piece)) { potentialDrag = { sq, x: e.clientX, y: e.clientY }; e.preventDefault(); } }); document.getElementById('chess-board').addEventListener('touchstart', e => { if (G.gameOver || G.waitingMove) return; const t = e.touches[0]; const sq = squareAtPoint(t.clientX, t.clientY); if (!sq) return; const piece = G.pieces[sq]; if (isOwnPiece(piece)) { potentialDrag = { sq, x: t.clientX, y: t.clientY }; e.preventDefault(); } }, { passive: false }); window.addEventListener('mousemove', e => { if (potentialDrag && !drag) { const dx = e.clientX - potentialDrag.x; const dy = e.clientY - potentialDrag.y; if (dx * dx + dy * dy > 25) { // 5px threshold startDrag(potentialDrag.sq, e.clientX, e.clientY); potentialDrag = null; } return; } moveDrag(e.clientX, e.clientY); }); window.addEventListener('touchmove', e => { const t = e.touches[0]; if (potentialDrag && !drag) { const dx = t.clientX - potentialDrag.x; const dy = t.clientY - potentialDrag.y; if (dx * dx + dy * dy > 25) { startDrag(potentialDrag.sq, t.clientX, t.clientY); potentialDrag = null; } return; } if (drag) { moveDrag(t.clientX, t.clientY); e.preventDefault(); } }, { passive: false }); window.addEventListener('mouseup', e => { if (potentialDrag && !drag) { // Never became a drag — treat as a click handleClick(potentialDrag.sq); potentialDrag = null; return; } if (drag) endDrag(e.clientX, e.clientY); }); window.addEventListener('touchend', e => { const t = e.changedTouches[0]; if (potentialDrag && !drag) { handleClick(potentialDrag.sq); potentialDrag = null; return; } if (drag) endDrag(t.clientX, t.clientY); }); // Click-to-select / click-to-move (shared between 2D and 3D modes) function handleClick(sq) { if (G.gameOver || G.waitingMove) return; const piece = G.pieces[sq]; if (G.selectedSq) { const prefix = G.selectedSq + sq; const matches = G.legalMovesUci.filter(m => m.startsWith(prefix.slice(0,4))); if (matches.length > 0) { const fromSq = G.selectedSq; G.selectedSq = null; if (isPromotionMove(fromSq, sq)) { showPromoDialog(fromSq, sq, moveUci => executeMove(moveUci)); } else { const move = matches.find(m => m.length === 4) || matches[0]; executeMove(move); } } else if (isOwnPiece(piece)) { G.selectedSq = sq; renderBoard(); } else { G.selectedSq = null; renderBoard(); } } else if (isOwnPiece(piece)) { G.selectedSq = sq; renderBoard(); } } /* ════════════════════════════════════════ PROMOTION DIALOG ════════════════════════════════════════ */ function isPromotionMove(fromSq, toSq) { const piece = G.pieces[fromSq]; if (!piece) return false; if (piece === 'P' && toSq[1] === '8') return true; if (piece === 'p' && toSq[1] === '1') return true; return false; } function showPromoDialog(fromSq, toSq, callback) { const overlay = document.getElementById('promo-overlay'); const choices = document.getElementById('promo-choices'); if (!overlay || !choices) { callback(fromSq + toSq + 'q'); return; } const isWhite = G.pieces[fromSq] === 'P'; const pieces = isWhite ? [['q','Q'],['r','R'],['b','B'],['n','N']] : [['q','q'],['r','r'],['b','b'],['n','n']]; const imgPrefix = isWhite ? 'w' : 'b'; choices.innerHTML = ''; for (const [letter, label] of pieces) { const btn = document.createElement('button'); btn.className = 'promo-btn'; btn.dataset.piece = letter; const img = document.createElement('img'); img.src = PIECE_IMG[isWhite ? label : label.toLowerCase()]; img.alt = label; btn.appendChild(img); btn.addEventListener('click', () => { overlay.style.display = 'none'; callback(fromSq + toSq + letter); }); choices.appendChild(btn); } overlay.style.display = 'flex'; } function executeMove(moveUci) { G.waitingMove = true; G.selectedSq = null; // Fade the board hint after first player move const hint = document.getElementById('board-hint'); if (hint && hint.style.opacity !== '0') { hint.style.transition = 'opacity 0.6s ease'; hint.style.opacity = '0'; } const from = moveUci.slice(0,2); const to = moveUci.slice(2,4); const isCapture = !!G.pieces[to]; G.pieces[to] = G.pieces[from]; delete G.pieces[from]; G.lastMove = { from, to }; if (G.mode === 'vs_viktor') G.isPlayerTurn = false; renderBoard(); updateCapturedDisplay(); flashMove(from, to); isCapture ? Audio.playCapture() : Audio.playMove(); updateStatus({ is_check: false }); if (G.mode === 'vs_viktor') showThinking(); socket.emit('make_move', { move: moveUci }); } /* ════════════════════════════════════════ CHAT ════════════════════════════════════════ */ function addMessage(role, content, tone = null, thinking = null, meta = {}) { const box = document.getElementById('chat-messages'); const msg = document.createElement('div'); const toneClass = tone ? ` tone-${tone}` : ''; msg.className = `cmsg ${role}${toneClass}`; // Memory surfaced pill — shows before the bubble, very subtle if (role === 'agent' && meta.memories_surfaced > 0) { const pill = document.createElement('div'); pill.className = 'cmsg-memory-pill'; pill.textContent = meta.memories_surfaced === 1 ? '· memory surfaced' : `· ${meta.memories_surfaced} memories surfaced`; msg.appendChild(pill); } const bubble = document.createElement('div'); bubble.className = 'cmsg-bubble'; bubble.textContent = content; msg.appendChild(bubble); if (tone) { const t = document.createElement('div'); t.className = 'cmsg-tone'; t.textContent = '· ' + tone; msg.appendChild(t); } if (thinking && role === 'agent') { const toggle = document.createElement('div'); toggle.className = 'cmsg-thinking-toggle'; toggle.textContent = '› show thinking'; const think = document.createElement('div'); think.className = 'cmsg-thinking'; think.textContent = thinking; toggle.addEventListener('click', () => { const v = think.classList.toggle('visible'); toggle.textContent = v ? '› hide thinking' : '› show thinking'; }); msg.appendChild(toggle); msg.appendChild(think); } box.appendChild(msg); // If Viktor saved a memory, add a small system note below if (role === 'agent' && meta.memory_saved) { const note = document.createElement('div'); note.className = 'cmsg system'; note.innerHTML = '