'use strict'; // ─── State ──────────────────────────────────────────────────── let socket; let myName = ''; let mySeat = -1; let myToken = null; let myRoomId = null; let spectating = false; let authToken = localStorage.getItem('hearts_token') || null; let authUser = localStorage.getItem('hearts_user') || null; let lastState = null; let deferredInstallPrompt = null; let selectedScore = 100; // ─── Play mode & hand display mode ─────────────────────────── function isTouchDevice() { return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; } function loadPlayMode() { const s = localStorage.getItem('hearts_play_mode'); if (s === 'tap' || s === 'drag') return s; return isTouchDevice() ? 'drag' : 'tap'; } function savePlayMode(m) { localStorage.setItem('hearts_play_mode', m); } function loadHandMode() { const s = localStorage.getItem('hearts_hand_mode'); return s === 'fan' ? 'fan' : 'scroll'; } function saveHandMode(m) { localStorage.setItem('hearts_hand_mode', m); } let playMode = loadPlayMode(); let handMode = loadHandMode(); function updatePlayModeBtn() { const btn = $('btn-play-mode'); if (!btn) return; const isDrag = playMode === 'drag'; btn.textContent = isDrag ? '☝ Drag' : '👆 Tap'; btn.classList.toggle('drag-mode', isDrag); btn.title = isDrag ? 'Switch to tap mode' : 'Switch to drag mode'; } const HAND_MODES = ['scroll', 'fan', 'playables']; const HAND_MODE_LABEL = { scroll: '📜 Scroll', fan: '🃏 Fan', playables: '✅ Playables' }; function updateHandModeBtn() { const btn = $('btn-hand-mode'); if (!btn) return; btn.textContent = HAND_MODE_LABEL[handMode] || '📜 Scroll'; btn.classList.toggle('fan-mode', handMode === 'fan' || handMode === 'playables'); } function applyHandMode() { const fanLike = handMode === 'fan' || handMode === 'playables'; const handEl = $('my-hand'); handEl?.classList.toggle('fan-mode', fanLike); $('my-area')?.classList.toggle('fan-active', fanLike); // Reset inline justify so scroll mode's CSS takes over cleanly if (!fanLike && handEl) handEl.style.justifyContent = ''; } // Dynamic card spacing for fan/playables mode. // If cards overflow → minimum overlap to fit (+2px buffer). // If cards fit → spread up to 16px but never exceed available space // (prevents fan overflow on screens where cards are borderline). function updateHandSpacing() { const handEl = $('my-hand'); if (!handEl || handMode === 'scroll') return; const cards = Array.from(handEl.querySelectorAll('.card')); const n = cards.length; if (n === 0) return; if (n === 1) { cards[0].style.marginLeft = '0'; return; } const cardW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 68; const containerW = handEl.offsetWidth || (window.innerWidth - 24); const overflow = n * cardW - containerW; let ml; if (overflow > 0) { // Cards don't fit — overlap just enough, plus 2px safety buffer ml = -(Math.ceil(overflow / (n - 1)) + 2); handEl.style.justifyContent = 'flex-start'; } else { // Cards fit — spread up to 16px, capped to available space ml = Math.min(16, Math.floor(-overflow / (n - 1))); // Center the group so few cards don't hug the left edge handEl.style.justifyContent = 'center'; } cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; }); } // Attach touch drag-to-play handlers to a card element function addDragHandlers(cardEl, code) { const handEl = $('my-hand'); // Magnifier only helps when the hand is dense (>8 cards); sparse fans are readable const cardCount = $('my-hand')?.querySelectorAll('.card').length ?? 0; const useMagnifier = isTouchDevice() && playMode === 'drag' && (handMode === 'fan' || handMode === 'playables') && cardCount > 8; let touchStartY = 0, touchStartX = 0, isDragging = false, ghost = null; cardEl.addEventListener('touchstart', e => { touchStartY = e.touches[0].clientY; touchStartX = e.touches[0].clientX; isDragging = false; if (useMagnifier) cardEl.classList.add('magnified'); }, { passive: true }); cardEl.addEventListener('touchmove', e => { const dy = touchStartY - e.touches[0].clientY; // Horizontal sweep in fan+drag mode — magnifier picks cards if (useMagnifier && !isDragging && dy < 20) { cardEl.classList.remove('magnified'); const el2 = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); const tgt = el2?.closest('.card'); if (tgt) tgt.classList.add('magnified'); return; } if (dy > 20) { e.preventDefault(); isDragging = true; handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); if (!ghost) { ghost = document.createElement('div'); ghost.className = 'drag-ghost'; ghost.appendChild(cardImg(code)); document.body.appendChild(ghost); $('trick-area')?.classList.add('drag-active'); } ghost.style.left = (e.touches[0].clientX - cardEl.offsetWidth / 2) + 'px'; ghost.style.top = (e.touches[0].clientY - cardEl.offsetHeight * 0.7) + 'px'; } }, { passive: false }); const endDrag = e => { if (ghost) { ghost.remove(); ghost = null; } $('trick-area')?.classList.remove('drag-active'); handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); if (isDragging) { const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY; if (endY < window.innerHeight * 0.70) playCard(code); } isDragging = false; }; cardEl.addEventListener('touchend', endDrag); cardEl.addEventListener('touchcancel', endDrag); } const SUIT_ICON = { H: '♥', D: '♦', C: '♣', S: '♠' }; const SUIT_COLOR = { H: '#e53935', D: '#e53935', C: '#111', S: '#111' }; const PASS_DIR_LABEL = { left: '← Pass Left', right: '→ Pass Right', across: '↑ Pass Across', hold: 'No Pass (Hold)' }; // Map from internal code (e.g. "S-Q") to SVG filename (e.g. "SPADE-12-QUEEN.svg") const SUIT_FILE = { H: 'HEART', D: 'DIAMOND', C: 'CLUB', S: 'SPADE' }; const RANK_FILE = { 'A': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', '10': '10', 'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING', }; // ─── Helpers ────────────────────────────────────────────────── function $(id) { return document.getElementById(id); } function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); } function hide(...ids) { ids.forEach(id => $(id)?.classList.add('hidden')); } function showScreen(id) { document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); $(id).classList.add('active'); } function cardImg(code) { const [s, r] = code.split('-'); const img = document.createElement('img'); img.src = `/cards/${SUIT_FILE[s]}-${RANK_FILE[r]}.svg`; img.alt = code; img.draggable = false; return img; } function suitOf(code) { return code.split('-')[0]; } function rankOf(code) { return code.split('-')[1]; } function isRed(code) { const s = suitOf(code); return s === 'H' || s === 'D'; } function isHeart(code){ return suitOf(code) === 'H'; } function isQoS(code) { return code === 'S-Q'; } function cardPoints(code) { return isHeart(code) ? 1 : isQoS(code) ? 13 : 0; } // Seat mapping: my seat is bottom; left/top/right follow clockwise function relativePosition(mySeat, theirSeat) { const delta = (theirSeat - mySeat + 4) % 4; return ['bottom', 'right', 'top', 'left'][delta]; } function positionToArea(pos) { return { top: 'top', left: 'left', right: 'right', bottom: 'bottom' }[pos]; } // ─── Auth helpers ───────────────────────────────────────────── function authHeaders() { return authToken ? { 'Authorization': `Bearer ${authToken}` } : {}; } async function apiFetch(url, opts = {}) { const res = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(opts.headers || {}) }, body: opts.body ? JSON.stringify(opts.body) : undefined, }); return res.json(); } function setAuth(token, username) { authToken = token; authUser = username; if (token) { localStorage.setItem('hearts_token', token); localStorage.setItem('hearts_user', username); } else { localStorage.removeItem('hearts_token'); localStorage.removeItem('hearts_user'); } updateAuthBar(); } function updateAuthBar() { if (authUser) { $('auth-status').textContent = `Logged in as ${authUser}`; show('btn-show-profile', 'btn-logout'); hide('btn-show-auth'); // Pre-fill the name input so the user doesn't have to type it const nameInput = $('input-name'); if (nameInput && !nameInput.value) nameInput.value = authUser; } else { $('auth-status').textContent = 'Playing as guest'; hide('btn-show-profile', 'btn-logout'); show('btn-show-auth'); } } // ─── Session helpers ────────────────────────────────────────── function saveSession(roomId, seat, token) { localStorage.setItem('hearts_session', JSON.stringify({ roomId, seat, token, ts: Date.now() })); } function loadSession() { try { const s = JSON.parse(localStorage.getItem('hearts_session')); if (s && Date.now() - s.ts < 3 * 60 * 60 * 1000) return s; } catch { /* ignore */ } return null; } function clearSession() { localStorage.removeItem('hearts_session'); } // ─── Socket init ────────────────────────────────────────────── function initSocket() { socket = io({ auth: { token: authToken } }); socket.on('connect', () => { // Try to rejoin an active session const sess = loadSession(); if (sess && sess.roomId) { socket.emit('rejoin', { roomId: sess.roomId, seat: sess.seat, token: sess.token }); } }); socket.on('created', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(roomId, seat, token); $('display-room-code').textContent = roomId; showScreen('screen-waiting'); }); socket.on('joined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(roomId, seat, token); $('display-room-code').textContent = roomId; showScreen('screen-waiting'); }); socket.on('spectating', ({ roomId }) => { myRoomId = roomId; mySeat = -1; spectating = true; showScreen('screen-game'); show('spectator-banner'); }); socket.on('rejoined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(roomId, seat, token); hide('overlay-pass', 'overlay-hand', 'overlay-gameover'); }); socket.on('rejoinError', (msg) => { clearSession(); $('lobby-error').textContent = msg || 'Could not rejoin'; }); // Core game events — all handled by renderState socket.on('roomInfo', state => handleState(state)); socket.on('cardPlayed',state => handleState(state)); socket.on('trickWon', state => handleTrickWon(state)); socket.on('handOver', state => handleHandOver(state)); socket.on('gameOver', state => handleGameOver(state)); socket.on('error', msg => { $('lobby-error').textContent = msg; }); socket.on('joinError', msg => { $('lobby-error').textContent = msg; }); socket.on('passError', msg => console.warn('passError:', msg)); socket.on('playError', msg => console.warn('playError:', msg)); } // ─── State rendering ────────────────────────────────────────── function handleState(state) { lastState = state; // Always dismiss result overlays when fresh state arrives hide('overlay-hand', 'overlay-gameover'); if (state.state === 'WAITING') { renderWaitingRoom(state); return; } showScreen('screen-game'); renderInfoBar(state); renderTable(state); if (state.state === 'PASSING') { renderPassOverlay(state); } else { hide('overlay-pass'); } } function handleTrickWon(state) { lastState = state; renderInfoBar(state); renderTable(state); // Flash the winning slot briefly const winnerPos = spectating ? null : relativePosition(mySeat, state.lastTrickWinner); const slot = winnerPos ? $(`trick-${winnerPos}`) : null; if (slot) slot.classList.add('trick-winner-flash'); setTimeout(() => slot?.classList.remove('trick-winner-flash'), 600); } function handleHandOver(state) { lastState = state; renderInfoBar(state); renderTable(state); hide('overlay-pass'); showHandOverlay(state); } function handleGameOver(state) { lastState = state; renderInfoBar(state); renderTable(state); hide('overlay-pass', 'overlay-hand'); showGameOverOverlay(state); } // ── Info bar ────────────────────────────────────────────────── function renderInfoBar(state) { // Score display const scoreDiv = $('score-display'); scoreDiv.innerHTML = ''; const hpDiv = $('hand-points-display'); hpDiv.innerHTML = ''; const minScore = Math.min(...state.scores); state.names.forEach((name, i) => { const isMine = i === mySeat; const entry = document.createElement('div'); entry.className = 'score-entry' + (isMine ? ' my-score' : '') + (state.scores[i] === minScore && state.scores.filter(s => s === minScore).length < 4 ? ' leading' : ''); const nameEl = document.createElement('span'); nameEl.className = 'score-name'; nameEl.textContent = name || `P${i+1}`; const valEl = document.createElement('span'); valEl.className = 'score-val'; valEl.textContent = state.scores[i]; entry.appendChild(nameEl); entry.appendChild(valEl); scoreDiv.appendChild(entry); }); // Hand points row: only show the local player's own points const myHpVal = state.handPoints[mySeat]; if (myHpVal != null && myHpVal > 0 && mySeat >= 0) { const hp = document.createElement('span'); hp.className = 'hp-entry'; const hn = document.createElement('span'); hn.className = 'hp-name'; hn.textContent = 'You:'; const hv = document.createElement('span'); hv.className = 'hp-val has-pts'; hv.textContent = myHpVal; hp.appendChild(hn); hp.appendChild(hv); hpDiv.appendChild(hp); } // Hearts broken if (state.heartsBroken) { show('hearts-broken-display'); } else { hide('hearts-broken-display'); } // Pass direction if (state.state === 'PASSING') { $('pass-dir-display').textContent = PASS_DIR_LABEL[state.passDirection] || state.passDirection; show('pass-dir-display'); } else { hide('pass-dir-display'); } } // ── Waiting room ────────────────────────────────────────────── function renderWaitingRoom(state) { showScreen('screen-waiting'); const filled = state.names.filter((n, i) => n && (state.bots[i] || true)).length; const humanCount = state.names.filter((n, i) => n && !state.bots[i]).length; const botCount = state.bots.filter(Boolean).length; const total = humanCount + botCount; for (let i = 0; i < 4; i++) { const slot = document.querySelector(`.seat-slot[data-seat="${i}"]`); if (!slot) continue; const nameSpan = slot.querySelector('.seat-name'); if (state.names[i]) { nameSpan.textContent = state.names[i] + (state.bots[i] ? ' 🤖' : ''); slot.classList.add('filled'); } else { nameSpan.textContent = '—'; slot.classList.remove('filled'); } } const remaining = 4 - total; $('waiting-status').textContent = remaining > 0 ? `Waiting for ${remaining} more player${remaining > 1 ? 's' : ''}…` : 'All players ready!'; // Show fill-bots button if we're the first seat and there are empty seats if (mySeat === 0 && total < 4) { show('btn-fill-bots'); } else { hide('btn-fill-bots'); } } // ── Game table ──────────────────────────────────────────────── function renderTable(state) { if (spectating) { renderSpectatorTable(state); return; } const positions = ['top', 'left', 'right']; const otherSeats = [ (mySeat + 2) % 4, // top (across) (mySeat + 3) % 4, // left (mySeat + 1) % 4, // right ]; positions.forEach((pos, idx) => { const seat = otherSeats[idx]; $(`${pos}-name`).textContent = state.names[seat] || `P${seat+1}`; $(`${pos}-score`).textContent = state.scores[seat]; // Turn indicator if (state.currentTurn === seat && state.state === 'PLAYING') { show(`${pos}-turn`); } else { hide(`${pos}-turn`); } // No card-back display — opponents' hand size is hidden $(`${pos}-cards`).innerHTML = ''; }); // My area $('my-name').textContent = state.names[mySeat] || 'You'; $('my-score').textContent = state.scores[mySeat]; // Only show my own in-hand points (server sends null for others) const myHp = state.handPoints[mySeat]; $('my-hand-pts').textContent = myHp > 0 ? `+${myHp}♥` : ''; if (state.currentTurn === mySeat && state.state === 'PLAYING') { show('my-turn'); } else { hide('my-turn'); } // Render my hand const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; renderMyHand(myHand, state); // Render trick renderTrick(state); // Phase message renderPhaseMsg(state); } function renderSpectatorTable(state) { // For spectators, show all hands as backs, no interaction ['top', 'left', 'right'].forEach((pos, idx) => { const seat = [2, 3, 1][idx]; $(`${pos}-name`).textContent = state.names[seat] || `P${seat+1}`; $(`${pos}-score`).textContent = state.scores[seat]; $(`${pos}-cards`).innerHTML = ''; // no card backs if (state.currentTurn === seat && state.state === 'PLAYING') show(`${pos}-turn`); else hide(`${pos}-turn`); }); $('my-name').textContent = state.names[0] || 'P1'; $('my-score').textContent = state.scores[0]; $('my-hand-pts').textContent = ''; $('my-hand').innerHTML = ''; renderTrick(state); renderPhaseMsg(state); } function renderOppCards(containerId, count, vertical) { const el = $(containerId); if (!el) return; el.innerHTML = ''; const max = vertical ? 8 : 13; for (let i = 0; i < Math.min(count, max); i++) { const back = document.createElement('div'); back.className = 'card-back'; el.appendChild(back); } } function renderMyHand(hand, state) { const el = $('my-hand'); el.innerHTML = ''; applyHandMode(); if (!hand.length) return; const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat; const legal = isMyTurn ? legalCards(hand, state) : []; // "Only Playables" mode: when it's my turn, render ONLY legal cards // (all cards shown when not my turn or during passing — player still needs to plan) const displayHand = (handMode === 'playables' && isMyTurn && state.state === 'PLAYING') ? legal : hand; displayHand.forEach(code => { const div = document.createElement('div'); div.className = 'card'; if (isQoS(code)) div.classList.add('card-qos'); if (isHeart(code)) div.classList.add('card-heart'); if (state.state === 'PASSING') { const selected = state.passSelected || []; if (selected.includes(code)) div.classList.add('selected'); div.addEventListener('click', () => togglePassSelect(code, state)); } else if (isMyTurn) { // In playables mode every shown card is legal — no illegal overlay needed const isLegal = legal.includes(code); if (!isLegal) { div.classList.add('illegal'); } else { if (playMode === 'tap') { div.addEventListener('click', () => playCard(code)); } addDragHandlers(div, code); } } div.appendChild(cardImg(code)); el.appendChild(div); }); // Show/hide play-mode toggle (touch devices only, during play) if (!spectating) { if (isTouchDevice() && state.state === 'PLAYING') { show('btn-play-mode'); } else { hide('btn-play-mode'); } updatePlayModeBtn(); updateHandModeBtn(); } // Fan / Playables: dynamic overlap spacing; Scroll: center visible window if (handMode === 'fan' || handMode === 'playables') { requestAnimationFrame(updateHandSpacing); } else { requestAnimationFrame(() => { const overflow = el.scrollWidth - el.clientWidth; if (overflow > 0) el.scrollLeft = overflow / 2; }); } } function renderTrick(state) { const trick = state.trick || []; const seats = spectating ? { bottom: 0, right: 1, top: 2, left: 3 } : { bottom: mySeat, right: (mySeat + 1) % 4, top: (mySeat + 2) % 4, left: (mySeat + 3) % 4, }; ['top', 'left', 'right', 'bottom'].forEach(pos => { const slot = $(`trick-${pos}`); if (!slot) return; slot.innerHTML = ''; const seat = seats[pos]; const entry = trick.find(t => t.player === seat); if (entry) { slot.appendChild(cardImg(entry.card)); } }); } function renderPhaseMsg(state) { const el = $('phase-msg'); if (!el) return; if (state.state === 'WAITING') { el.textContent = 'Waiting…'; } else if (state.state === 'PASSING') { const pending = state.passReady.filter(r => !r).length; el.textContent = pending > 0 ? `${pending} player${pending > 1 ? 's' : ''} passing…` : 'Exchanging…'; } else if (state.state === 'PLAYING') { if (state.trick.length === 0) { const name = state.names[state.currentTurn] || `P${state.currentTurn + 1}`; el.textContent = state.currentTurn === mySeat ? 'Your lead' : `${name} leads`; } else { const name = state.names[state.currentTurn] || `P${state.currentTurn + 1}`; el.textContent = state.currentTurn === mySeat ? 'Your turn' : `${name}…`; } } else { el.textContent = ''; } } // ── Pass overlay ────────────────────────────────────────────── // Clubs → Diamonds → Spades → Hearts, one row per suit const PASS_SUIT_ORDER = ['C', 'D', 'S', 'H']; const PASS_SUIT_LABEL = { C: '♣', D: '♦', S: '♠', H: '♥' }; function renderPassOverlay(state) { if (state.passDirection === 'hold') { hide('overlay-pass'); return; } const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; const selected = state.passSelected || []; $('pass-title').textContent = 'Pass 3 cards'; $('pass-hint').textContent = PASS_DIR_LABEL[state.passDirection] || ''; // Render hand grouped by suit, one row per suit const passHandEl = $('pass-hand'); passHandEl.innerHTML = ''; PASS_SUIT_ORDER.forEach(s => { const suitCards = myHand.filter(c => suitOf(c) === s); if (suitCards.length === 0) return; const row = document.createElement('div'); row.className = 'pass-suit-row'; const lbl = document.createElement('span'); lbl.className = 'pass-suit-label suit-' + s.toLowerCase(); lbl.textContent = PASS_SUIT_LABEL[s]; row.appendChild(lbl); const cardsWrap = document.createElement('div'); cardsWrap.className = 'pass-suit-cards'; suitCards.forEach(code => { const div = document.createElement('div'); div.className = 'card' + (selected.includes(code) ? ' selected' : ''); div.appendChild(cardImg(code)); div.addEventListener('click', () => togglePassSelect(code, state)); cardsWrap.appendChild(div); }); row.appendChild(cardsWrap); passHandEl.appendChild(row); }); // Preview selected const preview = $('pass-selected-preview'); preview.innerHTML = ''; for (let i = 0; i < 3; i++) { if (selected[i]) { const div = document.createElement('div'); div.className = 'card'; div.appendChild(cardImg(selected[i])); preview.appendChild(div); } else { const ph = document.createElement('div'); ph.className = 'pass-placeholder'; preview.appendChild(ph); } } // Confirm button $('btn-confirm-pass').disabled = selected.length !== 3; // Show waiting status const readyCount = state.passReady.filter(Boolean).length; $('pass-waiting').textContent = readyCount > 0 ? `${readyCount}/4 ready…` : ''; // If already passed, show waiting message if (state.passReady[mySeat]) { $('btn-confirm-pass').disabled = true; $('pass-waiting').textContent = 'Waiting for others…'; } show('overlay-pass'); } let passSelectedLocal = []; function togglePassSelect(code, state) { if (state.passReady[mySeat]) return; // already passed const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; const selected = [...(state.passSelected || [])]; const idx = selected.indexOf(code); if (idx !== -1) { selected.splice(idx, 1); } else if (selected.length < 3) { selected.push(code); } else { return; // already 3 selected } // Optimistic update for immediate feedback state.passSelected = selected; renderPassOverlay(state); // Also update my-hand in game table renderMyHand(myHand, state); } function confirmPass() { const state = lastState; if (!state) return; const selected = state.passSelected || []; if (selected.length !== 3) return; socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected }); } // ── Play ────────────────────────────────────────────────────── function playCard(code) { socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code }); } // ── Client-side legal card computation (mirrors server) ─────── function legalCards(hand, state) { const trick = state.trick || []; // First card of first trick if (trick.length === 0 && state.tricksPlayed === 0) { return hand.includes('C-2') ? ['C-2'] : hand; } // Leading if (trick.length === 0) { const nonHearts = hand.filter(c => !isHeart(c)); if (!state.heartsBroken && nonHearts.length > 0) return nonHearts; return hand; } // Following const leadSuit = suitOf(trick[0].card); const suitCards = hand.filter(c => suitOf(c) === leadSuit); if (suitCards.length > 0) return suitCards; // Can't follow — first trick avoids points if possible if (state.tricksPlayed === 0) { const safe = hand.filter(c => cardPoints(c) === 0); if (safe.length > 0) return safe; } return hand; } // ── Hand over overlay ───────────────────────────────────────── function showHandOverlay(state) { const moon = state.moonShooter; if (moon !== -1) { $('hand-result-icon').textContent = '🌙'; $('hand-result-title').textContent = `${state.names[moon]} shot the moon!`; $('hand-result-detail').textContent = 'Everyone else gets 26 points.'; } else { $('hand-result-icon').textContent = '🃏'; $('hand-result-title').textContent = 'Hand over'; $('hand-result-detail').textContent = ''; } const scoresEl = $('hand-result-scores'); scoresEl.innerHTML = ''; const deltas = state.handDeltas || [0,0,0,0]; state.names.forEach((name, i) => { const row = document.createElement('div'); row.className = 'result-row' + (moon === i ? ' moon' : ''); const nameEl = document.createElement('span'); nameEl.textContent = name || `P${i+1}`; const deltaEl = document.createElement('span'); deltaEl.className = 'result-delta ' + (deltas[i] === 0 ? 'zero' : deltas[i] >= 13 ? 'red' : ''); deltaEl.textContent = `+${deltas[i]} → ${state.scores[i]}`; row.appendChild(nameEl); row.appendChild(deltaEl); scoresEl.appendChild(row); }); show('overlay-hand'); } // ── Game over overlay ───────────────────────────────────────── function showGameOverOverlay(state) { const winners = Array.isArray(state.gameWinner) ? state.gameWinner : [state.gameWinner]; const minScore = Math.min(...state.scores); $('gameover-title').textContent = winners.length === 1 ? `${state.names[winners[0]]} wins!` : `Tie game!`; const scoresEl = $('gameover-scores'); scoresEl.innerHTML = ''; // Sort by score ascending for display const order = state.names.map((n, i) => i).sort((a, b) => state.scores[a] - state.scores[b]); order.forEach(i => { const row = document.createElement('div'); const isWin = winners.includes(i); row.className = 'go-row' + (isWin ? ' winner' : ''); const nameEl = document.createElement('span'); nameEl.className = 'go-name'; nameEl.textContent = state.names[i] || `P${i+1}`; const scoreEl = document.createElement('span'); scoreEl.className = 'go-score'; scoreEl.textContent = state.scores[i]; if (isWin) { const badge = document.createElement('span'); badge.className = 'go-badge'; badge.textContent = '🏆'; scoreEl.appendChild(badge); } row.appendChild(nameEl); row.appendChild(scoreEl); scoresEl.appendChild(row); }); show('overlay-gameover'); } // ─── Auth flow ──────────────────────────────────────────────── async function doLogin() { const username = $('auth-login-user').value.trim(); const password = $('auth-login-pass').value; $('auth-login-error').textContent = ''; if (!username || !password) { $('auth-login-error').textContent = 'Fill in all fields'; return; } const data = await apiFetch('/api/login', { method: 'POST', body: { username, password } }); if (data.error) { $('auth-login-error').textContent = data.error; return; } setAuth(data.token, data.username); hide('overlay-auth'); // Re-init socket with new token socket.disconnect(); initSocket(); } let regEmail = ''; async function doRegister() { const username = $('auth-reg-user').value.trim(); const email = $('auth-reg-email').value.trim(); const password = $('auth-reg-pass').value; $('auth-reg-error').textContent = ''; const body = { username, email, password }; const cfWidget = window.turnstile; if (cfWidget) body.cfToken = cfWidget.getResponse?.() || ''; const data = await apiFetch('/api/register/initiate', { method: 'POST', body }); if (data.error) { $('auth-reg-error').textContent = data.error; return; } if (data.done) { setAuth(data.token, data.username); hide('overlay-auth'); socket.disconnect(); initSocket(); return; } // Email verification step regEmail = data.email; $('reg-verify-hint').textContent = `A 6-digit code was sent to ${data.email}`; hide('reg-step-form'); show('reg-step-verify'); } async function doVerify() { const code = $('auth-reg-code').value.trim(); $('auth-verify-error').textContent = ''; const data = await apiFetch('/api/register/confirm', { method: 'POST', body: { email: regEmail, code } }); if (data.error) { $('auth-verify-error').textContent = data.error; return; } setAuth(data.token, data.username); hide('overlay-auth'); socket.disconnect(); initSocket(); } async function loadProfile() { if (!authUser) return; const data = await apiFetch(`/api/profile/${encodeURIComponent(authUser)}`); if (data.error) return; $('profile-username').textContent = data.username; $('stat-games-played').textContent = data.games_played || 0; $('stat-games-won').textContent = data.games_won || 0; $('stat-moon-shots').textContent = data.moon_shots || 0; $('stat-total-score').textContent = data.total_score || 0; // Admin panel if (data.username === (window._adminUser || '')) { show('admin-panel'); loadAdminState(); } else { hide('admin-panel'); } } async function loadAdminState() { const data = await apiFetch('/api/config'); if (data.signupsOpen !== undefined) { $('btn-toggle-signups').textContent = data.signupsOpen ? 'Open ✓' : 'Closed ✗'; } } async function doChangePassword() { const cur = $('change-pass-current').value; const nw = $('change-pass-new').value; $('change-pass-msg').textContent = ''; const data = await apiFetch('/api/change-password', { method: 'POST', body: { currentPassword: cur, newPassword: nw } }); if (data.error) { $('change-pass-msg').textContent = data.error; return; } $('change-pass-msg').style.color = '#69f0ae'; $('change-pass-msg').textContent = 'Password updated!'; } async function loadLeaderboard() { const data = await apiFetch('/api/leaderboard'); if (!Array.isArray(data)) return; const tbody = $('lb-body'); tbody.innerHTML = ''; data.forEach((row, idx) => { const tr = document.createElement('tr'); if (idx < 3) tr.className = 'lb-top'; const avg = row.score_per_game != null ? row.score_per_game.toFixed(1) : '—'; tr.innerHTML = `