'use strict'; // ─── State ──────────────────────────────────────────────────── let socket; 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; // Lobby selections let selectedScore = 100; let selectedPublic = false; let swapSelectedSeat = -1; // seat highlighted for swap in the waiting room // Pass (3-card exchange) state let passSelected = []; let passActive = false; // true while the pass overlay owns a live selection // Turn reminder / escalation state let turnReminderTimer = null; let turnReminderActive = false; let turnEscalationLevel = 0; // AFK / AI control state let afkBannerVoted = false; let aiControlledSeats = new Set(); // PWA let deferredInstallPrompt = null; // ─── Audio (unlocked on first gesture — required by iOS) ────── let _audioCtx = null; function getAudioCtx() { if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (_audioCtx.state === 'suspended') _audioCtx.resume(); return _audioCtx; } document.addEventListener('pointerdown', () => getAudioCtx(), { once: true }); function playTurnChime() { try { const ctx = getAudioCtx(); const now = ctx.currentTime; [[784, 0], [988, 0.18]].forEach(([freq, delay]) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.value = freq; gain.gain.setValueAtTime(0, now + delay); gain.gain.linearRampToValueAtTime(0.22, now + delay + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45); osc.start(now + delay); osc.stop(now + delay + 0.5); }); } catch { /* audio not available */ } } function applyTurnEscalation(level) { const pm = $('phase-msg'); if (!pm) return; pm.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3'); if (level > 0) pm.classList.add(`your-turn-lvl${level}`); } function handleTurnReminder(isMyTurn) { if (isMyTurn && !turnReminderActive) { turnReminderActive = true; turnEscalationLevel = 0; $('my-area')?.classList.remove('turn-urgent'); applyTurnEscalation(0); // Escalate every 5 seconds: level 1 → 2 → 3 (vibrate/chime at level 3) turnReminderTimer = setInterval(() => { turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3); applyTurnEscalation(turnEscalationLevel); if (turnEscalationLevel >= 3) { $('my-area')?.classList.add('turn-urgent'); if (navigator.vibrate) navigator.vibrate([300, 100, 300]); else playTurnChime(); clearInterval(turnReminderTimer); turnReminderTimer = null; } }, 5000); } else if (!isMyTurn && turnReminderActive) { if (turnReminderTimer) { clearInterval(turnReminderTimer); turnReminderTimer = null; } turnReminderActive = false; turnEscalationLevel = 0; applyTurnEscalation(0); $('my-area')?.classList.remove('turn-urgent'); } } // ─── Play / 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'); if (['scroll', 'fan', 'playables'].includes(s)) return s; return isTouchDevice() ? 'fan' : 'scroll'; } function saveHandMode(m) { localStorage.setItem('hearts_hand_mode', m); } function loadBarBottom() { return localStorage.getItem('hearts_bar_bottom') === '1'; } function saveBarBottom(v) { localStorage.setItem('hearts_bar_bottom', v ? '1' : '0'); } let playMode = loadPlayMode(); let handMode = loadHandMode(); let barAtBottom = loadBarBottom(); function applyBarBottom() { $('screen-game')?.classList.toggle('bar-bottom', barAtBottom); const btn = $('btn-toggle-bar'); if (btn) btn.textContent = barAtBottom ? '⬆ Bar to top' : '⬇ Bar to bottom'; } const HAND_MODES = ['scroll', 'fan', 'playables']; const HAND_LABELS = { scroll: '📜 Scroll', fan: '🃏 Fan', playables: '✅ Playables' }; function updatePlayModeBtn() { const btn = $('btn-play-mode'); if (!btn) return; const drag = playMode === 'drag'; btn.textContent = drag ? '☝ Drag' : '👆 Tap'; btn.classList.toggle('drag-mode', drag); btn.title = drag ? 'Switch to tap mode' : 'Switch to drag mode'; } function updateHandModeBtn() { const btn = $('btn-hand-mode'); if (!btn) return; btn.textContent = HAND_LABELS[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); if (!fanLike && handEl) handEl.style.justifyContent = ''; } // Dynamic overlap for fan/playables mode: overlap just enough when the cards // don't fit, spread up to 16px when they do. 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) { ml = -(Math.ceil(overflow / (n - 1)) + 2); handEl.style.justifyContent = 'flex-start'; } else { ml = Math.min(16, Math.floor(-overflow / (n - 1))); handEl.style.justifyContent = 'center'; } cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; }); } // ═══════════════════════════════════════════════════════════════ // CARD ASSETS — loaded once, never refetched // ═══════════════════════════════════════════════════════════════ // Cards used to visibly flash at the start of every trick because each render // built brand-new elements. Now every face is decoded once into a // template image; everything on screen is a clone of that template, and the // renderers below reuse existing DOM nodes instead of rebuilding them. 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', }; const SUITS = ['C', 'D', 'H', 'S']; const RANKS = ['2','3','4','5','6','7','8','9','10','J','Q','K','A']; const ALL_CARDS = SUITS.flatMap(s => RANKS.map(r => `${s}-${r}`)); function cardSvg(code) { const [s, r] = code.split('-'); return `/cards/${SUIT_FILE[s]}-${RANK_FILE[r]}.svg`; } const cardTemplates = new Map(); function cardTemplate(code) { let t = cardTemplates.get(code); if (!t) { t = new Image(); t.src = cardSvg(code); t.alt = code; t.draggable = false; t.dataset.card = code; cardTemplates.set(code, t); } return t; } // Cloning a loaded image reuses the decoded bitmap — no request, no flash. function cardImg(code) { return cardTemplate(code).cloneNode(true); } function preloadCards() { ALL_CARDS.forEach(cardTemplate); } function makeCardEl(code) { const el = document.createElement('div'); el.className = 'card'; el.dataset.card = code; el.appendChild(cardImg(code)); wireCardDrag(el); return el; } // Reconcile a row of cards against `codes`, keeping the DOM nodes that are // already there. Returns the elements in display order. function syncCards(container, codes) { const existing = new Map(); for (const el of Array.from(container.children)) { if (el.dataset?.card) existing.set(el.dataset.card, el); } const wanted = codes.map(code => existing.get(code) || makeCardEl(code)); const keep = new Set(wanted); for (const el of existing.values()) if (!keep.has(el)) el.remove(); // appendChild/insertBefore on an existing node moves it; the image is untouched wanted.forEach((el, i) => { if (container.children[i] !== el) container.insertBefore(el, container.children[i] || null); }); return wanted; } // A trick slot holds at most one card. Leave it alone if it already shows it. function setSlotCard(slotId, code) { const el = $(slotId); if (!el) return; const cur = el.firstElementChild; if (!code) { if (cur) el.replaceChildren(); return; } if (cur && cur.dataset.card === code) return; el.replaceChildren(cardImg(code)); } // ─── Drag-to-play (touch devices) ───────────────────────────── // Wired once per card element; the mode checks happen at touch time so a mode // switch doesn't require rebuilding the hand. function wireCardDrag(cardEl) { let touchStartY = 0, isDragging = false, ghost = null, magnifying = false; cardEl.addEventListener('touchstart', e => { if (!cardEl.classList.contains('playable')) return; touchStartY = e.touches[0].clientY; isDragging = false; const count = $('my-hand')?.querySelectorAll('.card').length ?? 0; magnifying = isTouchDevice() && playMode === 'drag' && (handMode === 'fan' || handMode === 'playables') && count > 8; if (magnifying) cardEl.classList.add('magnified'); }, { passive: true }); cardEl.addEventListener('touchmove', e => { if (!cardEl.classList.contains('playable')) return; const dy = touchStartY - e.touches[0].clientY; // Horizontal sweep with the magnifier picks a card without playing it if (magnifying && !isDragging && dy < 20) { cardEl.classList.remove('magnified'); const under = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); under?.closest('.card')?.classList.add('magnified'); return; } if (dy > 20) { e.preventDefault(); isDragging = true; $('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); if (!ghost) { ghost = document.createElement('div'); ghost.className = 'drag-ghost'; ghost.appendChild(cardImg(cardEl.dataset.card)); 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'); $('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); if (isDragging && cardEl.classList.contains('playable')) { const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY; if (endY < window.innerHeight * 0.70) playCard(cardEl.dataset.card); } isDragging = false; }; cardEl.addEventListener('touchend', endDrag); cardEl.addEventListener('touchcancel', endDrag); } // ─── 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 escHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function suitOf(code) { return code.split('-')[0]; } function isHeart(code) { return suitOf(code) === 'H'; } function cardPoints(code) { return isHeart(code) ? 1 : code === 'S-Q' ? 13 : 0; } const PASS_DIR_LABEL = { left: '← Pass Left', right: '→ Pass Right', across: '↑ Pass Across', hold: 'No Pass (Hold)', }; // Seats run clockwise from the viewer: left = +1, across = +2, right = +3. // Play runs the other way (seat → seat+3), i.e. bottom → right → top → left. function seatSlots() { const base = mySeat < 0 ? 0 : mySeat; return { bottom: base, left: (base + 1) % 4, top: (base + 2) % 4, right: (base + 3) % 4 }; } // ─── Auth fetch 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(); } // ─── Lobby init ─────────────────────────────────────────────── function initLobby() { updateAuthBar(); document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); $('tab-' + btn.dataset.tab)?.classList.add('active'); if (btn.dataset.tab === 'join') loadPublicRooms(); }); }); document.querySelectorAll('.score-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.score-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedScore = +btn.dataset.score; }); }); document.querySelectorAll('.visibility-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedPublic = btn.dataset.public === 'true'; }); }); $('btn-create').addEventListener('click', createGame); $('btn-join').addEventListener('click', joinGame); $('btn-spectate').addEventListener('click', spectateGame); $('btn-refresh-rooms').addEventListener('click', loadPublicRooms); $('btn-show-auth').addEventListener('click', () => { show('overlay-auth'); $('auth-login-user').focus(); }); $('btn-auth-close').addEventListener('click', () => hide('overlay-auth')); $('btn-show-leaderboard').addEventListener('click', showLeaderboard); $('btn-show-profile').addEventListener('click', () => showProfile(authUser)); $('btn-logout').addEventListener('click', doLogout); document.querySelectorAll('.auth-tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.auth-panel').forEach(p => p.classList.remove('active')); tab.classList.add('active'); $('auth-panel-' + tab.dataset.authTab)?.classList.add('active'); }); }); $('btn-do-login').addEventListener('click', doLogin); $('auth-login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); $('btn-do-register').addEventListener('click', doRegister); $('btn-do-verify').addEventListener('click', doVerify); $('btn-reg-back').addEventListener('click', () => { hide('reg-step-verify'); show('reg-step-form'); $('auth-verify-error').textContent = ''; }); } function createGame() { const name = $('input-name').value.trim(); if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; } $('lobby-error').textContent = ''; connectSocket(() => { socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic }); }); } function joinGame() { const name = $('input-name').value.trim(); const code = $('input-code').value.trim().toUpperCase(); if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; } if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; } $('lobby-error').textContent = ''; connectSocket(() => socket.emit('join', { name, roomId: code })); } function spectateGame() { const code = $('input-spectate-code').value.trim().toUpperCase(); if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; } $('lobby-error').textContent = ''; spectating = true; connectSocket(() => socket.emit('spectate', { roomId: code })); } async function loadPublicRooms() { const listEl = $('public-rooms-list'); listEl.innerHTML = '

Loading…

'; try { const list = await fetch('/api/rooms').then(r => r.json()); if (!Array.isArray(list) || list.length === 0) { listEl.innerHTML = '

No public rooms right now.

'; return; } listEl.innerHTML = ''; list.forEach(room => { const row = document.createElement('div'); row.className = 'public-room-item'; row.innerHTML = `
${escHtml(room.hostName)}'s room ${room.playerCount}/4 · to ${room.winScore}
`; row.querySelector('.btn-join-pub').addEventListener('click', () => { $('input-code').value = room.id; joinGame(); }); listEl.appendChild(row); }); } catch { listEl.innerHTML = '

Failed to load rooms.

'; } } // ─── Socket ─────────────────────────────────────────────────── function connectSocket(onReady) { if (socket?.connected) { onReady(); return; } if (!socket) { socket = io({ auth: { token: authToken } }); initSocketHandlers(); } else { socket.auth = { token: authToken }; } socket.once('connect', onReady); } // Auth is only read at (re)connect time, so bounce the socket after login. function reauthSocket() { if (!socket) return; socket.auth = { token: authToken }; if (socket.connected) socket.disconnect().connect(); } function initSocketHandlers() { // Auto-rejoin after any network drop mid-game socket.on('connect', () => { if (myRoomId && mySeat >= 0 && myToken) { socket.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } }); socket.on('created', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); showScreen('screen-waiting'); }); socket.on('joined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); showScreen('screen-waiting'); }); socket.on('spectating', ({ roomId }) => { myRoomId = roomId; mySeat = -1; spectating = true; showScreen('screen-game'); }); socket.on('rejoined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); }); socket.on('seatChanged', ({ seat, token }) => { mySeat = seat; myToken = token; saveSession(); }); // Pushed when an authenticated user connects with a game running elsewhere socket.on('hasActiveGame', ({ roomId, seat, token }) => { if (myRoomId) return; myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); socket.emit('rejoin', { roomId, seat, token }); }); socket.on('joinError', msg => { $('lobby-error').textContent = msg || 'Could not join room.'; }); socket.on('spectateError', msg => { $('lobby-error').textContent = msg || 'Could not spectate.'; }); socket.on('rejoinError', () => { clearSession(); myRoomId = null; mySeat = -1; myToken = null; showScreen('screen-lobby'); }); socket.on('error', msg => { $('lobby-error').textContent = msg || 'Server error.'; }); socket.on('playError', msg => console.warn('playError:', msg)); socket.on('passError', msg => console.warn('passError:', msg)); socket.on('roomInfo', render); socket.on('cardPlayed', render); socket.on('trickWon', onTrickWon); socket.on('handOver', onHandOver); socket.on('gameOver', onGameOver); // The server rewound the hand because the card counts had drifted apart socket.on('stateReverted', ({ trick } = {}) => { $('revert-banner-msg').textContent = `⚠ Card counts didn't match — the hand was rewound to the start of trick ${trick}.`; show('revert-banner'); setTimeout(() => hide('revert-banner'), 6000); }); socket.on('afkWarning', ({ seat, name } = {}) => { afkBannerVoted = false; const isMe = seat === mySeat; $('afk-banner-msg').textContent = isMe ? "You haven't played for a minute!" : `${name} hasn't played for 1 minute.`; const btn = $('afk-vote-btn'); if (btn) { btn.disabled = false; btn.textContent = 'Let AI Play'; } if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn'); show('afk-banner'); }); socket.on('afkResolved', () => { afkBannerVoted = false; hide('afk-banner'); }); socket.on('aiControl', ({ seat, active } = {}) => { if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat); updateAiControlBanner(); }); } // ─── Session persistence ────────────────────────────────────── // The seat token uses its own key — sharing 'hearts_token' with the auth JWT // would overwrite the login on every join. function saveSession() { localStorage.setItem('hearts_room', myRoomId); localStorage.setItem('hearts_seat', mySeat); localStorage.setItem('hearts_seat_token', myToken); } function clearSession() { localStorage.removeItem('hearts_room'); localStorage.removeItem('hearts_seat'); localStorage.removeItem('hearts_seat_token'); } function tryRejoin() { const room = localStorage.getItem('hearts_room'); const seat = localStorage.getItem('hearts_seat'); const token = localStorage.getItem('hearts_seat_token'); if (!room || seat === null || !token) return; connectSocket(() => { myRoomId = room; mySeat = +seat; myToken = token; socket.emit('rejoin', { roomId: room, seat: +seat, token }); }); } // ─── Waiting room ───────────────────────────────────────────── function renderWaitingRoom(st) { $('display-room-code').textContent = st.id; show('waiting-options'); $('waiting-score-label').textContent = `To ${st.winScore} pts`; $('waiting-public-badge').textContent = st.isPublic ? '🌐 Public' : '🔒 Private'; const isCreator = mySeat === 0; document.querySelectorAll('.seat-slot').forEach(slot => { const s = +slot.dataset.seat; const name = slot.querySelector('.seat-name'); name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—'); slot.classList.toggle('filled', !!st.names[s]); if (isCreator) { slot.classList.add('seat-swappable'); slot.classList.toggle('seat-swap-selected', swapSelectedSeat === s); slot.onclick = () => { if (swapSelectedSeat === -1) swapSelectedSeat = s; else if (swapSelectedSeat === s) swapSelectedSeat = -1; else { socket?.emit('swapSeats', { roomId: myRoomId, seatA: swapSelectedSeat, seatB: s }); swapSelectedSeat = -1; } renderWaitingRoom(lastState); }; } else { slot.classList.remove('seat-swappable', 'seat-swap-selected'); slot.onclick = null; } }); const filled = (st.seats ? st.seats.filter(Boolean).length : 0) + st.bots.filter(Boolean).length; let statusText = filled < 4 ? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}…` : 'Ready to start!'; if (isCreator && swapSelectedSeat >= 0) statusText = 'Tap another seat to swap ↕'; $('waiting-status').textContent = statusText; if (isCreator) show('waiting-creator-controls'); else hide('waiting-creator-controls'); } // ─── Main render ────────────────────────────────────────────── function render(st) { if (!st) return; lastState = st; aiControlledSeats = new Set(st.aiControlledSeats || []); updateAiControlBanner(); if (st.state === 'WAITING') { if ($('screen-waiting')?.classList.contains('active')) renderWaitingRoom(st); return; } // Only switch screens when we actually need to — re-running showScreen on // every state push made the whole table blink. if (!$('screen-game')?.classList.contains('active')) showScreen('screen-game'); // Reloading or rejoining during a result screen must bring the result back, // not drop the player onto an empty table. if (st.state === 'GAME_OVER') { onGameOver(st); return; } if (st.state === 'HAND_OVER') { onHandOver(st); return; } hide('overlay-hand', 'overlay-gameover'); renderInfoBar(st); renderTable(st); renderOverlays(st); } // ─── Info bar ───────────────────────────────────────────────── function renderInfoBar(st) { const scoreDiv = $('score-display'); const minScore = Math.min(...st.scores); const soleLeader = st.scores.filter(s => s === minScore).length < 4; // Reuse the four entry nodes rather than rebuilding them each tick for (let i = 0; i < 4; i++) { let entry = scoreDiv.children[i]; if (!entry) { entry = document.createElement('div'); entry.innerHTML = ''; scoreDiv.appendChild(entry); } entry.className = 'score-entry' + (i === mySeat ? ' my-score' : '') + (soleLeader && st.scores[i] === minScore ? ' leading' : ''); entry.firstChild.textContent = st.names[i] || `P${i + 1}`; entry.lastChild.textContent = st.scores[i]; } if (st.heartsBroken && st.state === 'PLAYING') show('hearts-broken-display'); else hide('hearts-broken-display'); if (st.state === 'PASSING') { $('pass-dir-display').textContent = PASS_DIR_LABEL[st.passDirection] || st.passDirection; show('pass-dir-display'); } else { hide('pass-dir-display'); } } // ─── Table ──────────────────────────────────────────────────── function renderTable(st) { const slots = seatSlots(); renderOpponent('top', slots.top, st); renderOpponent('left', slots.left, st); renderOpponent('right', slots.right, st); renderTrick(st, slots); renderMyHand(st); if (mySeat < 0) { // Spectator: the bottom of the table is seat 0's place $('my-name').textContent = st.names[0] || 'P1'; $('my-score').textContent = st.scores[0]; hide('my-turn', 'my-hand-pts', 'btn-play-mode'); } else { $('my-name').textContent = st.names[mySeat] || 'You'; $('my-score').textContent = st.scores[mySeat]; (st.currentTurn === mySeat && st.state === 'PLAYING') ? show('my-turn') : hide('my-turn'); // Points taken so far this hand (only ever my own — the server hides the rest) const hp = st.handPoints[mySeat]; if (hp > 0) { $('my-hand-pts').textContent = `+${hp} this hand`; show('my-hand-pts'); } else { hide('my-hand-pts'); } } // Phase message let msg = '', myTurnNow = false; if (st.state === 'PASSING') { const pending = st.passReady.filter(r => !r).length; msg = pending > 0 ? `${pending} player${pending > 1 ? 's' : ''} still passing…` : 'Exchanging…'; } else if (st.state === 'PLAYING') { const justWon = st.trick.length === 0 && st.lastTrickWinner >= 0 && st.tricksPlayed > 0; if (justWon) { const w = st.names[st.lastTrickWinner] || '?'; msg = st.lastTrickWinner === mySeat ? 'You took it' : `${w.slice(0, 9)} took it`; if (st.currentTurn === mySeat) myTurnNow = true; } else if (st.currentTurn === mySeat) { msg = st.trick.length === 0 ? 'Your lead!' : 'Your turn!'; myTurnNow = true; } else if (st.currentTurn >= 0) { const n = (st.names[st.currentTurn] || '?').slice(0, 9); msg = st.trick.length === 0 ? `${n} leads` : `${n}'s turn`; } } const pm = $('phase-msg'); pm.textContent = msg; pm.classList.toggle('your-turn', myTurnNow); handleTurnReminder(myTurnNow && !spectating); spectating ? show('spectator-banner') : hide('spectator-banner'); } function renderOpponent(slot, seat, st) { $(`${slot}-name`).textContent = st.names[seat] || '—'; $(`${slot}-score`).textContent = st.scores[seat]; (st.currentTurn === seat && st.state === 'PLAYING') ? show(`${slot}-turn`) : hide(`${slot}-turn`); // Card-count badge instead of a fan of card backs — cheaper and clearer, // and it makes a count mismatch visible to the players immediately. const countEl = $(`${slot}-count`); if (countEl) { const h = st.hands[seat]; const n = typeof h === 'number' ? h : (Array.isArray(h) ? h.length : 0); countEl.textContent = n > 0 ? `${n}` : ''; } } function renderTrick(st, slots) { const played = {}; for (const t of st.trick) played[t.player] = t.card; setSlotCard('trick-bottom', played[slots.bottom]); setSlotCard('trick-top', played[slots.top]); setSlotCard('trick-left', played[slots.left]); setSlotCard('trick-right', played[slots.right]); } function renderMyHand(st) { const handEl = $('my-hand'); if (mySeat < 0) { handEl.replaceChildren(); return; } const myHand = st.hands[mySeat]; if (!Array.isArray(myHand)) { handEl.replaceChildren(); return; } const isMyTurn = st.state === 'PLAYING' && st.currentTurn === mySeat && !spectating; const legal = isMyTurn ? computeLegalCards(st, mySeat) : []; const legalSet = new Set(legal); const passing = st.state === 'PASSING' && !spectating; // "Playables" mode shows only the legal cards while it's my turn const display = (handMode === 'playables' && isMyTurn) ? legal : myHand; const els = syncCards(handEl, display); els.forEach((el, i) => { const code = display[i]; el.classList.toggle('illegal', isMyTurn && !legalSet.has(code)); el.classList.toggle('playable', isMyTurn && legalSet.has(code)); el.classList.toggle('selected', passing && passSelected.includes(code)); }); if (!spectating) { if (isTouchDevice() && st.state === 'PLAYING') show('btn-play-mode'); else hide('btn-play-mode'); updatePlayModeBtn(); updateHandModeBtn(); } applyHandMode(); if (handMode === 'fan' || handMode === 'playables') { requestAnimationFrame(updateHandSpacing); } else { requestAnimationFrame(() => { const overflow = handEl.scrollWidth - handEl.clientWidth; if (overflow > 0) handEl.scrollLeft = overflow / 2; }); } } function onTrickWon(st) { render(st); // Flash the slot that took the trick const slots = seatSlots(); const pos = Object.keys(slots).find(k => slots[k] === st.lastTrickWinner); const slot = pos ? $(`trick-${pos}`) : null; if (slot) { slot.classList.add('trick-winner-flash'); setTimeout(() => slot.classList.remove('trick-winner-flash'), 600); } } // ─── Legal cards (client-side mirror of the server rules) ───── function computeLegalCards(st, seat) { const hand = st.hands[seat]; if (!Array.isArray(hand)) return []; const trick = st.trick || []; // Opening lead of the hand must be 2♣ if (trick.length === 0 && st.tricksPlayed === 0) { return hand.includes('C-2') ? ['C-2'] : hand; } // Leading: no hearts until they're broken if (trick.length === 0) { const nonHearts = hand.filter(c => !isHeart(c)); if (!st.heartsBroken && nonHearts.length > 0) return nonHearts; return hand; } // Following: must follow suit if able const leadSuit = suitOf(trick[0].card); const suitCards = hand.filter(c => suitOf(c) === leadSuit); if (suitCards.length > 0) return suitCards; // Void on the first trick: no point cards if avoidable if (st.tricksPlayed === 0) { const safe = hand.filter(c => cardPoints(c) === 0); if (safe.length > 0) return safe; } return hand; } function playCard(code) { if (!socket || mySeat < 0) return; socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code }); } // ─── Overlays ───────────────────────────────────────────────── function renderOverlays(st) { const passingNow = st.state === 'PASSING' && mySeat >= 0 && !spectating && st.passDirection !== 'hold'; if (!passingNow) { if (passActive) { passActive = false; passSelected = []; } hide('overlay-pass'); return; } renderPassOverlay(st); } function hideAllOverlays() { hide('overlay-pass', 'overlay-hand'); passActive = false; } // ─── Pass overlay ───────────────────────────────────────────── const PASS_SUIT_ORDER = ['C', 'D', 'S', 'H']; const PASS_SUIT_LABEL = { C: '♣', D: '♦', S: '♠', H: '♥' }; function renderPassOverlay(st) { const alreadyPassed = st.passReady[mySeat]; if (!passActive) { // First render of this PASSING phase — start from a clean selection passActive = true; passSelected = []; show('overlay-pass'); } else { // Re-render while open (a spectator joined, someone else passed, …). // Keep the selection; only drop cards that are no longer in hand. const hand = Array.isArray(st.hands[mySeat]) ? st.hands[mySeat] : []; passSelected = passSelected.filter(c => hand.includes(c)); } $('pass-title').textContent = 'Pass 3 cards'; $('pass-hint').textContent = PASS_DIR_LABEL[st.passDirection] || ''; renderPassHand(st, alreadyPassed); updatePassPreview(); const readyCount = st.passReady.filter(Boolean).length; if (alreadyPassed) { $('btn-confirm-pass').disabled = true; $('pass-waiting').textContent = `Waiting for others… (${readyCount}/4 ready)`; } else { $('btn-confirm-pass').disabled = passSelected.length !== 3; $('pass-waiting').textContent = readyCount > 0 ? `${readyCount}/4 ready…` : ''; } } function renderPassHand(st, locked) { const container = $('pass-hand'); const hand = st.hands[mySeat]; if (!Array.isArray(hand)) return; // One row per suit, reusing the card nodes across renders PASS_SUIT_ORDER.forEach(s => { const suitCards = hand.filter(c => suitOf(c) === s); let row = container.querySelector(`.pass-suit-row[data-suit="${s}"]`); if (suitCards.length === 0) { row?.remove(); return; } if (!row) { row = document.createElement('div'); row.className = 'pass-suit-row'; row.dataset.suit = s; row.innerHTML = `${PASS_SUIT_LABEL[s]}` + '
'; container.appendChild(row); } const els = syncCards(row.querySelector('.pass-suit-cards'), suitCards); els.forEach((el, i) => { el.classList.toggle('selected', passSelected.includes(suitCards[i])); el.classList.toggle('illegal', locked); }); }); // Keep suit rows in a stable order PASS_SUIT_ORDER.forEach(s => { const row = container.querySelector(`.pass-suit-row[data-suit="${s}"]`); if (row) container.appendChild(row); }); } function togglePassSelect(code) { if (!lastState || lastState.passReady[mySeat]) return; const i = passSelected.indexOf(code); if (i >= 0) passSelected.splice(i, 1); else if (passSelected.length < 3) passSelected.push(code); else return; renderPassOverlay(lastState); renderMyHand(lastState); } function updatePassPreview() { const preview = $('pass-selected-preview'); preview.replaceChildren(); for (let i = 0; i < 3; i++) { if (passSelected[i]) preview.appendChild(makeCardEl(passSelected[i])); else { const ph = document.createElement('div'); ph.className = 'pass-placeholder'; preview.appendChild(ph); } } } function confirmPass() { if (passSelected.length !== 3) return; socket?.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: passSelected }); } // ─── Hand over ──────────────────────────────────────────────── function onHandOver(st) { lastState = st; hideAllOverlays(); renderInfoBar(st); renderTable(st); const moon = st.moonShooter; if (moon !== -1) { $('hand-result-icon').textContent = '🌙'; $('hand-result-title').textContent = `${st.names[moon] || 'Someone'} shot the moon!`; $('hand-result-detail').textContent = moon === mySeat ? 'You score 0 — everyone else takes 26.' : 'Everyone else takes 26 points.'; } else { $('hand-result-icon').textContent = '🃏'; $('hand-result-title').textContent = 'Hand over'; const deltas = st.handDeltas || [0, 0, 0, 0]; const mine = mySeat >= 0 ? deltas[mySeat] : null; $('hand-result-detail').textContent = mine === 0 ? 'You took nothing this hand.' : mine != null ? `You took ${mine} point${mine === 1 ? '' : 's'}.` : ''; } const scoresEl = $('hand-result-scores'); scoresEl.replaceChildren(); const deltas = st.handDeltas || [0, 0, 0, 0]; const minDelta = Math.min(...deltas); st.names.forEach((name, i) => { const row = document.createElement('div'); row.className = 'result-score-row' + (moon === i ? ' winner' : '') + (i === mySeat ? ' mine' : ''); row.innerHTML = ` ${escHtml(name || `P${i + 1}`)} +${deltas[i]} → ${st.scores[i]}`; scoresEl.appendChild(row); }); renderRoundBreakdown($('hand-round-breakdown'), st.roundHistory, st.names, st.scores); show('overlay-hand'); } // ─── Game over ──────────────────────────────────────────────── function onGameOver(st) { lastState = st; hideAllOverlays(); hide('afk-banner'); renderInfoBar(st); renderTable(st); const winners = new Set(Array.isArray(st.gameWinner) ? st.gameWinner : [st.gameWinner]); const iWon = mySeat >= 0 && winners.has(mySeat); $('gameover-title').textContent = iWon ? '🏆 You win!' : winners.size > 1 ? 'Tie game!' : `${st.names[[...winners][0]] || 'Someone'} wins!`; const scoresEl = $('gameover-scores'); scoresEl.replaceChildren(); // Lowest score first — that's the winner in Hearts st.names .map((n, i) => i) .sort((a, b) => st.scores[a] - st.scores[b]) .forEach(i => { const row = document.createElement('div'); row.className = 'gameover-row' + (winners.has(i) ? ' winner' : ''); row.innerHTML = `${escHtml(st.names[i] || `P${i + 1}`)}` + `${st.scores[i]}${winners.has(i) ? ' 🏆' : ''}`; scoresEl.appendChild(row); }); renderRoundBreakdown($('gameover-breakdown'), st.roundHistory, st.names, st.scores); show('overlay-gameover'); } // ─── Round-by-round breakdown (shared) ──────────────────────── // One row per hand showing what each player picked up, then a ruled-off totals // row with the running score. Used at hand-over, game-over and in history. function renderRoundBreakdown(container, rounds, names, finalScores) { if (!container) return; if (!Array.isArray(rounds) || rounds.length === 0) { container.innerHTML = '

No hands played yet.

'; return; } const totals = finalScores || rounds[rounds.length - 1].scores; let html = '
'; for (let i = 0; i < 4; i++) html += ``; html += ''; rounds.forEach(r => { const deltas = r.deltas || [0, 0, 0, 0]; const best = Math.min(...deltas); html += ``; for (let i = 0; i < 4; i++) { const cls = r.moonShooter === i ? 'rb-moon' : deltas[i] === best ? 'rb-best' : ''; html += ``; } html += ''; }); html += ''; for (let i = 0; i < 4; i++) html += ``; html += '
${escHtml(names?.[i] || `P${i + 1}`)}
H${r.hand}${r.moonShooter === i ? '🌙 ' : ''}+${deltas[i]}
Total${totals[i]}
'; container.innerHTML = html; } // ─── AI control banner ──────────────────────────────────────── function updateAiControlBanner() { if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; } const isMe = aiControlledSeats.has(mySeat); const msg = isMe ? '🤖 AI is playing for you! Play a card to take back control.' : `🤖 AI is playing for: ${[...aiControlledSeats].map(s => lastState?.names[s] || `Seat ${s + 1}`).join(', ')}`; $('ai-control-msg').textContent = msg; $('ai-control-banner').classList.toggle('is-me', isMe); show('ai-control-banner'); } function clearGameState() { aiControlledSeats.clear(); passSelected = []; passActive = false; handleTurnReminder(false); hide('ai-control-banner', 'afk-banner', 'revert-banner', 'overlay-pass', 'overlay-hand', 'overlay-gameover'); } // ─── Auth ───────────────────────────────────────────────────── function updateAuthBar() { if (authUser) { $('auth-status').textContent = `Logged in as ${authUser}`; show('btn-show-profile', 'btn-logout'); hide('btn-show-auth'); 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'); } } 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(); } 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; } try { const d = await apiFetch('/api/login', { method: 'POST', body: { username, password } }); if (d.error) { $('auth-login-error').textContent = d.error; return; } setAuth(d.token, d.username); hide('overlay-auth'); reauthSocket(); } catch { $('auth-login-error').textContent = 'Network error'; } } 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 }; if (window.turnstile) body.cfToken = window.turnstile.getResponse?.() || ''; const d = await apiFetch('/api/register/initiate', { method: 'POST', body }); if (d.error) { $('auth-reg-error').textContent = d.error; return; } if (d.done) { setAuth(d.token, d.username); hide('overlay-auth'); reauthSocket(); return; } regEmail = d.email; $('reg-verify-hint').textContent = `A 6-digit code was sent to ${d.email}`; hide('reg-step-form'); show('reg-step-verify'); } async function doVerify() { const code = $('auth-reg-code').value.trim(); $('auth-verify-error').textContent = ''; const d = await apiFetch('/api/register/confirm', { method: 'POST', body: { email: regEmail, code } }); if (d.error) { $('auth-verify-error').textContent = d.error; return; } setAuth(d.token, d.username); hide('overlay-auth'); reauthSocket(); } function doLogout() { setAuth(null, null); reauthSocket(); } // Drop a stale or corrupted token before we act on it, so we don't run around // as a "logged in" ghost whose socket is actually an unauthenticated guest. async function validateAuth() { if (!authToken) return; try { const r = await fetch('/api/me', { headers: authHeaders() }); if (!r.ok) throw new Error('invalid'); const d = await r.json(); authUser = d.username; localStorage.setItem('hearts_user', authUser); } catch { authToken = null; authUser = null; localStorage.removeItem('hearts_token'); localStorage.removeItem('hearts_user'); } updateAuthBar(); } // ─── Profile / leaderboard ──────────────────────────────────── async function showProfile(username) { if (!username) return; showScreen('screen-profile'); $('profile-username').textContent = username; try { const r = await fetch(`/api/profile/${encodeURIComponent(username)}`); const d = await r.json(); if (!r.ok) return; $('stat-win-rate').textContent = (d.win_rate ?? 0) + '%'; $('stat-games-won').textContent = d.games_won || 0; $('stat-games-played').textContent = d.games_played || 0; $('stat-moon-shots').textContent = d.moon_shots || 0; $('stat-avg-score').textContent = d.avg_score ?? 0; $('stat-total-score').textContent = d.total_score || 0; $('btn-show-history').dataset.username = username; if (username === authUser) show('btn-show-change-pass'); else hide('btn-show-change-pass'); if (d.isAdmin) { show('admin-panel'); const btn = $('btn-toggle-signups'); btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗'; btn.classList.toggle('signup-open', d.signupsOpen); } else { hide('admin-panel'); } } catch { /* ignore */ } } async function showLeaderboard() { showScreen('screen-leaderboard'); try { const rows = await fetch('/api/leaderboard').then(r => r.json()); const tbody = $('lb-body'); tbody.innerHTML = ''; let rank = 0, dividerDone = false; rows.forEach(row => { // Players below the minimum game count are listed after the ranking, // under a divider, so a newcomer can see how close they are. if (!row.ranked && !dividerDone) { dividerDone = true; const sep = document.createElement('tr'); sep.className = 'lb-divider'; sep.innerHTML = `Not yet ranked — needs ${ row.gamesToRank + row.games_played} games`; tbody.appendChild(sep); } const tr = document.createElement('tr'); let rankCell; if (row.ranked) { rank++; tr.className = rank <= 3 ? 'lb-top' : ''; rankCell = ['🥇','🥈','🥉'][rank - 1] || rank; } else { tr.className = 'lb-provisional'; rankCell = '—'; } tr.innerHTML = ` ${rankCell} ${escHtml(row.username)} ${row.win_rate != null ? row.win_rate + '%' : '—'} ${row.games_won} ${row.games_played}${row.ranked ? '' : ` +${row.gamesToRank}`} ${row.score_per_game ?? '—'} ${row.moon_shots || 0} `; tbody.appendChild(tr); }); tbody.querySelectorAll('.lb-btn').forEach(btn => { btn.addEventListener('click', () => showPlayerDetails(btn.dataset.user)); }); } catch { /* ignore */ } } async function showPlayerDetails(username) { const d = await apiFetch(`/api/profile/${encodeURIComponent(username)}`); if (d.error) return; $('details-username').textContent = d.username; $('details-body').innerHTML = ` Games Played${d.games_played} Games Won${d.games_won} Win Rate${d.win_rate}% Avg Score / Game${d.avg_score} Moon Shots 🌙${d.moon_shots} Total Score${d.total_score}`; show('overlay-player-details'); } // ─── Game history ───────────────────────────────────────────── async function showHistory(username) { if (!username) return; showScreen('screen-history'); $('history-title').textContent = username === authUser ? 'Your Game History' : `${username}'s History`; hide('history-detail'); show('history-list'); const listEl = $('history-list'); listEl.innerHTML = '

Loading…

'; try { const games = await fetch(`/api/history/${encodeURIComponent(username)}`).then(r => r.json()); if (!Array.isArray(games) || games.length === 0) { listEl.innerHTML = '

No games played yet.

'; return; } listEl.innerHTML = ''; games.forEach(g => { const row = document.createElement('div'); row.className = 'history-item' + (g.won ? ' won' : ' lost'); const date = new Date(g.endedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const others = g.playerNames.filter((_, i) => i !== g.mySeat).join(', '); row.innerHTML = `
${g.won ? '🏆 Won' : '✖ Lost'} vs ${escHtml(others)}
${g.myScore} pts · ${g.rounds.length} hand${g.rounds.length === 1 ? '' : 's'} ${date}
`; row.addEventListener('click', () => showHistoryDetail(g)); listEl.appendChild(row); }); } catch { listEl.innerHTML = '

Failed to load history.

'; } } function showHistoryDetail(g) { hide('history-list'); show('history-detail'); $('history-detail-title').textContent = `${g.won ? '🏆 ' : ''}Game to ${g.winScore} — ${new Date(g.endedAt).toLocaleString()}`; const scoresEl = $('history-detail-scores'); scoresEl.replaceChildren(); g.playerNames .map((n, i) => i) .sort((a, b) => g.finalScores[a] - g.finalScores[b]) .forEach(i => { const row = document.createElement('div'); row.className = 'gameover-row' + (g.winners.includes(i) ? ' winner' : ''); row.innerHTML = `${escHtml(g.playerNames[i] || `P${i + 1}`)}${i === g.mySeat ? ' (you)' : ''}` + `${g.finalScores[i]}`; scoresEl.appendChild(row); }); renderRoundBreakdown($('history-detail-breakdown'), g.rounds, g.playerNames, g.finalScores); } // ─── Event wiring ───────────────────────────────────────────── function leaveAndReturnToLobby() { socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); clearSession(); clearGameState(); mySeat = -1; myRoomId = null; myToken = null; spectating = false; swapSelectedSeat = -1; showScreen('screen-lobby'); } function wireGameEvents() { // ── Waiting room ── $('btn-leave-waiting').addEventListener('click', leaveAndReturnToLobby); $('btn-waiting-menu').addEventListener('click', e => { e.stopPropagation(); $('waiting-menu-dropdown').classList.toggle('hidden'); }); $('btn-waiting-reload').addEventListener('click', () => { if (myRoomId && mySeat >= 0 && myToken) { socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } hide('waiting-menu-dropdown'); }); $('btn-waiting-exit').addEventListener('click', () => { hide('waiting-menu-dropdown'); leaveAndReturnToLobby(); }); $('btn-copy').addEventListener('click', async () => { const code = $('display-room-code').textContent.trim(); const btn = $('btn-copy'); try { if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(code); else { const ta = document.createElement('textarea'); ta.value = code; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } btn.textContent = '✓'; btn.style.color = '#69f0ae'; } catch { btn.textContent = '✗'; } setTimeout(() => { btn.textContent = '⧉'; btn.style.color = ''; }, 1500); }); $('btn-fill-bots').addEventListener('click', () => socket?.emit('fillBots', { roomId: myRoomId })); $('btn-start-game').addEventListener('click', () => socket?.emit('forceStart', { roomId: myRoomId })); // ── Game menu ── $('btn-game-menu').addEventListener('click', e => { e.stopPropagation(); $('game-menu-dropdown').classList.toggle('hidden'); }); document.addEventListener('click', e => { if (!e.target.closest('.game-menu-wrap')) { hide('game-menu-dropdown', 'waiting-menu-dropdown'); } }); $('btn-refresh-game').addEventListener('click', () => { if (myRoomId && mySeat >= 0 && myToken) { socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } hide('game-menu-dropdown'); }); $('btn-toggle-bar').addEventListener('click', () => { barAtBottom = !barAtBottom; saveBarBottom(barAtBottom); applyBarBottom(); hide('game-menu-dropdown'); }); $('btn-exit-game').addEventListener('click', () => { hide('game-menu-dropdown'); show('overlay-exit-confirm'); }); $('btn-leave-game').addEventListener('click', () => { if (spectating) { spectating = false; clearGameState(); showScreen('screen-lobby'); return; } show('overlay-exit-confirm'); }); // Leave buttons inside phase overlays — those cover the info bar, so players // need their own way out while passing. document.querySelectorAll('[data-overlay-leave]').forEach(btn => { btn.addEventListener('click', () => show('overlay-exit-confirm')); }); $('btn-exit-confirm-yes').addEventListener('click', () => { hide('overlay-exit-confirm'); leaveAndReturnToLobby(); }); $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); // ── Cards: one delegated tap handler each, so reused nodes never stack listeners ── $('my-hand').addEventListener('click', e => { const card = e.target.closest('.card'); if (!card || !card.classList.contains('playable')) return; if (playMode !== 'tap') return; playCard(card.dataset.card); }); $('pass-hand').addEventListener('click', e => { const card = e.target.closest('.card'); if (!card || card.classList.contains('illegal')) return; togglePassSelect(card.dataset.card); }); $('btn-confirm-pass').addEventListener('click', confirmPass); // ── Display modes ── $('btn-play-mode').addEventListener('click', () => { playMode = playMode === 'tap' ? 'drag' : 'tap'; savePlayMode(playMode); updatePlayModeBtn(); }); $('btn-hand-mode').addEventListener('click', () => { handMode = HAND_MODES[(HAND_MODES.indexOf(handMode) + 1) % HAND_MODES.length]; saveHandMode(handMode); updateHandModeBtn(); if (lastState) renderMyHand(lastState); }); // ── Post-game ── $('btn-new-game').addEventListener('click', () => { hide('overlay-gameover'); leaveAndReturnToLobby(); }); // ── Screens ── $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); $('btn-details-close').addEventListener('click', () => hide('overlay-player-details')); $('btn-show-history').addEventListener('click', e => showHistory(e.currentTarget.dataset.username || authUser)); $('btn-history-back').addEventListener('click', () => showScreen('screen-profile')); $('btn-history-detail-back').addEventListener('click', () => { hide('history-detail'); show('history-list'); }); // ── Change password ── $('btn-show-change-pass').addEventListener('click', () => $('change-pass-form').classList.toggle('hidden')); $('btn-do-change-pass').addEventListener('click', async () => { const cur = $('change-pass-current').value; const nw = $('change-pass-new').value; $('change-pass-msg').textContent = ''; try { const d = await apiFetch('/api/change-password', { method: 'POST', body: { currentPassword: cur, newPassword: nw }, }); $('change-pass-msg').textContent = d.error ? d.error : '✓ Password updated'; $('change-pass-msg').style.color = d.error ? '#ff6b6b' : '#69f0ae'; } catch { $('change-pass-msg').textContent = 'Network error'; } }); // ── AFK ── $('afk-vote-btn').addEventListener('click', () => { if (afkBannerVoted) return; afkBannerVoted = true; socket?.emit('voteAITakeover', { roomId: myRoomId }); const btn = $('afk-vote-btn'); btn.disabled = true; btn.textContent = 'Voted ✓'; }); $('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner')); // ── Admin ── $('btn-toggle-signups').addEventListener('click', async () => { const d = await apiFetch('/api/admin/toggle-signups', { method: 'POST', body: {} }); if (d.signupsOpen !== undefined) { const btn = $('btn-toggle-signups'); btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗'; btn.classList.toggle('signup-open', d.signupsOpen); } }); // ── PWA ── $('btn-install').addEventListener('click', () => deferredInstallPrompt?.prompt()); $('pwa-banner-install').addEventListener('click', () => deferredInstallPrompt?.prompt()); $('pwa-banner-dismiss').addEventListener('click', () => $('pwa-banner').classList.remove('show')); window.addEventListener('resize', () => { if (handMode === 'fan' || handMode === 'playables') updateHandSpacing(); }); } window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredInstallPrompt = e; show('btn-install'); setTimeout(() => { const banner = $('pwa-banner'); banner.classList.remove('hidden'); banner.classList.add('show'); }, 3000); }); // ─── Version footer ─────────────────────────────────────────── async function loadConfig() { try { const d = await fetch('/api/config').then(r => r.json()); if (d.version) $('app-version').textContent = `v${d.version} · tap to update`; if (d.turnstileSiteKey) { const s = document.createElement('script'); s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; s.onload = () => { if (window.turnstile) { window.turnstile.render('#auth-captcha-wrap', { sitekey: d.turnstileSiteKey }); show('auth-captcha-wrap'); } }; document.head.appendChild(s); } } catch { /* ignore */ } } // Tapping the version drops the service worker and every cache, so a phone // stuck on stale assets can pull the latest build. async function forceUpdate() { const el = $('app-version'); if (el) el.textContent = 'Updating…'; try { if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map(r => r.unregister())); } if (window.caches) { const keys = await caches.keys(); await Promise.all(keys.map(k => caches.delete(k))); } } catch { /* ignore */ } location.reload(); } // ─── Boot ───────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { preloadCards(); // decode every face once, up front initLobby(); wireGameEvents(); applyBarBottom(); updatePlayModeBtn(); updateHandModeBtn(); applyHandMode(); if (!isTouchDevice()) hide('btn-play-mode'); loadConfig(); $('app-version').addEventListener('click', forceUpdate); await validateAuth(); if (authUser) $('input-name').value = authUser; tryRejoin(); // Authenticated with no local session: connect anyway so the server can push // hasActiveGame for a cross-device rejoin if (authToken && !myRoomId) connectSocket(() => {}); });