diff --git a/.gitignore b/.gitignore index 71ea255..3bc41db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .env data/ +data-dev/ node_modules/ -push.sh \ No newline at end of file +push.sh +rerun.sh +compose.dev.yml diff --git a/README.md b/README.md index 76496c0..d9a2ce6 100644 --- a/README.md +++ b/README.md @@ -82,4 +82,53 @@ git pull ## Data -Game data (users, stats, config) is stored in `./data/` which is bind-mounted into the container. It persists across restarts and rebuilds. +Game data is stored in `./data/`, bind-mounted into the container, and persists +across restarts and rebuilds: + +| File | Contents | +|---|---| +| `users.json` | Local accounts (Hokm/Shelem accounts come from `SHARED_USERS_FILE`) | +| `stats.json` | Per-player totals behind the profile and leaderboard | +| `config.json` | Runtime config (e.g. whether signups are open) | +| `history.json` | Finished games with their round-by-round breakdown | + +`history.json` keeps the most recent 2000 games. Games with a bot at the table +are not recorded, so neither history nor the leaderboard counts them. + +## Game history + +Every finished all-human game is saved with the score each player picked up in +each hand. Players reach it from **Profile β†’ πŸ“œ Game History**; tapping a game +shows the final standings and a hand-by-hand table with a ruled-off totals row. +The same breakdown appears on the end-of-hand and end-of-game screens, so the +running scores are visible from the first hand onwards rather than only at the +end. + +## Leaderboard + +Ranked by **win rate** β€” the percentage of finished games in which the player +had the lowest score. A game that ends in a tie for lowest counts as a win for +each tied player. Average score per game breaks ties in the ranking, and there +*lower* is better, since Hearts scores are penalty points. + +A player needs **5 finished games** before they take a rank, so one lucky game +can't top the board. Players below that are still listed underneath the ranking, +unranked and dimmed, showing how many more games they need. Change +`MIN_RANKED_GAMES` in `server.js` to adjust; the client reads the value back from +`/api/config`, so the label follows automatically. + +Games with a bot at the table are not counted at all. + +## Card-count integrity + +Hearts games could previously end early if one seat somehow got two cards into a +single trick: that player ran out while everyone else still held cards. The +server now rejects a second card from a seat that has already played in the +current trick, refuses cards that aren't in the player's hand, and puts nobody +on turn while a trick is resolving. + +As a backstop, it snapshots the table at the start of every trick (only when all +four hands are level) and re-checks the counts after each trick and at the end of +each hand. If they have drifted apart it rewinds to that last sound snapshot, +tells every client (they show a brief notice), and play resumes from the start of +that trick. diff --git a/package.json b/package.json index 46eef56..173fd70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hearts", - "version": "1.0.0", + "version": "1.1.0", "description": "Hearts card game - multiplayer", "main": "server.js", "scripts": { diff --git a/public/app.js b/public/app.js index 975aa6e..4ceae1d 100644 --- a/public/app.js +++ b/public/app.js @@ -2,28 +2,98 @@ // ─── State ──────────────────────────────────────────────────── let socket; -let myName = ''; -let mySeat = -1; -let myToken = null; -let myRoomId = null; +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; -// ─── Turn escalation state ──────────────────────────────────── -let turnEscalationInterval = null; -let turnEscalationLevel = 0; -let wasMyTurn = false; - -// ─── AFK / AI-takeover state ────────────────────────────────── -let aiControlledSeats = new Set(); -let afkWarnSeat = -1; +// Lobby selections let selectedScore = 100; let selectedPublic = false; +let swapSelectedSeat = -1; // seat highlighted for swap in the waiting room -// ─── Play mode & hand display mode ─────────────────────────── +// 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; } @@ -35,28 +105,38 @@ function loadPlayMode() { function savePlayMode(m) { localStorage.setItem('hearts_play_mode', m); } function loadHandMode() { const s = localStorage.getItem('hearts_hand_mode'); - return s === 'fan' ? 'fan' : 'scroll'; + 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 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 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 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'; } -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.textContent = HAND_LABELS[handMode] || 'πŸ“œ Scroll'; btn.classList.toggle('fan-mode', handMode === 'fan' || handMode === 'playables'); } function applyHandMode() { @@ -64,14 +144,11 @@ function applyHandMode() { 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). +// 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; @@ -79,63 +156,130 @@ function updateHandSpacing() { 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; +// ═══════════════════════════════════════════════════════════════ +// 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; - touchStartX = e.touches[0].clientX; isDragging = false; - if (useMagnifier) cardEl.classList.add('magnified'); + 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 in fan+drag mode β€” magnifier picks cards - if (useMagnifier && !isDragging && dy < 20) { + // Horizontal sweep with the magnifier picks a card without playing it + if (magnifying && !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'); + 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; - handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); + $('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); if (!ghost) { ghost = document.createElement('div'); ghost.className = 'drag-ghost'; - ghost.appendChild(cardImg(code)); + ghost.appendChild(cardImg(cardEl.dataset.card)); document.body.appendChild(ghost); $('trick-area')?.classList.add('drag-active'); } @@ -147,10 +291,10 @@ function addDragHandlers(cardEl, code) { 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) { + $('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(code); + if (endY < window.innerHeight * 0.70) playCard(cardEl.dataset.card); } isDragging = false; }; @@ -158,103 +302,34 @@ function addDragHandlers(cardEl, code) { 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', -}; - -// ─── Turn escalation ────────────────────────────────────────── -function handleTurnReminder(isMyTurn) { - clearInterval(turnEscalationInterval); - turnEscalationLevel = 0; - applyTurnEscalation(0); - if (!isMyTurn) return; - turnEscalationInterval = setInterval(() => { - turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3); - applyTurnEscalation(turnEscalationLevel); - if (turnEscalationLevel >= 3) { - if (navigator.vibrate) navigator.vibrate([200, 100, 200]); - try { - const ctx = new (window.AudioContext || window.webkitAudioContext)(); - const o = ctx.createOscillator(); - const g = ctx.createGain(); - o.connect(g); g.connect(ctx.destination); - o.frequency.value = 880; - g.gain.setValueAtTime(0.3, ctx.currentTime); - g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); - o.start(); o.stop(ctx.currentTime + 0.4); - } catch(e) {} - } - }, 5000); -} - -function applyTurnEscalation(level) { - const el = $('phase-msg'); - if (!el) return; - el.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3'); - if (level > 0) el.classList.add(`your-turn-lvl${level}`); -} - -// ─── AFK / AI-takeover UI ───────────────────────────────────── -function updateAiControlBanner() { - const banner = $('ai-control-banner'); - const msg = $('ai-control-msg'); - if (!banner || !msg) return; - if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; } - if (aiControlledSeats.has(mySeat)) { - msg.textContent = 'AI is playing for you β€” play a card to resume control'; - banner.className = 'ai-control-banner ai-self'; - } else { - const names = [...aiControlledSeats].map(s => lastState?.names[s] || `P${s+1}`).join(', '); - msg.textContent = `AI is playing for ${names}`; - banner.className = 'ai-control-banner ai-other'; - } - show('ai-control-banner'); -} - // ─── 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'); + $(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 escHtml(s) { + return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } -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; } +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; } -// 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]; +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 helpers ───────────────────────────────────────────── -function authHeaders() { - return authToken ? { 'Authorization': `Bearer ${authToken}` } : {}; -} +// ─── Auth fetch helpers ─────────────────────────────────────── +function authHeaders() { return authToken ? { Authorization: `Bearer ${authToken}` } : {}; } async function apiFetch(url, opts = {}) { const res = await fetch(url, { ...opts, @@ -264,862 +339,100 @@ async function apiFetch(url, opts = {}) { 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'); - } +// ─── Lobby init ─────────────────────────────────────────────── +function initLobby() { 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'; - showScreen('screen-lobby'); - }); - - socket.on('hasActiveGame', ({ roomId, seat, token }) => { - if (myRoomId) return; // already in a session - myRoomId = roomId; mySeat = seat; myToken = token; - saveSession(roomId, seat, token); - socket.emit('rejoin', { roomId, seat, token }); - }); - - // 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)); - - socket.on('afkWarning', ({ seat, name }) => { - afkWarnSeat = seat; - const isMe = seat === mySeat; - $('afk-banner-msg').textContent = isMe - ? "You haven't played yet!" - : `${name} hasn't played. Let AI take over?`; - if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn'); - show('afk-banner'); - }); - - socket.on('afkResolved', () => { - afkWarnSeat = -1; - hide('afk-banner'); - }); - - socket.on('aiControl', ({ seat, active, name }) => { - if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat); - updateAiControlBanner(); - }); -} - -// ─── State rendering ────────────────────────────────────────── -function syncAiState(state) { - aiControlledSeats = new Set(state.aiControlledSeats || []); - updateAiControlBanner(); -} - -function handleState(state) { - lastState = state; - syncAiState(state); - - // Restore locally-selected pass cards that the server doesn't know about yet - if (state.state === 'PASSING' && mySeat >= 0 && !state.passReady[mySeat]) { - state.passSelected = passSelectedLocal; - } else { - passSelectedLocal = []; - } - - // 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; - syncAiState(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; - syncAiState(state); - hide('afk-banner'); - renderInfoBar(state); - renderTable(state); - hide('overlay-pass'); - showHandOverlay(state); -} - -function handleGameOver(state) { - lastState = state; - syncAiState(state); - hide('afk-banner'); - 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'); - } - - const badge = $('waiting-public-badge'); - if (badge) badge.textContent = state.isPublic ? '🌐 Public' : 'πŸ”’ Private'; -} - -// ── 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; + 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(); }); - } -} - -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; - - const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat && !spectating; - if (isMyTurn !== wasMyTurn) { - wasMyTurn = isMyTurn; - handleTurnReminder(isMyTurn); - } - - 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); + 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; }); - 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; persist so server pushes don't wipe it - state.passSelected = selected; - passSelectedLocal = 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; - passSelectedLocal = []; - 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); + 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'; + }); }); - show('overlay-hand'); -} + $('btn-create').addEventListener('click', createGame); + $('btn-join').addEventListener('click', joinGame); + $('btn-spectate').addEventListener('click', spectateGame); + $('btn-refresh-rooms').addEventListener('click', loadPublicRooms); -// ── Game over overlay ───────────────────────────────────────── -function showGameOverOverlay(state) { - const winners = Array.isArray(state.gameWinner) ? state.gameWinner : [state.gameWinner]; - const minScore = Math.min(...state.scores); + $('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); - $('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); + 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'); + }); }); - 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 = ` - ${['πŸ₯‡','πŸ₯ˆ','πŸ₯‰'][idx] || idx+1} - ${escHtml(row.username)} - ${avg} - ${row.games_won} - ${row.games_played} - ${row.moon_shots} - - `; - tbody.appendChild(tr); - }); - // Attach click handlers for player details - tbody.querySelectorAll('.lb-btn').forEach(btn => { - btn.addEventListener('click', () => showPlayerDetails(btn.dataset.user)); + $('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 = ''; }); } -async function showPlayerDetails(username) { - const data = await apiFetch(`/api/profile/${encodeURIComponent(username)}`); - if (data.error) return; - const played = data.games_played || 0; - const winRate = played ? ((data.games_won / played) * 100).toFixed(1) + '%' : 'β€”'; - const scorePerGame = played ? (data.total_score / played).toFixed(1) : 'β€”'; - $('details-username').textContent = data.username; - $('details-body').innerHTML = ` - Games Played${played} - Games Won${data.games_won} - Win Rate${winRate} - Score / Game${scorePerGame} - Moon Shots πŸŒ™${data.moon_shots} - Total Score${data.total_score} - `; - show('overlay-player-details'); -} - -function escHtml(s) { - return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); -} - -// ─── Lobby helpers ──────────────────────────────────────────── -function joinGame() { - const name = $('input-name').value.trim(); - const roomId = $('input-code').value.trim().toUpperCase(); - if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } - if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } +function createGame() { + const name = $('input-name').value.trim(); + if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; } $('lobby-error').textContent = ''; - myName = name; - socket.emit('join', { name, roomId }); + 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'); - if (!listEl) return; listEl.innerHTML = '

Loading…

'; try { - const list = await apiFetch('/api/rooms'); + 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; @@ -1131,9 +444,9 @@ async function loadPublicRooms() { row.innerHTML = `
${escHtml(room.hostName)}'s room - ${room.playerCount}/4 Β· score limit ${room.winScore} + ${room.playerCount}/4 Β· to ${room.winScore}
- `; + `; row.querySelector('.btn-join-pub').addEventListener('click', () => { $('input-code').value = room.id; joinGame(); @@ -1145,12 +458,1077 @@ async function loadPublicRooms() { } } -// ─── PWA install ────────────────────────────────────────────── +// ─── 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'); - // Show banner after 3s setTimeout(() => { const banner = $('pwa-banner'); banner.classList.remove('hidden'); @@ -1158,252 +1536,61 @@ window.addEventListener('beforeinstallprompt', e => { }, 3000); }); -// ─── Event listeners ────────────────────────────────────────── -window.addEventListener('DOMContentLoaded', () => { - // Load config (turnstile, signups) - apiFetch('/api/config').then(cfg => { - window._turnstileSiteKey = cfg.turnstileSiteKey; - if (cfg.turnstileSiteKey) { - // Load Turnstile script +// ─── 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'; - document.head.appendChild(s); s.onload = () => { if (window.turnstile) { - window.turnstile.render('#auth-captcha-wrap', { sitekey: cfg.turnstileSiteKey }); + window.turnstile.render('#auth-captcha-wrap', { sitekey: d.turnstileSiteKey }); show('auth-captcha-wrap'); } }; + document.head.appendChild(s); } - }); + } catch { /* ignore */ } +} - updateAuthBar(); - initSocket(); - - // Score limit buttons - document.querySelectorAll('.score-btn').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.score-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - selectedScore = parseInt(btn.dataset.score); - }); - }); - - // Lobby tabs - 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(); - }); - }); - - // Visibility toggle (public/private) - 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'; - }); - }); - - // Public rooms refresh - $('btn-refresh-rooms').addEventListener('click', loadPublicRooms); - - // Create game - $('btn-create').addEventListener('click', () => { - const name = $('input-name').value.trim(); - if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } - $('lobby-error').textContent = ''; - myName = name; - socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic }); - }); - - // Join game - $('btn-join').addEventListener('click', joinGame); - - // Spectate - $('btn-spectate').addEventListener('click', () => { - const roomId = $('input-spectate-code').value.trim().toUpperCase(); - if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } - spectating = true; - socket.emit('spectate', { roomId }); - }); - - // Leave waiting room - $('btn-leave-waiting').addEventListener('click', () => { - clearSession(); - socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); - myRoomId = null; mySeat = -1; myToken = null; - showScreen('screen-lobby'); - }); - - // Fill bots - $('btn-fill-bots').addEventListener('click', () => { - socket.emit('fillBots', { roomId: myRoomId }); - }); - - // Copy room code - $('btn-copy').addEventListener('click', () => { - navigator.clipboard?.writeText($('display-room-code').textContent); - }); - - // Pass overlay confirm - $('btn-confirm-pass').addEventListener('click', confirmPass); - - // AFK banner - $('afk-vote-btn').addEventListener('click', () => { - socket.emit('voteAITakeover', { roomId: myRoomId }); - hide('afk-banner'); - }); - $('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner')); - - // Leave game - $('btn-leave-game').addEventListener('click', () => { - if (spectating) { - spectating = false; - clearSession(); - showScreen('screen-lobby'); - return; +// 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())); } - show('overlay-exit-confirm'); - hide('game-menu-dropdown'); - }); + if (window.caches) { + const keys = await caches.keys(); + await Promise.all(keys.map(k => caches.delete(k))); + } + } catch { /* ignore */ } + location.reload(); +} - $('btn-exit-confirm-yes').addEventListener('click', () => { - hide('overlay-exit-confirm'); - clearSession(); - socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); - myRoomId = null; mySeat = -1; myToken = null; spectating = false; - wasMyTurn = false; handleTurnReminder(false); - aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); - showScreen('screen-lobby'); - }); - $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); - - // New game (after game over) - $('btn-new-game').addEventListener('click', () => { - hide('overlay-gameover'); - clearSession(); - myRoomId = null; mySeat = -1; myToken = null; spectating = false; - wasMyTurn = false; handleTurnReminder(false); - aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); - showScreen('screen-lobby'); - }); - - // Game menu - // Play mode toggle (tap ↔ drag) β€” touch only - $('btn-play-mode').addEventListener('click', () => { - playMode = playMode === 'drag' ? 'tap' : 'drag'; - savePlayMode(playMode); - updatePlayModeBtn(); - if (lastState) renderMyHand(Array.isArray(lastState.hands[mySeat]) ? lastState.hands[mySeat] : [], lastState); - }); - - // Hand display mode β€” cycles: scroll β†’ fan β†’ playables β†’ scroll - $('btn-hand-mode').addEventListener('click', () => { - const idx = HAND_MODES.indexOf(handMode); - handMode = HAND_MODES[(idx + 1) % HAND_MODES.length]; - saveHandMode(handMode); - applyHandMode(); - updateHandModeBtn(); - if (lastState) renderMyHand(Array.isArray(lastState.hands[mySeat]) ? lastState.hands[mySeat] : [], lastState); - }); - - // Initialize mode buttons with saved preferences +// ─── 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); - $('btn-game-menu').addEventListener('click', (e) => { - e.stopPropagation(); - $('game-menu-dropdown').classList.toggle('hidden'); - }); - document.addEventListener('click', () => { hide('game-menu-dropdown'); }); + await validateAuth(); + if (authUser) $('input-name').value = authUser; - $('btn-info-pos').addEventListener('click', () => { - $('screen-game').classList.toggle('info-bottom'); - hide('game-menu-dropdown'); - }); - - $('btn-refresh-game').addEventListener('click', () => location.reload()); - - $('btn-exit-game').addEventListener('click', () => { - show('overlay-exit-confirm'); - hide('game-menu-dropdown'); - }); - - // Auth modal - $('btn-show-auth').addEventListener('click', () => show('overlay-auth')); - $('btn-auth-close').addEventListener('click', () => hide('overlay-auth')); - - 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 = ''; - }); - - // Profile - $('btn-show-profile').addEventListener('click', () => { - loadProfile(); - showScreen('screen-profile'); - }); - $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); - $('btn-logout').addEventListener('click', () => { - setAuth(null, null); - socket.disconnect(); - initSocket(); - }); - - $('btn-show-change-pass').addEventListener('click', () => { - $('change-pass-form').classList.toggle('hidden'); - }); - $('btn-do-change-pass').addEventListener('click', doChangePassword); - - $('btn-toggle-signups').addEventListener('click', async () => { - const data = await apiFetch('/api/admin/toggle-signups', { method: 'POST', body: {} }); - if (data.signupsOpen !== undefined) { - $('btn-toggle-signups').textContent = data.signupsOpen ? 'Open βœ“' : 'Closed βœ—'; - } - }); - - // Leaderboard - $('btn-show-leaderboard').addEventListener('click', () => { - loadLeaderboard(); - showScreen('screen-leaderboard'); - }); - $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); - $('btn-details-close').addEventListener('click', () => hide('overlay-player-details')); - - // PWA - $('btn-install').addEventListener('click', () => { - deferredInstallPrompt?.prompt(); - }); - $('pwa-banner-install').addEventListener('click', () => { - deferredInstallPrompt?.prompt(); - }); - $('pwa-banner-dismiss').addEventListener('click', () => { - $('pwa-banner').classList.remove('show'); - }); - - // Recalculate fan spacing on resize / orientation change - window.addEventListener('resize', () => { - if (handMode === 'fan' || handMode === 'playables') updateHandSpacing(); - }); + tryRejoin(); + // Authenticated with no local session: connect anyway so the server can push + // hasActiveGame for a cross-device rejoin + if (authToken && !myRoomId) connectSocket(() => {}); }); diff --git a/public/index.html b/public/index.html index a3dfb11..79a96c3 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ Hearts - + @@ -44,16 +44,16 @@
- -
- + +
+
-
+
@@ -62,19 +62,20 @@
-
-
- Public Rooms - -
-

Switch to this tab to load rooms.

-
- +
- -
+ +
+
+
+ Public Rooms + +
+

Loading…

+
+
@@ -84,28 +85,46 @@

+
- +
+ +
+ + +
+

Waiting for Players

-
πŸ”’ Private
Room Code β€”β€”

Share this code β€” 4 players needed

+
1β€”
2β€”
3β€”
4β€”
+

Every player for themselves β€” lowest score wins

Waiting for 3 more players…

- + +
@@ -116,23 +135,22 @@
- +
-
- - - - - + +
+ + +
@@ -141,11 +159,11 @@ - + @@ -153,30 +171,33 @@
+ + +
-
β€” +
-
-
β€” +
-
- +
@@ -189,14 +210,13 @@
-
β€” +
-
@@ -207,7 +227,7 @@ You - +
@@ -223,11 +243,12 @@

- Selected: + Passing:

+
@@ -236,8 +257,9 @@

-

+

+

Next hand starting…

@@ -248,6 +270,7 @@
πŸ†

+
@@ -270,7 +293,7 @@ -

Hokm accounts work here too

+

Hokm and Shelem accounts work here too

@@ -307,39 +330,50 @@ - +
β™₯

-
- β€” - Games Played + β€” + Win Rate
β€” Games Won
+
+ β€” + Games Played +
β€” Moon Shots πŸŒ™
+
+ β€” + Avg Score +
β€” Total Score
+
+ +
+

How to win

    -
  • 1 pt – each β™₯ heart taken
  • -
  • 13 pts – taking the β™ Q (Queen of Spades)
  • -
  • Shoot the Moon – take all 13 hearts + β™ Q: you score 0, everyone else scores 26
  • +
  • 1 pt β€” each β™₯ heart taken
  • +
  • 13 pts β€” taking the β™ Q
  • +
  • Shoot the Moon β€” take all 13 hearts + β™ Q: you score 0, everyone else 26
  • Game ends when any player reaches the score limit. Lowest score wins.
@@ -374,20 +408,14 @@
-
+
πŸ†

Leaderboard

- - - - - - - + @@ -396,10 +424,28 @@ + +
+
+
+ +
πŸ“œ
+

Game History

+

Loading…

+ +
+
+
+
#PlayerAvg β™₯WPlayedπŸŒ™#PlayerWin %WPAvgπŸŒ™
@@ -414,7 +460,7 @@

Leave Game?

Your session will be discarded.
You will not be able to rejoin.

- +
@@ -434,9 +480,7 @@ diff --git a/public/style.css b/public/style.css index 8d21e4b..122fbc4 100644 --- a/public/style.css +++ b/public/style.css @@ -2,18 +2,21 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { - --felt: #1d6b3a; - --felt-dark: #145228; - --felt-light: #268847; - --card-w: 68px; - --card-h: 98px; - --radius: 8px; - --shadow: 0 2px 8px rgba(0,0,0,.4); - --red: #d32f2f; - --black: #111; - --gold: #f5c518; - --heart-red: #e53935; - --muted: rgba(255,255,255,.55); + --felt: #1d6b3a; + --felt-dark: #133025; + --felt-light: #268847; + --card-w: 68px; + --card-h: 98px; + --radius: 8px; + --shadow: 0 2px 8px rgba(0,0,0,.4); + --gold: #f5c518; + --accent: #c8860a; + --red: #d32f2f; + --muted: rgba(255,255,255,.55); + --team-a: #4fc3f7; /* leading player highlight */ + --team-b: #ef9a9a; + --black: #111; + --heart-red: #e53935; } html, body { @@ -27,608 +30,435 @@ html, body { /* ── Screens ──────────────────────────────────── */ .screen { display: none; } -.screen.active { display: flex; flex-direction: column; height: 100vh; } +.screen.active { + display: flex; flex-direction: column; + height: 100vh; /* fallback */ + height: 100dvh; /* modern: excludes browser chrome / address bar */ +} /* ── Lobby ────────────────────────────────────── */ #screen-lobby { justify-content: center; align-items: center; - background: radial-gradient(ellipse at center, #1d6b3a 0%, #0c3a20 100%); + background: radial-gradient(ellipse at center, #1d5c3a 0%, var(--felt-dark) 100%); } .lobby-box { - background: rgba(0,0,0,.45); - border: 1px solid rgba(255,255,255,.15); + background: rgba(0,0,0,.5); + border: 1px solid rgba(255,255,255,.12); border-radius: 16px; - padding: 32px 36px; - width: min(420px, 94vw); + padding: 28px 34px; + width: min(430px, 94vw); display: flex; flex-direction: column; - gap: 16px; + gap: 14px; + max-height: 96vh; + overflow-y: auto; } .logo { font-size: 2.4rem; text-align: center; - color: var(--heart-red); - text-shadow: 0 2px 8px rgba(0,0,0,.5); + color: var(--gold); + text-shadow: 0 2px 12px rgba(200,130,10,.6); letter-spacing: 2px; } .tagline { text-align: center; - color: rgba(255,255,255,.6); - font-size: .9rem; - margin-top: -10px; + color: rgba(255,255,255,.55); + font-size: .85rem; + margin-top: -8px; } -.btn-install { - display: block; - margin: 8px auto 0; - padding: 7px 20px; - background: rgba(100,180,255,.15); - border: 1px solid rgba(100,180,255,.4); - border-radius: 20px; - color: rgba(180,220,255,.9); - font-size: .82rem; - cursor: pointer; - letter-spacing: .03em; -} -.btn-install:hover { background: rgba(100,180,255,.28); } - -/* ── PWA install banner ─────────────────────────────────────── */ -.pwa-banner { - position: fixed; - bottom: 0; left: 0; right: 0; - display: flex; - align-items: center; - gap: 10px; - padding: 12px 14px 14px; - background: #1a3354; - border-top: 1px solid rgba(100,180,255,.25); - box-shadow: 0 -4px 24px rgba(0,0,0,.5); - z-index: 9999; - transform: translateY(100%); - transition: transform .35s cubic-bezier(.22,1,.36,1); - padding-bottom: calc(14px + env(safe-area-inset-bottom)); -} -.pwa-banner.show { transform: translateY(0); } -.pwa-banner-icon { width: 44px; height: 44px; border-radius: 10px; flex-shrink: 0; } -.pwa-banner-text { flex: 1; display: flex; flex-direction: column; gap: 2px; } -.pwa-banner-text strong { color: #fff; font-size: .9rem; } -.pwa-banner-text span { color: rgba(255,255,255,.6); font-size: .75rem; line-height: 1.3; } -.pwa-banner-btn { padding: 7px 16px; background: #2979ff; border: none; border-radius: 8px; color: #fff; font-size: .85rem; font-weight: 600; cursor: pointer; white-space: nowrap; flex-shrink: 0; } -.pwa-banner-btn:hover { background: #448aff; } -.pwa-banner-close { background: none; border: none; color: rgba(255,255,255,.45); font-size: 1rem; cursor: pointer; padding: 4px; flex-shrink: 0; } -.pwa-banner-close:hover { color: rgba(255,255,255,.8); } - -.field label { - display: block; - font-size: .8rem; - color: rgba(255,255,255,.7); - margin-bottom: 4px; -} - -.field input { - width: 100%; - padding: 10px 14px; - border-radius: 8px; - border: 1px solid rgba(255,255,255,.2); - background: rgba(255,255,255,.1); - color: #fff; - font-size: 1rem; - outline: none; - transition: border-color .2s; -} -.field input:focus { border-color: var(--heart-red); } - -/* Score limit buttons */ -.score-limit-row { - display: flex; - gap: 8px; -} -.score-btn { - flex: 1; - padding: 8px; - border-radius: 8px; - border: 1px solid rgba(255,255,255,.2); - background: transparent; - color: rgba(255,255,255,.6); - font-size: .9rem; - cursor: pointer; - transition: all .2s; -} -.score-btn.active { - background: var(--heart-red); - color: #fff; - border-color: var(--heart-red); - font-weight: 700; -} - -.lobby-tabs { - display: flex; - gap: 8px; -} -.tab-btn { - flex: 1; - padding: 8px; - border-radius: 8px; - border: 1px solid rgba(255,255,255,.2); - background: transparent; - color: rgba(255,255,255,.6); - font-size: .9rem; - cursor: pointer; - transition: all .2s; -} -.tab-btn.active { - background: var(--gold); - color: #000; - border-color: var(--gold); - font-weight: 600; -} - -.tab-panel { display: none; flex-direction: column; gap: 12px; } -.tab-panel.active { display: flex; } - -.btn-primary { - padding: 12px 20px; - background: var(--gold); - color: #000; - border: none; - border-radius: 8px; - font-size: 1rem; - font-weight: 700; - cursor: pointer; - transition: opacity .2s, transform .1s; -} -.btn-primary:hover { opacity: .9; } -.btn-primary:active { transform: scale(.97); } -.btn-primary:disabled { opacity: .4; cursor: default; } - -.btn-secondary { - padding: 10px 20px; - background: rgba(255,255,255,.12); - color: rgba(255,255,255,.85); - border: 1px solid rgba(255,255,255,.25); - border-radius: 8px; - font-size: .9rem; - cursor: pointer; - transition: background .2s; -} -.btn-secondary:hover { background: rgba(255,255,255,.2); } - -.error-msg { color: #ff8a80; font-size: .85rem; min-height: 18px; text-align: center; } - /* ── Auth bar ─────────────────────────────────── */ .auth-bar { display: flex; align-items: center; justify-content: space-between; + gap: 8px; + padding: 6px 10px; + background: rgba(255,255,255,.06); + border: 1px solid rgba(255,255,255,.1); + border-radius: 10px; + font-size: .8rem; +} +.auth-status { color: var(--muted); } +.auth-bar-actions { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; } +.btn-text { + background: none; border: none; color: var(--gold); + cursor: pointer; font-size: .78rem; padding: 2px 5px; + text-decoration: underline; +} +.btn-text:hover { color: #fff; } + +/* ── Tabs ─────────────────────────────────────── */ +.lobby-tabs { display: flex; gap: 6px; } +.tab-btn { + flex: 1; + padding: 7px; background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12); border-radius: 8px; - padding: 8px 12px; - font-size: .82rem; - gap: 8px; -} -.auth-status { color: rgba(255,255,255,.7); flex: 1; } -.auth-bar-actions { display: flex; gap: 6px; flex-wrap: wrap; } -.btn-text { - background: none; - border: 1px solid rgba(255,255,255,.25); - border-radius: 6px; - color: rgba(255,255,255,.85); - cursor: pointer; - font-size: .78rem; - padding: 3px 8px; - white-space: nowrap; - transition: background .15s; -} -.btn-text:hover { background: rgba(255,255,255,.12); } - -/* ── Auth modal ───────────────────────────────── */ -.auth-box { position: relative; min-width: min(340px, 90vw); } -.btn-close { - position: absolute; - top: 10px; right: 14px; - background: none; - border: none; - color: rgba(255,255,255,.5); - font-size: 1.1rem; - cursor: pointer; - line-height: 1; -} -.btn-close:hover { color: #fff; } -.auth-tabs { display: flex; gap: 8px; margin-bottom: 16px; } -.auth-tab { - flex: 1; - padding: 8px; - background: rgba(255,255,255,.08); - border: 1px solid rgba(255,255,255,.15); - border-radius: 8px; - color: rgba(255,255,255,.7); - cursor: pointer; - font-size: .9rem; - transition: background .15s; -} -.auth-tab.active { - background: var(--gold); - border-color: var(--gold); - color: #000; - font-weight: 700; -} -.auth-panel { display: none; flex-direction: column; gap: 12px; } -.auth-panel.active { display: flex; } - -/* ── Profile / Leaderboard ────────────────────── */ -#screen-profile, -#screen-leaderboard { - justify-content: center; - align-items: center; - background: radial-gradient(ellipse at center, #1d6b3a 0%, #0c3a20 100%); - overflow-y: auto; -} -.profile-wrap { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - padding: 20px; -} -.profile-box { - background: rgba(0,0,0,.45); - border: 1px solid rgba(255,255,255,.15); - border-radius: 16px; - padding: 32px 36px; - width: min(440px, 96vw); - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; - position: relative; -} -.btn-back { - align-self: flex-start; - background: none; - border: 1px solid rgba(255,255,255,.25); - border-radius: 6px; - color: rgba(255,255,255,.8); + color: var(--muted); cursor: pointer; font-size: .85rem; - padding: 4px 12px; transition: background .15s; } -.btn-back:hover { background: rgba(255,255,255,.1); } -.profile-avatar { font-size: 3rem; } -.profile-name { font-size: 1.6rem; font-weight: 700; } +.tab-btn.active, .tab-btn:hover { background: rgba(255,255,255,.18); color: #fff; } +.tab-panel { display: none; flex-direction: column; gap: 10px; } +.tab-panel.active { display: flex; } -.stat-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; +/* ── Fields & inputs ──────────────────────────── */ +.field { display: flex; flex-direction: column; gap: 5px; } +.field label { font-size: .8rem; color: var(--muted); } +.field small { color: rgba(255,255,255,.4); font-size: .72rem; } +input[type=text], input[type=password], input[type=email] { width: 100%; + padding: 9px 12px; + background: rgba(255,255,255,.09); + border: 1px solid rgba(255,255,255,.18); + border-radius: 8px; + color: #fff; + font-size: .9rem; + outline: none; } -.stat-card { - background: rgba(255,255,255,.08); - border: 1px solid rgba(255,255,255,.12); - border-radius: 12px; - padding: 16px 12px; - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; -} -.stat-num { font-size: 2rem; font-weight: 700; color: var(--gold); } -.stat-label { font-size: .75rem; color: rgba(255,255,255,.6); text-align: center; } +input:focus { border-color: var(--gold); } -.points-legend { - background: rgba(255,255,255,.05); - border: 1px solid rgba(255,255,255,.1); - border-radius: 10px; - padding: 14px 16px; - width: 100%; -} -.points-legend h4 { font-size: .85rem; color: rgba(255,255,255,.6); margin-bottom: 8px; } -.points-legend ul { list-style: none; display: flex; flex-direction: column; gap: 6px; } -.points-legend li { font-size: .82rem; color: rgba(255,255,255,.75); } -.points-legend strong { color: var(--heart-red); } - -.profile-section { - width: 100%; - border: 1px solid rgba(255,255,255,.12); - border-radius: 10px; - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 8px; -} -.admin-section { border-color: rgba(255,193,7,.3); } -.admin-title { font-size: .9rem; color: var(--gold); margin: 0 0 4px; } -.admin-row { - display: flex; - align-items: center; - justify-content: space-between; - font-size: .88rem; - color: rgba(255,255,255,.8); -} -.btn-admin-toggle { font-size: .82rem; padding: 4px 12px; min-width: 90px; } - -/* ── Leaderboard ──────────────────────────────── */ -.lb-table { width: 100%; border-collapse: collapse; font-size: .9rem; } -.lb-table th { - text-align: left; - padding: 6px 10px; - color: rgba(255,255,255,.5); - font-size: .75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: .04em; - border-bottom: 1px solid rgba(255,255,255,.12); -} -.lb-table td { padding: 9px 10px; border-bottom: 1px solid rgba(255,255,255,.06); } -.lb-table tr:last-child td { border-bottom: none; } -.lb-rank { font-size: 1.1rem; width: 36px; } -.lb-name { font-weight: 600; } -.lb-top td { background: rgba(245,197,24,.06); } -.lb-btn { - background: none; - border: 1px solid rgba(255,255,255,.2); - color: rgba(255,255,255,.7); - border-radius: 4px; - font-size: .75rem; - padding: 2px 8px; +/* ── Visibility toggle (Private / Public) ─────── */ +.visibility-row { display: flex; gap: 6px; } +.visibility-btn { + flex: 1; + padding: 8px 6px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.14); + border-radius: 8px; + color: var(--muted); cursor: pointer; + font-size: .82rem; + text-align: center; + transition: background .15s; } -.lb-btn:hover { background: rgba(255,255,255,.1); } +.visibility-btn.active, .visibility-btn:hover { + background: rgba(245,197,24,.2); + border-color: var(--gold); + color: var(--gold); +} + +/* ── Mode / score buttons ─────────────────────── */ +.mode-row, .score-row { display: flex; gap: 6px; } +.mode-btn, .score-btn { + flex: 1; + padding: 8px 6px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.14); + border-radius: 8px; + color: var(--muted); + cursor: pointer; + font-size: .82rem; + text-align: center; + transition: background .15s; +} +.mode-btn small { display: block; font-size: .72rem; color: rgba(255,255,255,.4); margin-top: 2px; } +.mode-btn.active, .mode-btn:hover, +.score-btn.active, .score-btn:hover { + background: rgba(245,197,24,.2); + border-color: var(--gold); + color: var(--gold); +} + +/* ── Buttons ──────────────────────────────────── */ +.btn-primary { + padding: 10px; + background: rgba(245,197,24,.25); + border: 1px solid rgba(245,197,24,.55); + border-radius: 10px; + color: var(--gold); + font-size: .95rem; + font-weight: 600; + cursor: pointer; + transition: background .15s; +} +.btn-primary:hover { background: rgba(245,197,24,.38); } +.btn-primary:disabled { opacity: .4; cursor: default; } + +.btn-secondary { + padding: 9px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.16); + border-radius: 10px; + color: var(--muted); + font-size: .9rem; + cursor: pointer; + transition: background .15s; +} +.btn-secondary:hover { background: rgba(255,255,255,.15); } + +.divider { border: none; border-top: 1px solid rgba(255,255,255,.1); margin: 2px 0; } +.error-msg { color: #ff6b6b; font-size: .82rem; min-height: 16px; } +.hint { color: var(--muted); font-size: .82rem; text-align: center; } /* ── Waiting Room ─────────────────────────────── */ #screen-waiting { justify-content: center; align-items: center; - background: radial-gradient(ellipse at center, #1d6b3a 0%, #0c3a20 100%); + background: radial-gradient(ellipse at center, #1d5c3a 0%, var(--felt-dark) 100%); } .waiting-box { - background: rgba(0,0,0,.45); - border: 1px solid rgba(255,255,255,.15); + background: rgba(0,0,0,.48); + border: 1px solid rgba(255,255,255,.12); border-radius: 16px; - padding: 24px 36px 32px; - width: min(440px, 94vw); + padding: 28px 34px; + width: min(380px, 94vw); display: flex; flex-direction: column; - gap: 20px; + gap: 14px; align-items: center; + text-align: center; } -.waiting-box .btn-leave-screen { align-self: flex-start; } -.waiting-box h2 { font-size: 1.4rem; color: var(--heart-red); } +.btn-leave-screen { + align-self: flex-start; + background: none; border: none; color: var(--muted); + cursor: pointer; font-size: .85rem; + padding: 0; text-decoration: underline; +} +.btn-leave-screen:hover { color: #fff; } .room-code-box { - display: flex; - align-items: center; - gap: 12px; - background: rgba(0,0,0,.3); + display: flex; align-items: center; gap: 8px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.14); border-radius: 10px; - padding: 12px 20px; -} -.room-code-box .label { font-size: .8rem; color: rgba(255,255,255,.6); } -.room-code { - font-size: 1.8rem; - font-weight: 800; - letter-spacing: 4px; - color: var(--gold); + padding: 8px 14px; } +.room-code-box .label { font-size: .75rem; color: var(--muted); } +.room-code { font-size: 1.5rem; font-weight: 700; letter-spacing: 3px; color: var(--gold); } .btn-copy { - background: rgba(255,255,255,.15); - border: 1px solid rgba(255,255,255,.3); - color: #fff; - border-radius: 6px; - padding: 6px 10px; - font-size: 1rem; - cursor: pointer; + background: none; border: none; color: var(--muted); + cursor: pointer; font-size: 1rem; padding: 2px 4px; } -.btn-copy:hover { background: rgba(255,255,255,.25); } - -.hint { font-size: .82rem; color: rgba(255,255,255,.5); text-align: center; } .waiting-seats { display: grid; grid-template-columns: 1fr 1fr; - gap: 10px; + gap: 8px; width: 100%; } .seat-slot { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - background: rgba(255,255,255,.08); - border: 1px solid rgba(255,255,255,.15); - border-radius: 10px; - padding: 12px; -} -.seat-slot.filled { background: rgba(229,57,53,.18); border-color: var(--heart-red); } -.seat-num { font-size: .75rem; color: rgba(255,255,255,.4); } -.seat-name { font-size: .9rem; font-weight: 600; } -.waiting-status { color: rgba(255,255,255,.7); font-size: .9rem; } - -.btn-fill-bots { - padding: 9px 18px; - background: rgba(255,255,255,.1); - border: 1px solid rgba(255,255,255,.25); + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.12); border-radius: 8px; - color: rgba(255,255,255,.85); - cursor: pointer; - font-size: .9rem; - transition: background .15s; + font-size: .85rem; } -.btn-fill-bots:hover { background: rgba(255,255,255,.18); } +.seat-slot.partner-a { border-color: rgba(79,195,247,.3); } +.seat-slot.partner-b { border-color: rgba(239,154,154,.3); } +.seat-num { color: var(--muted); font-size: .75rem; min-width: 14px; } +.seat-name { font-weight: 600; color: #fff; } +.waiting-status { font-size: .88rem; color: var(--muted); } +.waiting-options { display: flex; gap: 10px; justify-content: center; } +.waiting-opt-label { + font-size: .78rem; color: var(--gold); + padding: 2px 8px; + background: rgba(245,197,24,.1); + border: 1px solid rgba(245,197,24,.25); + border-radius: 12px; +} +/* ── Public rooms list ────────────────────────── */ +.public-rooms-section { display: flex; flex-direction: column; gap: 6px; width: 100%; } +.rooms-header { + display: flex; align-items: center; justify-content: space-between; + font-size: .78rem; color: var(--muted); +} +.rooms-label { font-weight: 600; letter-spacing: .5px; } +.public-room-item { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 8px 10px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.1); + border-radius: 8px; +} +.public-room-info { display: flex; flex-direction: column; gap: 2px; text-align: left; } +.public-room-host { font-size: .85rem; font-weight: 600; color: #fff; } +.public-room-meta { font-size: .72rem; color: var(--muted); } +.btn-join-pub { padding: 5px 14px; font-size: .82rem; flex-shrink: 0; } -/* ── Game Screen layout ───────────────────────── */ -#screen-game { - flex-direction: column; - height: 100vh; - height: 100dvh; - overflow: hidden; - background: radial-gradient(ellipse at center, var(--felt-light) 0%, var(--felt) 50%, var(--felt-dark) 100%); +/* ── Creator controls in waiting room ────────── */ +.waiting-creator-controls { + display: flex; gap: 8px; width: 100%; +} +.btn-fill-bots { + flex: 1; + padding: 8px 10px; + background: rgba(255,255,255,.08); + border: 1px solid rgba(255,255,255,.16); + border-radius: 10px; + color: var(--muted); + cursor: pointer; + font-size: .82rem; +} +.btn-fill-bots:hover { background: rgba(255,255,255,.16); color: #fff; } +.btn-start-game { + flex: 1; + padding: 8px 10px; + background: rgba(100,200,100,.15); + border: 1px solid rgba(100,200,100,.35); + border-radius: 10px; + color: #a5d6a7; + cursor: pointer; + font-size: .82rem; + font-weight: 600; +} +.btn-start-game:hover { background: rgba(100,200,100,.28); color: #c8e6c9; } + +/* ── Seat swap (creator taps to reorder) ─────── */ +.seat-slot.seat-swappable { cursor: pointer; transition: background .12s, border-color .12s; } +.seat-slot.seat-swappable:hover { background: rgba(255,255,255,.1); } +.seat-slot.seat-swap-selected { + border-color: var(--gold); + background: rgba(245,197,24,.12); } -.btn-leave-screen { - background: none; - border: 1px solid rgba(255,255,255,.25); - color: rgba(255,255,255,.7); - border-radius: 6px; - cursor: pointer; - font-size: .78rem; - padding: 4px 10px; - white-space: nowrap; - flex-shrink: 0; -} -.btn-leave-screen:hover { background: rgba(255,255,255,.1); color: #fff; } +/* ── Game screen ──────────────────────────────── */ +#screen-game { background: var(--felt); overflow: hidden; position: relative; } /* Info bar */ #info-bar { display: flex; align-items: center; - justify-content: space-between; - padding: 4px 16px; + gap: 8px; + padding: 6px 10px; background: rgba(0,0,0,.35); - border-bottom: 1px solid rgba(255,255,255,.1); + border-bottom: 1px solid rgba(0,0,0,.3); flex-shrink: 0; - font-size: .85rem; - gap: 10px; - order: 0; + z-index: 10; } -#screen-game.info-bottom #info-bar { - order: 10; - border-top: 1px solid rgba(255,255,255,.1); - border-bottom: none; -} -#screen-game.info-bottom #table-grid { order: 1; } -#screen-game.info-bottom #my-area { order: 5; } -#screen-game.info-bottom .game-menu-dropdown { top: auto; bottom: calc(100% + 6px); } #score-block { display: flex; - flex-direction: column; - align-items: center; - gap: 2px; + gap: 8px; flex: 1; + justify-content: center; + min-width: 0; + flex-wrap: wrap; } #score-display { display: flex; - align-items: center; - gap: 6px; + gap: 5px; flex-wrap: wrap; justify-content: center; + min-width: 0; } .score-entry { display: flex; - align-items: center; - gap: 3px; - font-size: .8rem; -} -.score-name { color: rgba(255,255,255,.6); font-size: .72rem; max-width: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.score-val { font-size: 1rem; font-weight: 800; color: var(--gold); min-width: 20px; text-align: center; } -.score-entry.my-score .score-name { color: rgba(255,255,255,.9); } -.score-entry.my-score .score-val { color: #fff; } -.score-entry.leading .score-val { color: var(--heart-red); } - -#hand-points-display { - display: flex; - align-items: center; + align-items: baseline; gap: 4px; - font-size: .68rem; - color: rgba(255,255,255,.5); - border-top: 1px solid rgba(255,255,255,.1); - padding-top: 2px; - justify-content: center; -} -.hp-entry { display: flex; gap: 2px; align-items: center; } -.hp-name { color: rgba(255,255,255,.45); font-size: .65rem; } -.hp-val { font-weight: 700; color: rgba(255,255,255,.75); min-width: 14px; text-align: center; } -.hp-val.has-pts { color: var(--heart-red); } - -.hearts-broken { - font-size: .75rem; - color: var(--heart-red); - background: rgba(229,57,53,.15); - border: 1px solid rgba(229,57,53,.35); - border-radius: 10px; + font-size: .74rem; padding: 2px 8px; - white-space: nowrap; - flex-shrink: 0; + border-radius: 20px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.12); + min-width: 0; } - -.pass-dir-display { - font-size: .78rem; +.score-entry .score-name { + max-width: 72px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: rgba(255,255,255,.75); - background: rgba(100,180,255,.15); - border: 1px solid rgba(100,180,255,.3); - border-radius: 10px; +} +.score-entry .score-val { font-weight: 700; color: #fff; } +/* You */ +.score-entry.my-score { + background: rgba(245,197,24,.16); + border-color: rgba(245,197,24,.4); +} +.score-entry.my-score .score-name { color: var(--gold); } +/* Currently lowest score = currently winning */ +.score-entry.leading { + background: rgba(105,240,174,.15); + border-color: rgba(105,240,174,.4); +} +.score-entry.leading .score-val { color: #69f0ae; } + +#game-meta { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } +.hearts-broken { + font-size: .75rem; font-weight: 700; padding: 2px 8px; + border-radius: 12px; + background: rgba(229,57,53,.22); + border: 1px solid rgba(229,57,53,.45); + color: #ff8a80; + white-space: nowrap; +} +.pass-dir-display { + font-size: .75rem; font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + background: rgba(245,197,24,.18); + border: 1px solid rgba(245,197,24,.4); + color: var(--gold); white-space: nowrap; - flex-shrink: 0; } -/* Hamburger menu */ -.game-menu-wrap { position: relative; flex-shrink: 0; } +.game-menu-wrap { position: relative; } .game-menu-dropdown { position: absolute; top: calc(100% + 6px); right: 0; - background: #0e2235; - border: 1px solid rgba(255,255,255,.18); + background: #1a2e1a; + border: 1px solid rgba(255,255,255,.15); border-radius: 10px; - padding: 6px 0; - min-width: 160px; + min-width: 140px; z-index: 200; - box-shadow: 0 8px 28px rgba(0,0,0,.55); - display: flex; - flex-direction: column; + overflow: hidden; } -.game-menu-dropdown.hidden { display: none; } .game-menu-item { - background: none; - border: none; - color: rgba(255,255,255,.85); - font-size: .85rem; - padding: 10px 16px; - text-align: left; - cursor: pointer; - white-space: nowrap; - transition: background .12s; + display: block; width: 100%; text-align: left; + padding: 9px 14px; + background: none; border: none; + color: #eee; font-size: .85rem; cursor: pointer; } .game-menu-item:hover { background: rgba(255,255,255,.1); } -.game-menu-exit { color: rgba(255,140,140,.9); } -.game-menu-exit:hover { background: rgba(200,50,50,.2); } +.game-menu-exit { color: #ff8080; } -.hidden { display: none !important; } .spectator-banner { + background: rgba(0,0,0,.5); text-align: center; - padding: 6px; + padding: 5px; font-size: .8rem; - background: rgba(0,0,0,.4); - color: rgba(255,255,255,.6); - border-bottom: 1px solid rgba(255,255,255,.08); + color: var(--muted); flex-shrink: 0; } -/* Table grid */ +/* ── Table grid ───────────────────────────────── */ #table-grid { - display: grid; - grid-template-areas: "top top top" "left trick right"; - grid-template-columns: 80px 1fr 80px; - grid-template-rows: auto 1fr; flex: 1; + display: grid; + grid-template-columns: 90px 1fr 90px; + grid-template-rows: auto 1fr; + gap: 4px; + padding: 6px; + overflow: hidden; min-height: 0; - gap: 8px; - padding: 8px; } -.player-area { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; } -.area-top { grid-area: top; flex-direction: row; align-items: center; justify-content: center; gap: 12px; padding: 4px 0; } -.area-left { grid-area: left; } -.area-right { grid-area: right; } -#trick-area { grid-area: trick; } +.player-area { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.area-top { grid-column: 2; grid-row: 1; } +.area-left { grid-column: 1; grid-row: 1 / 3; justify-content: center; } +.area-right { grid-column: 3; grid-row: 1 / 3; justify-content: center; } + +#trick-area { + grid-column: 2; + grid-row: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + position: relative; +} .player-label { display: flex; @@ -643,71 +473,23 @@ html, body { } .player-label.vertical { flex-direction: column; font-size: .72rem; } -.turn-dot { font-size: .6rem; color: #4fc3f7; animation: blink 1s infinite; } -@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:.25; } } +.turn-dot { color: var(--gold); font-size: .6rem; animation: pulse 1s infinite; } +@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} } .player-score-badge { - font-size: .7rem; - font-weight: 700; - color: var(--gold); - min-width: 18px; - text-align: center; + font-size: .7rem; color: var(--gold); + padding: 1px 5px; + background: rgba(245,197,24,.12); + border-radius: 8px; +} +.tricks-badge { + font-size: .7rem; color: var(--muted); + padding: 1px 5px; + background: rgba(255,255,255,.08); + border-radius: 8px; } -.hand-pts-badge { - font-size: .72rem; - color: var(--heart-red); - font-weight: 700; - min-width: 18px; -} - -/* Trick area */ -#trick-area { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; -} -.trick-slot { - width: var(--card-w); - height: var(--card-h); - display: flex; - align-items: center; - justify-content: center; -} -.trick-slot img { width: var(--card-w); height: var(--card-h); border-radius: 6px; box-shadow: var(--shadow); } -.trick-middle-row { - display: flex; - align-items: center; - gap: 4px; -} -.trick-center-info { - width: 60px; - text-align: center; -} -.phase-msg { - font-size: .72rem; - color: rgba(255,255,255,.6); - line-height: 1.4; -} -.trick-winner-flash { animation: flash-win .6s ease-out; } -@keyframes flash-win { 0% { filter: brightness(1.8); } 100% { filter: brightness(1); } } - -/* Opponent card piles */ -.opp-cards { - display: flex; - flex-wrap: wrap; - gap: 3px; - justify-content: center; - max-width: 140px; -} -.opp-cards.vertical { - flex-direction: column; - max-width: 36px; - max-height: 200px; - overflow: hidden; -} +/* ── Card back / face-down ────────────────────── */ .card-back { width: 36px; height: 52px; @@ -715,29 +497,176 @@ html, body { border-radius: 4px; border: 1px solid rgba(255,255,255,.25); box-shadow: 0 1px 3px rgba(0,0,0,.4); + flex-shrink: 0; } -.opp-cards.vertical .card-back { width: 30px; height: 42px; } -/* My hand */ +/* ── Playing cards ────────────────────────────── */ +.card { + width: var(--card-w); + height: var(--card-h); + border-radius: var(--radius); + box-shadow: var(--shadow); + cursor: pointer; + flex-shrink: 0; + transition: transform .12s, box-shadow .12s; + position: relative; + overflow: hidden; + border: 2px solid transparent; + background: #fff; +} +.card img { width: 100%; height: 100%; object-fit: contain; display: block; border-radius: 6px; } +.card:hover { transform: translateY(-8px); box-shadow: 0 8px 20px rgba(0,0,0,.5); } +.card.selected { + transform: translateY(-12px); + border-color: var(--gold); + box-shadow: 0 0 12px rgba(245,197,24,.6); +} +.card.illegal { cursor: default; } +.card.illegal::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(0,0,0,.55); + border-radius: 6px; + pointer-events: none; +} + +/* Magnifier (drag mode, dense fan) */ +.card.magnified { + transform: translateY(-34px) scale(1.5) !important; + z-index: 50 !important; + box-shadow: 0 14px 36px rgba(0,0,0,.75) !important; + border: 2px solid #fff !important; + transition: transform .1s, box-shadow .1s !important; +} + +/* Joker identification badges */ + +/* ── Trick area ───────────────────────────────── */ +.trick-slot { + width: var(--card-w); + height: var(--card-h); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.trick-slot img { + width: var(--card-w); + height: var(--card-h); + border-radius: 6px; + box-shadow: var(--shadow); + display: block; +} +.trick-card-wrap { + position: relative; + width: var(--card-w); + height: var(--card-h); + flex-shrink: 0; +} +.trick-card-wrap img { + width: 100%; + height: 100%; + border-radius: 6px; + box-shadow: var(--shadow); + display: block; +} +.trick-middle-row { + display: flex; align-items: center; justify-content: center; gap: 4px; +} +.trick-center-info { + flex: 0 0 80px; + text-align: center; +} +.phase-msg { + font-size: .72rem; color: rgba(255,255,255,.6); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.phase-msg.your-turn { + color: var(--gold); + font-weight: 700; + font-size: .78rem; + animation: your-turn-flash .8s ease-in-out infinite alternate; +} +@keyframes your-turn-flash { + from { opacity: .7; } + to { opacity: 1; } +} +/* Escalating urgency β€” applied every 5 s while it's your turn */ +.phase-msg.your-turn-lvl1 { + font-size: .96rem; + color: #ffc400; + animation: your-turn-flash .6s ease-in-out infinite alternate; +} +.phase-msg.your-turn-lvl2 { + font-size: 1.18rem; + color: #ffeb3b; + font-weight: 800; + text-shadow: 0 0 10px rgba(255, 220, 0, 0.6); + animation: your-turn-flash .4s ease-in-out infinite alternate; +} +.phase-msg.your-turn-lvl3 { + font-size: 1.4rem; + color: #fff; + font-weight: 800; + text-shadow: 0 0 22px rgba(255, 200, 0, 1), 0 0 6px rgba(255,255,255,.8); + animation: your-turn-flash .25s ease-in-out infinite alternate; +} + +/* Drop-zone pulse when dragging */ +#trick-area { position: relative; } +#trick-area.drag-active::before { + content: ''; + position: absolute; + inset: -10px; + border: 2px dashed rgba(255,255,255,.4); + border-radius: 18px; + pointer-events: none; + animation: pulse-border .8s ease-in-out infinite alternate; +} +@keyframes pulse-border { from { opacity: .35; } to { opacity: 1; } } + +/* Drag ghost */ +.drag-ghost { + position: fixed; + pointer-events: none; + opacity: .92; + z-index: 9999; + transform: scale(1.15) rotate(-4deg); + box-shadow: 0 12px 32px rgba(0,0,0,.7); + border-radius: 6px; + width: var(--card-w); + height: var(--card-h); + overflow: hidden; + transition: none !important; +} +.drag-ghost img { width: 100%; height: 100%; display: block; } + +/* ── My hand ──────────────────────────────────── */ #my-area { flex-shrink: 0; - padding: 6px 12px 10px; - background: rgba(0,0,0,.2); - border-top: 1px solid rgba(255,255,255,.08); display: flex; flex-direction: column; - gap: 6px; - padding-bottom: calc(10px + env(safe-area-inset-bottom)); + gap: 4px; + padding: 4px 8px calc(8px + env(safe-area-inset-bottom)); + background: rgba(0,0,0,.2); + border-top: 1px solid rgba(0,0,0,.3); } +#my-area.fan-active { overflow: visible; } + #my-label { display: flex; align-items: center; gap: 6px; - font-size: .82rem; + font-size: .8rem; font-weight: 600; } -/* ── Hand display: scroll mode (default) ─────────────── */ -.my-hand { + +/* Scroll mode (default) */ +#my-hand { display: flex; align-items: flex-end; gap: 4px; @@ -747,19 +676,17 @@ html, body { scrollbar-width: none; justify-content: safe center; } -.my-hand::-webkit-scrollbar { display: none; } +#my-hand::-webkit-scrollbar { display: none; } -/* ── Hand display: fan/overlap mode (Hokm-style) ─────── */ -.my-hand.fan-mode { +/* Fan / overlap mode */ +#my-hand.fan-mode { gap: 0; overflow: visible; padding: 14px 4px 4px; justify-content: flex-start; } -/* Allow cards to pop up above the hand area in fan mode */ -#my-area.fan-active { overflow: visible; } -/* ── Play mode / hand mode toggle buttons ─────────────── */ +/* Play / hand mode toggle buttons */ .btn-play-mode, .btn-hand-mode { margin-left: auto; @@ -772,8 +699,7 @@ html, body { cursor: pointer; white-space: nowrap; } -.btn-play-mode:hover, -.btn-hand-mode:hover { background: rgba(255,255,255,.22); } +.btn-play-mode:hover, .btn-hand-mode:hover { background: rgba(255,255,255,.22); } .btn-play-mode.drag-mode { background: rgba(100,200,100,.18); border-color: #66bb6a; @@ -785,89 +711,58 @@ html, body { color: rgba(180,220,255,.9); } -/* ── Card magnifier (drag mode with crowded hand) ─────── */ -.card.magnified { - transform: translateY(-34px) scale(1.5) !important; - z-index: 50 !important; - box-shadow: 0 14px 36px rgba(0,0,0,.75) !important; - border: 2px solid #fff !important; - transition: transform .1s, box-shadow .1s !important; -} - -/* ── Drag ghost ───────────────────────────────────────── */ -.drag-ghost { +/* ── Overlays ─────────────────────────────────── */ +.overlay { position: fixed; - pointer-events: none; - opacity: 0.92; - z-index: 9999; - transform: scale(1.15) rotate(-4deg); - box-shadow: 0 12px 32px rgba(0,0,0,.7); - border-radius: 6px; - width: var(--card-w); - height: var(--card-h); - overflow: hidden; - transition: none !important; -} -.drag-ghost img { width: 100%; height: 100%; display: block; } - -/* ── Drop-zone pulse when dragging ───────────────────── */ -#trick-area { position: relative; } -#trick-area.drag-active::before { - content: ''; - position: absolute; - inset: -10px; - border: 2px dashed rgba(255,255,255,.4); - border-radius: 18px; - pointer-events: none; - animation: pulse-border .8s ease-in-out infinite alternate; -} -@keyframes pulse-border { - from { opacity: .35; } - to { opacity: 1; } -} -.card { - width: var(--card-w); - height: var(--card-h); - flex-shrink: 0; - cursor: pointer; - border-radius: 6px; - box-shadow: var(--shadow); - transition: transform .15s, box-shadow .15s; - position: relative; -} -.card img { width: 100%; height: 100%; border-radius: 6px; display: block; } -.card:hover { transform: translateY(-8px); box-shadow: 0 6px 16px rgba(0,0,0,.5); } -.card.selected { transform: translateY(-12px); box-shadow: 0 0 0 3px var(--heart-red), 0 8px 20px rgba(0,0,0,.5); } -.card.illegal { cursor: default; } -/* Solid overlay keeps the card opaque so it never bleeds through fan neighbours */ -.card.illegal::after { - content: ''; - position: absolute; inset: 0; - background: rgba(0,0,0,.55); - border-radius: 6px; - pointer-events: none; + background: rgba(0,0,0,.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 12px; } -.card.illegal:hover { transform: none; box-shadow: var(--shadow); } -.card.my-play { animation: play-out .3s ease-out forwards; } -@keyframes play-out { to { transform: scale(.9); opacity: 0; } } +.overlay.hidden { display: none; } -/* ── Pass overlay ─────────────────────────────── */ -.pass-box { +.overlay-box { + background: #1a2e1a; + border: 1px solid rgba(255,255,255,.15); + border-radius: 16px; + padding: 26px 28px; + width: min(440px, 96vw); + display: flex; + flex-direction: column; + gap: 14px; max-height: 92vh; overflow-y: auto; - width: min(500px, 96vw); } -#pass-title { color: var(--heart-red); font-size: 1.2rem; margin-bottom: 4px; } -.pass-hint { font-size: .82rem; color: var(--muted); margin-bottom: 8px; } +.overlay-box.small { max-width: 320px; } +.overlay-box h3 { font-size: 1.1rem; color: var(--gold); text-align: center; } +.overlay-leave-btn { + align-self: center; + margin-top: 4px; + background: none; + border: none; + color: rgba(255,128,128,.75); + font-size: .82rem; + cursor: pointer; + padding: 4px 8px; +} +.overlay-leave-btn:hover { color: #ff8080; text-decoration: underline; } -/* One row per suit */ +/* ── Pass overlay (3-card exchange) ───────────── */ +.pass-box {} +.pass-hint { font-size: .82rem; color: var(--muted); text-align: center; } + +/* One row per suit β€” matches Hearts pass overlay */ .pass-hand { display: flex; flex-direction: column; gap: 8px; - margin-bottom: 12px; + margin-bottom: 4px; width: 100%; + max-height: 55vh; + overflow-y: auto; } .pass-suit-row { display: flex; @@ -882,7 +777,7 @@ html, body { line-height: 1; } .pass-suit-label.suit-h, -.pass-suit-label.suit-d { color: var(--heart-red); } +.pass-suit-label.suit-d { color: #ff8080; } .pass-suit-label.suit-c, .pass-suit-label.suit-s { color: #ddd; } .pass-suit-cards { @@ -892,17 +787,13 @@ html, body { } .pass-hand .card { width: 58px; height: 82px; } .pass-hand .card img { width: 58px; height: 82px; } + .pass-selected-row { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; + display: flex; align-items: center; gap: 8px; + min-height: 36px; } .pass-selected-label { font-size: .8rem; color: var(--muted); white-space: nowrap; } -.pass-selected-preview { - display: flex; - gap: 6px; -} +.pass-selected-preview { display: flex; gap: 4px; flex-wrap: wrap; } .pass-selected-preview .card { width: 48px; height: 68px; } .pass-selected-preview .card img { width: 48px; height: 68px; } .pass-placeholder { @@ -911,273 +802,490 @@ html, body { border-radius: 6px; } -/* ── Overlays ─────────────────────────────────── */ -.overlay { - position: fixed; - inset: 0; - background: rgba(0,0,0,.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - padding: 16px; -} -.overlay-box { - background: rgba(10,28,20,.95); - border: 1px solid rgba(255,255,255,.18); - border-radius: 16px; - padding: 28px 32px; - display: flex; - flex-direction: column; - align-items: center; - gap: 14px; - max-width: 420px; - width: 100%; - text-align: center; -} -.overlay-box.small { max-width: 320px; padding: 24px; } -.result-icon { font-size: 3rem; } -.result-icon.big { font-size: 4rem; } +/* ── Hand / game over overlays ────────────────── */ +.result-icon { font-size: 2.5rem; text-align: center; } +.result-icon.big { font-size: 3rem; } .result-scores { display: flex; flex-direction: column; gap: 6px; - width: 100%; } -.result-row { +.result-score-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: rgba(255,255,255,.06); border-radius: 8px; - font-size: .92rem; + font-size: .88rem; } -.result-row.winner { background: rgba(245,197,24,.12); border: 1px solid rgba(245,197,24,.3); } -.result-row.moon { background: rgba(229,57,53,.12); border: 1px solid rgba(229,57,53,.35); } -.result-delta { font-weight: 700; } -.result-delta.red { color: var(--heart-red); } -.result-delta.gold { color: var(--gold); } -.result-delta.zero { color: #69f0ae; } +.result-score-row.winner { background: rgba(245,197,24,.12); border: 1px solid rgba(245,197,24,.3); } +.result-score-row .team-label { font-weight: 600; } +.result-score-row .delta { font-weight: 700; } +.delta.pos { color: #69f0ae; } +.delta.neg { color: #ff6b6b; } .gameover-scores { display: flex; flex-direction: column; - gap: 6px; - width: 100%; - margin-bottom: 8px; + gap: 8px; + margin: 4px 0; } -.go-row { +.gameover-row { display: flex; justify-content: space-between; - align-items: center; padding: 8px 14px; + border-radius: 10px; background: rgba(255,255,255,.06); - border-radius: 8px; -} -.go-row.winner { - background: rgba(245,197,24,.15); - border: 1px solid rgba(245,197,24,.4); -} -.go-name { font-weight: 600; } -.go-score { font-size: 1.3rem; font-weight: 800; color: var(--gold); } -.go-row.winner .go-score { color: #fff; } -.go-badge { font-size: .8rem; color: rgba(255,255,255,.5); margin-left: 6px; } - -/* Modal box (player details) */ -.modal-box { - background: rgba(10,28,20,.95); - border: 1px solid rgba(255,255,255,.18); - border-radius: 16px; - padding: 24px 28px; - width: min(360px, 95vw); - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -/* Spinner */ -.spinner { - width: 36px; height: 36px; - border: 3px solid rgba(255,255,255,.15); - border-top-color: var(--gold); - border-radius: 50%; - animation: spin .7s linear infinite; - margin: 8px auto; -} -@keyframes spin { to { transform: rotate(360deg); } } - -/* ── Responsive ────────────────────────────────── */ -@media (max-width: 400px) { - .lobby-box { padding: 24px 20px; } - .profile-box { padding: 24px 20px; } - :root { --card-w: 56px; --card-h: 80px; } -} -@media (max-height: 600px) { - :root { --card-w: 52px; --card-h: 74px; } - #my-area { padding-bottom: 4px; } -} - -/* ── Visibility toggle (Create tab) ──────────── */ -.visibility-toggle { - display: flex; - gap: 8px; -} -.visibility-btn { - flex: 1; - padding: 8px; - border-radius: 8px; - border: 1px solid rgba(255,255,255,.2); - background: transparent; - color: rgba(255,255,255,.6); font-size: .9rem; - cursor: pointer; - transition: all .2s; -} -.visibility-btn.active { - background: rgba(100,180,255,.2); - border-color: rgba(100,180,255,.5); - color: rgba(200,230,255,.95); - font-weight: 600; } +.gameover-row.winner { background: rgba(245,197,24,.15); border: 1px solid rgba(245,197,24,.4); } -/* ── Public rooms list (Join tab) ─────────────── */ -.public-rooms-section { - display: flex; - flex-direction: column; - gap: 6px; +/* ── Auth overlay ─────────────────────────────── */ +.auth-box { max-width: 360px; } +.btn-close { + align-self: flex-end; + background: none; border: none; + color: var(--muted); font-size: 1rem; cursor: pointer; + line-height: 1; } -.rooms-header { - display: flex; - align-items: center; - justify-content: space-between; - font-size: .8rem; - color: rgba(255,255,255,.55); -} -.btn-refresh { - background: none; - border: 1px solid rgba(255,255,255,.2); - border-radius: 6px; - color: rgba(255,255,255,.6); - font-size: .75rem; - padding: 2px 8px; - cursor: pointer; - transition: background .15s; -} -.btn-refresh:hover { background: rgba(255,255,255,.1); color: #fff; } -#public-rooms-list { - display: flex; - flex-direction: column; - gap: 6px; - max-height: 160px; - overflow-y: auto; - scrollbar-width: thin; -} -.public-room-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; +.auth-tabs { display: flex; gap: 6px; } +.auth-tab { + flex: 1; padding: 7px; background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12); border-radius: 8px; - padding: 8px 10px; + color: var(--muted); + cursor: pointer; font-size: .85rem; } -.public-room-info { +.auth-tab.active { background: rgba(245,197,24,.15); border-color: rgba(245,197,24,.35); color: var(--gold); } +.auth-panel { display: none; flex-direction: column; gap: 10px; } +.auth-panel.active { display: flex; } + +/* ── Profile & leaderboard ────────────────────── */ +#screen-profile, #screen-leaderboard { + background: radial-gradient(ellipse at center, #1d5c3a 0%, var(--felt-dark) 100%); + justify-content: flex-start; + align-items: center; + overflow-y: auto; + padding: 16px; +} +.profile-wrap { width: 100%; display: flex; justify-content: center; } +.profile-box { + background: rgba(0,0,0,.48); + border: 1px solid rgba(255,255,255,.12); + border-radius: 16px; + padding: 24px 28px; + width: min(400px, 96vw); display: flex; flex-direction: column; - gap: 2px; - overflow: hidden; + gap: 14px; } -.public-room-host { - font-size: .85rem; - font-weight: 600; - color: #fff; +.btn-back { + background: none; border: none; color: var(--muted); + cursor: pointer; font-size: .85rem; align-self: flex-start; + text-decoration: underline; +} +.btn-back:hover { color: #fff; } +.profile-avatar { font-size: 2.5rem; text-align: center; } +.profile-name { font-size: 1.3rem; text-align: center; color: var(--gold); } + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.stat-card { + display: flex; flex-direction: column; + align-items: center; gap: 2px; + padding: 10px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.1); + border-radius: 10px; +} +.stat-num { font-size: 1.4rem; font-weight: 700; color: var(--gold); } +.stat-label { font-size: .72rem; color: var(--muted); text-align: center; } + +.points-legend { + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.1); + border-radius: 10px; + padding: 12px 14px; +} +.points-legend h4 { font-size: .85rem; color: var(--gold); margin-bottom: 8px; } +.points-legend ul { list-style: none; display: flex; flex-direction: column; gap: 4px; } +.points-legend li { font-size: .8rem; color: var(--muted); } +.points-legend strong { color: #fff; } + +.profile-section { display: flex; flex-direction: column; gap: 8px; } +.admin-section { + background: rgba(255,100,0,.06); + border: 1px solid rgba(255,100,0,.2); + border-radius: 10px; + padding: 10px 14px; +} +.admin-title { font-size: .85rem; color: #ffaa55; margin-bottom: 6px; } +.admin-row { display: flex; justify-content: space-between; align-items: center; font-size: .85rem; } +.btn-admin-toggle { min-width: 80px; } +.btn-admin-toggle.signup-open { border-color: rgba(100,220,100,.6); color: #69f0ae; } + +/* ── Leaderboard ──────────────────────────────── */ +.lb-table { width: 100%; border-collapse: collapse; font-size: .82rem; } +.lb-table th, .lb-table td { padding: 7px 8px; text-align: left; } +.lb-table th { color: var(--muted); font-weight: 600; border-bottom: 1px solid rgba(255,255,255,.1); } +.lb-table td { border-bottom: 1px solid rgba(255,255,255,.05); } +.lb-table tr:hover td { background: rgba(255,255,255,.04); } + +/* ── App version footer ───── */ +.app-version { + text-align: center; + margin-top: 14px; + font-size: .72rem; + color: rgba(255,255,255,.35); + letter-spacing: .5px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; +} +.app-version:hover { color: rgba(255,255,255,.6); text-decoration: underline; } +.app-version:active { color: var(--gold); } + +/* ── Round-by-round breakdown ───── */ +.round-breakdown-wrap { margin: 6px 0 2px; } +.rb-scroll { max-height: 42vh; overflow: auto; -webkit-overflow-scrolling: touch; } +.round-breakdown { + border-collapse: collapse; + font-size: .82rem; + margin: 0 auto; white-space: nowrap; +} +.round-breakdown th, .round-breakdown td { + padding: 4px 10px; + text-align: center; +} +/* Four players, so names have to stay narrow */ +.round-breakdown thead th { + max-width: 70px; overflow: hidden; text-overflow: ellipsis; } -.public-room-meta { font-size: .72rem; color: rgba(255,255,255,.5); } -.btn-join-pub { - padding: 5px 12px; - background: var(--gold); - border: none; - border-radius: 6px; - color: #000; - font-size: .8rem; +/* Best (lowest) score of the round */ +.round-breakdown td.rb-best { color: #69f0ae; font-weight: 700; } +/* Moon shot */ +.round-breakdown td.rb-moon { color: var(--gold); font-weight: 700; } +/* Sticky team-name header so it stays visible as rounds scroll */ +.round-breakdown thead th { + color: var(--muted); + font-weight: 600; + position: sticky; + top: 0; + background: #1a2e1a; + border-bottom: 1px solid rgba(255,255,255,.12); +} +.round-breakdown .rb-round { + text-align: left; + color: var(--muted); + font-weight: 600; +} +.round-breakdown td.pos { color: #69f0ae; } +.round-breakdown td.neg { color: #ff6b6b; } +/* Horizontal line before the final totals row */ +.round-breakdown .rb-final td { + border-top: 2px solid var(--gold); font-weight: 700; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - transition: opacity .15s; + color: var(--gold); } -.btn-join-pub:hover { opacity: .85; } +.round-breakdown .rb-final .rb-round { color: var(--gold); } -/* ── Waiting room public badge ─────────────────── */ -.waiting-public-badge { - font-size: .78rem; - color: rgba(255,255,255,.65); - background: rgba(255,255,255,.09); - border: 1px solid rgba(255,255,255,.15); +/* ── Game history list ───── */ +.history-list { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; } +.history-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; border-radius: 10px; - padding: 2px 10px; - align-self: center; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.08); + border-left: 4px solid rgba(255,255,255,.15); + cursor: pointer; +} +.history-item:hover { background: rgba(255,255,255,.07); } +.history-item.won { border-left-color: #69f0ae; } +.history-item.lost { border-left-color: #ff6b6b; } +.history-item-main { display: flex; gap: 10px; align-items: center; justify-content: space-between; } +.history-item-result { font-weight: 700; } +.history-item-teams { font-size: .82rem; color: rgba(255,255,255,.75); text-align: right; } +.history-item-meta { display: flex; justify-content: space-between; font-size: .78rem; color: var(--muted); } + +/* ── Waiting room top row (Leave + ☰ menu) ───── */ +.waiting-top-row { + display: flex; + width: 100%; + align-items: center; } -/* ── Your-turn escalation ─────────────────────── */ -@keyframes your-turn-flash { - 0%,100% { opacity:1; } 50% { opacity:.55; } +/* ── Turn urgent reminder ──────────────────────── */ +@keyframes turn-urgent-glow { + 0%, 100% { border-top-color: rgba(0,0,0,.3); box-shadow: none; } + 50% { + border-top-color: rgba(245,197,24,.7); + box-shadow: 0 -2px 16px rgba(245,197,24,.3); + } } -.phase-msg.your-turn-lvl1 { - font-size: .96rem; color: #ffc400; - animation: your-turn-flash .6s ease-in-out infinite; -} -.phase-msg.your-turn-lvl2 { - font-size: 1.18rem; color: #ffeb3b; - text-shadow: 0 0 10px #ffeb3b88; - animation: your-turn-flash .4s ease-in-out infinite; -} -.phase-msg.your-turn-lvl3 { - font-size: 1.4rem; color: #fff; - text-shadow: 0 0 22px #ffffffcc, 0 0 8px #ffcc00cc; - animation: your-turn-flash .25s ease-in-out infinite; +#my-area.turn-urgent { + animation: turn-urgent-glow .65s ease-in-out 6; } -/* ── AFK warning banner ───────────────────────── */ +/* ── Card count badge (replaces card backs) ──── */ +.card-count-badge { + font-size: .65rem; + color: rgba(255,255,255,.55); + padding: 1px 5px; + background: rgba(255,255,255,.08); + border-radius: 8px; + min-width: 20px; + text-align: center; + flex-shrink: 0; +} + +/* ── AFK banner ───────────────────────────────── */ .afk-banner { display: flex; align-items: center; - gap: 8px; - padding: 7px 14px; - background: rgba(180,60,30,.88); - border-bottom: 1px solid rgba(255,120,60,.35); + gap: 10px; + padding: 6px 12px; + background: rgba(200, 100, 0, 0.28); + border-bottom: 1px solid rgba(200, 120, 0, 0.45); font-size: .82rem; flex-shrink: 0; + flex-wrap: wrap; } -.afk-banner span { flex: 1; color: rgba(255,255,255,.9); } -.afk-banner button { - padding: 3px 10px; - border-radius: 6px; - border: 1px solid rgba(255,255,255,.35); - background: rgba(255,255,255,.18); - color: #fff; - font-size: .76rem; - cursor: pointer; - white-space: nowrap; -} -.afk-banner button:hover { background: rgba(255,255,255,.3); } +.afk-banner.hidden { display: none !important; } /* ── AI control banner ────────────────────────── */ .ai-control-banner { display: flex; align-items: center; - justify-content: center; - padding: 5px 14px; + gap: 8px; + padding: 5px 12px; + background: rgba(80, 0, 180, 0.3); + border-bottom: 1px solid rgba(120, 60, 220, 0.5); font-size: .8rem; flex-shrink: 0; - color: rgba(255,255,255,.9); + color: #ce93d8; } -.ai-control-banner.ai-self { background: rgba(190,40,90,.75); border-bottom: 1px solid rgba(255,80,140,.25); } -.ai-control-banner.ai-other { background: rgba(70,45,160,.75); border-bottom: 1px solid rgba(120,90,220,.25); } +.ai-control-banner.is-me { + background: rgba(180, 0, 100, 0.35); + border-bottom-color: rgba(220, 60, 140, 0.55); + color: #f48fb1; + font-weight: 700; +} +.ai-control-banner.hidden { display: none !important; } + +/* ── Util ─────────────────────────────────────── */ +.hidden { display: none !important; } + +/* ── Bar-at-bottom layout ─────────────────────── */ +#screen-game.bar-bottom #info-bar { + order: 10; + border-bottom: none; + border-top: 1px solid rgba(0,0,0,.3); +} +#screen-game.bar-bottom .game-menu-dropdown { + top: auto; + bottom: calc(100% + 6px); +} + +/* ═══════════════════════════════════════════════ + MOBILE OVERHAUL (touch devices / small screens) + ═══════════════════════════════════════════════ */ +@media (pointer: coarse) { + + /* Prevent text selection and system long-press menus on game elements */ + #screen-game { + user-select: none; + -webkit-user-select: none; + } + + /* Allow horizontal scroll in scroll mode, block vertical pull-to-refresh */ + #my-hand { + touch-action: pan-x; + overscroll-behavior-x: contain; + } + + /* In fan mode cards need full touch control for drag */ + #my-hand.fan-mode { + touch-action: none; + } + #my-hand.fan-mode .card { + touch-action: none; + } + + /* ── Info bar: compact ─────────────────────── */ + #info-bar { padding: 3px 8px; gap: 5px; } + .score-entry { font-size: .64rem; padding: 1px 6px; gap: 3px; } + .score-entry .score-name { max-width: 52px; } + .hearts-broken, .pass-dir-display { font-size: .66rem; padding: 1px 6px; } + .btn-leave-screen { font-size: .75rem; padding: 3px 7px; } + + /* ── Table grid: narrow side columns ──────── */ + #table-grid { + grid-template-columns: 36px 1fr 36px; + gap: 2px; + padding: 3px; + } + + /* ── Top player label: minimal height ──────── */ + .area-top .player-label { + font-size: .7rem; + padding: 2px 6px; + gap: 4px; + flex-wrap: wrap; + justify-content: center; + } + .area-top .player-score-badge { display: none; } + + /* ── Side player labels: vertical, very narrow */ + .player-label.vertical { + font-size: .6rem; + padding: 3px 2px; + gap: 3px; + writing-mode: vertical-rl; + transform: rotate(180deg); + overflow: hidden; + max-height: 140px; + text-align: center; + } + /* Rotate back the children that shouldn't be rotated */ + .player-label.vertical .turn-dot { + writing-mode: horizontal-tb; + transform: none; + font-size: .55rem; + } + .player-label.vertical .player-score-badge { display: none; } + .player-label.vertical .card-count-badge { + writing-mode: horizontal-tb; + transform: none; + font-size: .55rem; + min-width: 16px; + } + + /* ── My area: ensure it's always fully visible */ + #my-area { + padding: 3px 6px calc(6px + env(safe-area-inset-bottom)); + gap: 3px; + flex-shrink: 0; + } + #my-label { font-size: .72rem; gap: 4px; } + .btn-hand-mode, .btn-play-mode { font-size: .65rem; padding: 2px 6px; } + + /* ── Pass overlay: fill the screen so all 13 cards stay tappable ── */ + #overlay-pass .overlay-box { + width: 100%; + max-width: 100%; + max-height: 100dvh; + border-radius: 0; + padding: 10px 8px calc(10px + env(safe-area-inset-bottom)); + } + .pass-box h3 { font-size: .95rem; margin-bottom: 2px; } +} + +/* ── Small phones (≀ 390px wide) ─────────────── */ +@media (pointer: coarse) and (max-width: 390px) { + :root { --card-w: 48px; --card-h: 70px; } + #table-grid { grid-template-columns: 32px 1fr 32px; } + .overlay-box { padding: 14px 12px; } +} + +/* ── Desktop / mouse-pointer fallback tweaks ─── */ +@media (max-width: 480px) and (pointer: fine) { + :root { --card-w: 54px; --card-h: 78px; } + #table-grid { grid-template-columns: 70px 1fr 70px; } + .overlay-box { padding: 18px 16px; } +} + +/* ═══════════════════════════════════════════════ + HEARTS-SPECIFIC + ═══════════════════════════════════════════════ */ + +/* ── Card-count desync recovery notice ───────────────────────── */ +.revert-banner { + background: rgba(245,197,24,.18); + border-bottom: 1px solid rgba(245,197,24,.45); + color: var(--gold); + text-align: center; + padding: 6px 10px; + font-size: .78rem; + font-weight: 600; + flex-shrink: 0; + z-index: 12; +} + +/* ── Leaderboard cells ───────────────────────────────────────── */ +.lb-table tr.lb-top .lb-name { color: var(--gold); font-weight: 700; } +.lb-rank { width: 28px; } +.lb-name { text-align: left; } +.lb-btn { + background: none; + border: 1px solid rgba(255,255,255,.2); + border-radius: 6px; + color: rgba(255,255,255,.7); + cursor: pointer; + font-size: .8rem; + padding: 1px 6px; +} +.lb-btn:hover { background: rgba(255,255,255,.12); } + +/* ── PWA install ─────────────────────────────────────────────── */ +.btn-install { + padding: 8px 14px; + background: rgba(41,121,255,.9); + border: none; + border-radius: 8px; + color: #fff; + font-size: .85rem; + font-weight: 600; + cursor: pointer; +} +.pwa-banner { + position: fixed; + bottom: 0; left: 0; right: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px calc(14px + env(safe-area-inset-bottom)); + background: #10321f; + border-top: 1px solid rgba(105,240,174,.25); + box-shadow: 0 -4px 24px rgba(0,0,0,.5); + z-index: 9999; + transform: translateY(100%); + transition: transform .35s cubic-bezier(.22,1,.36,1); +} +.pwa-banner.show { transform: translateY(0); } +.pwa-banner-icon { width: 44px; height: 44px; border-radius: 10px; flex-shrink: 0; } +.pwa-banner-text { flex: 1; display: flex; flex-direction: column; gap: 2px; } +.pwa-banner-text strong { color: #fff; font-size: .9rem; } +.pwa-banner-text span { color: rgba(255,255,255,.6); font-size: .75rem; line-height: 1.3; } +.pwa-banner-btn { + padding: 7px 16px; background: var(--gold); border: none; border-radius: 8px; + color: #000; font-size: .85rem; font-weight: 700; cursor: pointer; + white-space: nowrap; flex-shrink: 0; +} +.pwa-banner-close { + background: none; border: none; color: rgba(255,255,255,.45); + font-size: 1rem; cursor: pointer; padding: 4px; flex-shrink: 0; +} + +/* ── Leaderboard: players below the minimum game count ────────── */ +.lb-table tr.lb-divider td { + padding-top: 12px; + font-size: .7rem; + color: rgba(255,255,255,.4); + text-align: center; + border-bottom: 1px solid rgba(255,255,255,.12); +} +.lb-table tr.lb-provisional { opacity: .55; } +.lb-table tr.lb-provisional .lb-rank { color: rgba(255,255,255,.3); } +.lb-togo { color: var(--gold); font-size: .72em; } diff --git a/public/sw.js b/public/sw.js index e10bad5..27c915b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,7 +1,26 @@ 'use strict'; -const CACHE_NAME = 'hearts-v1'; +// v2: card faces are now precached so they never flash or refetch mid-game +const CACHE_NAME = 'hearts-v2'; -const PRECACHE = [ +// ── All 52 card faces ─────────────────────────────────────────── +const SUITS = ['CLUB', 'DIAMOND', 'HEART', 'SPADE']; +const CARD_PATHS = SUITS.flatMap(s => [ + `/cards/${s}-1.svg`, + `/cards/${s}-2.svg`, + `/cards/${s}-3.svg`, + `/cards/${s}-4.svg`, + `/cards/${s}-5.svg`, + `/cards/${s}-6.svg`, + `/cards/${s}-7.svg`, + `/cards/${s}-8.svg`, + `/cards/${s}-9.svg`, + `/cards/${s}-10.svg`, + `/cards/${s}-11-JACK.svg`, + `/cards/${s}-12-QUEEN.svg`, + `/cards/${s}-13-KING.svg`, +]); + +const SHELL = [ '/', '/index.html', '/style.css', @@ -13,9 +32,12 @@ const PRECACHE = [ ]; self.addEventListener('install', event => { - event.waitUntil( - caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE)) - ); + event.waitUntil((async () => { + const cache = await caches.open(CACHE_NAME); + // The shell must land; a missing card shouldn't abort the whole install + await cache.addAll(SHELL); + await Promise.all(CARD_PATHS.map(p => cache.add(p).catch(() => {}))); + })()); self.skipWaiting(); }); @@ -33,23 +55,25 @@ self.addEventListener('fetch', event => { if (event.request.method !== 'GET') return; if (url.includes('/socket.io/')) return; + // Cards never change β€” always cache-first, and never go to the network again + // once we hold a copy. if (url.includes('/cards/')) { event.respondWith( - caches.match(event.request).then(cached => { - if (cached) return cached; - return fetch(event.request).then(res => { - caches.open(CACHE_NAME).then(c => c.put(event.request, res.clone())); - return res; - }); - }) + caches.match(event.request).then(cached => cached || fetch(event.request).then(res => { + const copy = res.clone(); + caches.open(CACHE_NAME).then(c => c.put(event.request, copy)); + return res; + })) ); return; } + // Everything else: network-first, cache as the offline fallback event.respondWith( fetch(event.request) .then(res => { - caches.open(CACHE_NAME).then(c => c.put(event.request, res.clone())); + const copy = res.clone(); + caches.open(CACHE_NAME).then(c => c.put(event.request, copy)); return res; }) .catch(() => caches.match(event.request)) diff --git a/rerun.sh b/rerun.sh index 3a26d05..4280b46 100755 --- a/rerun.sh +++ b/rerun.sh @@ -3,10 +3,10 @@ set -e cd "$(dirname "$0")" echo "==> Stopping..." -docker compose down +docker compose -f compose.build.yml down echo "==> Building and starting..." -docker compose up -d --build +docker compose -f compose.build.yml up -d --build echo "==> Logs (Ctrl+C to exit)..." -docker compose logs -f +docker compose -f compose.build.yml logs -f diff --git a/server.js b/server.js index d1998b7..107f0d8 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,17 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const app = express(); +app.disable('x-powered-by'); // don't advertise the tech stack + +// Safe security headers (no CSP here β€” the inline SW registration script and +// socket.io would both need 'unsafe-inline'/wss exceptions) +app.use((_req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'no-referrer'); + next(); +}); + const httpServer = http.createServer(app); // HTTPS with self-signed cert for local network / PWA mic access @@ -40,9 +51,10 @@ const SHARED_USERS_FILE = process.env.SHARED_USERS_FILE || ''; const DATA_DIR = path.join(__dirname, 'data'); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); -const USERS_FILE = path.join(DATA_DIR, 'users.json'); -const STATS_FILE = path.join(DATA_DIR, 'stats.json'); -const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); +const USERS_FILE = path.join(DATA_DIR, 'users.json'); +const STATS_FILE = path.join(DATA_DIR, 'stats.json'); +const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); +const HISTORY_FILE = path.join(DATA_DIR, 'history.json'); function readJSON(file, def) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; } @@ -56,10 +68,22 @@ function writeJSON(file, data) { let users = readJSON(USERS_FILE, []); let stats = readJSON(STATS_FILE, []); let config = readJSON(CONFIG_FILE, { signupsOpen: true }); +// Finished games, newest last. Each participant can replay the round-by-round +// breakdown from their profile. +let history = readJSON(HISTORY_FILE, []); -function saveUsers() { writeJSON(USERS_FILE, users); } -function saveStats() { writeJSON(STATS_FILE, stats); } -function saveConfig() { writeJSON(CONFIG_FILE, config); } +function saveUsers() { writeJSON(USERS_FILE, users); } +function saveStats() { writeJSON(STATS_FILE, stats); } +function saveConfig() { writeJSON(CONFIG_FILE, config); } +function saveHistory() { writeJSON(HISTORY_FILE, history); } + +// Keep the history file from growing without bound. +const MAX_HISTORY = 2000; +function pushHistory(record) { + history.push(record); + if (history.length > MAX_HISTORY) history = history.slice(-MAX_HISTORY); + saveHistory(); +} function isAdmin(user) { return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME; @@ -138,10 +162,20 @@ function findUser(username) { return null; } +const EMPTY_STATS = { games_played: 0, games_won: 0, moon_shots: 0, total_score: 0 }; + +// Read-only lookup. Never creates or persists a row β€” the leaderboard walks +// every known user, and creating-on-read there meant one full stats.json write +// per user on every page load, plus a file steadily filling with empty rows. +function readStats(userId) { + return stats.find(s => s.userId === userId) || { userId, ...EMPTY_STATS }; +} + +// Write path: materialises the row so addStats has something to accumulate into. function getStats(userId) { let s = stats.find(s => s.userId === userId); if (!s) { - s = { userId, games_played: 0, games_won: 0, moon_shots: 0, total_score: 0 }; + s = { userId, ...EMPTY_STATS }; stats.push(s); saveStats(); } @@ -178,8 +212,21 @@ app.use(express.static(path.join(__dirname, 'public'), { })); // ─── Auth API ───────────────────────────────────────────────── +const APP_VERSION = require('./package.json').version; + app.get('/api/config', (_req, res) => { - res.json({ turnstileSiteKey: TURNSTILE_SITE_KEY || null, signupsOpen: config.signupsOpen }); + res.json({ + turnstileSiteKey: TURNSTILE_SITE_KEY || null, + signupsOpen: config.signupsOpen, + version: APP_VERSION, + minRankedGames: MIN_RANKED_GAMES, + }); +}); + +// Validate the stored auth token; the client calls this at startup so a stale +// or corrupted token doesn't leave it running as a "logged in" ghost. +app.get('/api/me', requireAuth, (req, res) => { + res.json({ id: req.user.id, username: req.user.username }); }); app.post('/api/register/initiate', async (req, res) => { @@ -302,16 +349,52 @@ app.get('/api/profile/:username', (req, res) => { const user = findUser(req.params.username); if (!user) return res.status(404).json({ error: 'User not found' }); const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id; - const s = getStats(effectiveId); + const s = readStats(effectiveId); + const played = s.games_played || 0; res.json({ username: user.username, - games_played: s.games_played || 0, + games_played: played, games_won: s.games_won || 0, moon_shots: s.moon_shots || 0, total_score: s.total_score || 0, + win_rate: played > 0 ? +(((s.games_won || 0) / played) * 100).toFixed(1) : 0, + avg_score: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : 0, + isAdmin: ADMIN_USERNAME !== '' && user.username === ADMIN_USERNAME, + signupsOpen: config.signupsOpen, }); }); +// Every finished game this player took part in, newest first, with the full +// round-by-round breakdown so the client can render it without another call. +app.get('/api/history/:username', (req, res) => { + const user = findUser(req.params.username); + if (!user) return res.status(404).json({ error: 'User not found' }); + const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id; + const games = history + .filter(g => g.players?.some(p => p.uid === effectiveId)) + .sort((a, b) => b.endedAt - a.endedAt) + .map(g => { + const me = g.players.find(p => p.uid === effectiveId); + const mySeat = me ? me.seat : -1; + return { + id: g.id, + endedAt: g.endedAt, + playerNames: g.playerNames, + finalScores: g.finalScores, + winners: g.winners, + winScore: g.winScore, + mySeat, + myScore: mySeat >= 0 ? g.finalScores[mySeat] : null, + won: mySeat >= 0 && g.winners.includes(mySeat), + rounds: g.rounds, + }; + }); + res.json(games); +}); + +// Finished games a player needs before they appear in the ranking proper. +const MIN_RANKED_GAMES = 5; + app.get('/api/leaderboard', (_req, res) => { const allUsers = [...users]; if (SHARED_USERS_FILE) { @@ -326,26 +409,43 @@ app.get('/api/leaderboard', (_req, res) => { } const rows = allUsers.map(u => { const eid = u._fromShared ? `hokm_${u.id}` : u.id; - const s = getStats(eid); + const s = readStats(eid); const played = s.games_played || 0; + const won = s.games_won || 0; return { username: u.username, games_played: played, - games_won: s.games_won || 0, + games_won: won, moon_shots: s.moon_shots || 0, total_score: s.total_score || 0, - score_per_game: played > 0 ? +(( s.total_score || 0) / played).toFixed(2) : null, + win_rate: played > 0 ? +((won / played) * 100).toFixed(1) : null, + score_per_game: played > 0 ? +(( s.total_score || 0) / played).toFixed(1) : null, }; }) - .filter(r => r.games_played > 0) - // Lower score per game = better player β€” sort ascending - .sort((a, b) => { - if (a.score_per_game === null) return 1; - if (b.score_per_game === null) return -1; - return a.score_per_game - b.score_per_game || b.games_played - a.games_played; - }) - .slice(0, 30); - res.json(rows); + .filter(r => r.games_played > 0); + + // Win rate is the primary ranking; average score per game breaks ties, and + // there lower is better because Hearts scores are penalties. + const byWinRate = (a, b) => + (b.win_rate - a.win_rate) || + (a.score_per_game - b.score_per_game) || + (b.games_played - a.games_played); + + // A single lucky game shouldn't top the board, so a player needs + // MIN_RANKED_GAMES finished games before they get a rank. Everyone below that + // is still listed, unranked, so a new player can see their progress. + const ranked = rows.filter(r => r.games_played >= MIN_RANKED_GAMES) + .sort(byWinRate) + .slice(0, 30) + .map(r => ({ ...r, ranked: true, gamesToRank: 0 })); + + const provisional = rows.filter(r => r.games_played < MIN_RANKED_GAMES) + .sort((a, b) => b.games_played - a.games_played || byWinRate(a, b)) + .slice(0, 10) + .map(r => ({ ...r, ranked: false, + gamesToRank: MIN_RANKED_GAMES - r.games_played })); + + res.json([...ranked, ...provisional]); }); app.get('/api/rooms', (_req, res) => { @@ -429,6 +529,9 @@ function newRoom(id) { moonShooter: -1, handDeltas: null, winScore: 100, + roundHistory: [], // per-hand record for the breakdown + history + moonsBySeat: [0, 0, 0, 0], // moon shots this game, for the stats table + trickSnapshot: null, // last known-good state (see checkHandIntegrity) spectators: new Set(), isPublic: false, createdAt: Date.now(), @@ -472,6 +575,7 @@ function publicInfo(room, seat = -1) { moonShooter: room.moonShooter, handDeltas: room.handDeltas, gameWinner: room.gameWinner, + roundHistory: room.roundHistory, spectatorCount: room.spectators.size, isPublic: room.isPublic, aiControlledSeats: [...room.aiControlledSeats], @@ -513,21 +617,29 @@ function broadcastAiControl(room, seat, active) { function scheduleAfkTimer(room) { clearAfkTimer(room); + if (room.state !== 'PLAYING') return; const seat = room.currentTurn; - if (room.bots[seat]) return; + if (seat < 0 || room.bots[seat] || !room.seats[seat]) return; + if (room.aiControlledSeats.has(seat)) { + // Already AI-controlled β€” keep the game moving on a short timer room.afkTimer = setTimeout(() => { if (room.state !== 'PLAYING' || room.currentTurn !== seat) return; + if (!room.aiControlledSeats.has(seat)) return; const card = botChooseCard(room, seat); if (card) onCardPlayed(room, seat, card); }, 10000); - } else { - room.afkSeat = seat; - room.afkTimer = setTimeout(() => { - if (room.state !== 'PLAYING') return; - broadcast(room, 'afkWarning', { seat, name: room.names[seat] }); - }, 60000); + return; } + + // Normal seat: only flag it as AFK once the minute is actually up, so a + // takeover vote can't be cast against a player who is still thinking. + room.afkTimer = setTimeout(() => { + if (room.state !== 'PLAYING' || room.currentTurn !== seat) return; + room.afkSeat = seat; + room.afkVotes.clear(); + broadcast(room, 'afkWarning', { seat, name: room.names[seat] }); + }, 60000); } // ─── Dealing ────────────────────────────────────────────────── @@ -611,18 +723,85 @@ function exchangePassCards(room) { if (idx !== -1) room.hands[p].splice(idx, 1); } } - // Add to receivers + // Add to receivers. Seats are laid out clockwise from the viewer, matching + // the table: your left neighbour is seat+1, across is seat+2, right is seat+3. + // (Play runs anti-clockwise, seat β†’ seat+3, i.e. bottom β†’ right β†’ top β†’ left.) for (let from = 0; from < 4; from++) { - // "left" = visual left (area-left = seat+3); "right" = visual right (area-right = seat+1) - const to = dir === 'left' ? (from + 3) % 4 + const to = dir === 'left' ? (from + 1) % 4 : dir === 'across' ? (from + 2) % 4 - : (from + 1) % 4; // right + : (from + 3) % 4; // right for (const c of sending[from]) room.hands[to].push(c); room.hands[to].sort(sortCards); } room.passCards = [null, null, null, null]; } +// ─── Hand integrity: every player must hold the same card count ─── +// A player who somehow gets two cards out in one trick ends the hand early for +// everyone (they run dry while the others still hold cards). Playing is guarded +// against that at several points; this is the safety net that catches whatever +// slips through, by rewinding to the last state that was known to be sound. + +function handsBalanced(room) { + const n = room.hands[0].length; + return room.hands.every(h => h.length === n); +} + +// Save the state at the start of a trick, but only when it is actually sound β€” +// so the snapshot is always the last *correct* state we can fall back to. +function snapshotTrickStart(room) { + if (!handsBalanced(room)) return; + room.trickSnapshot = { + tricksPlayed: room.tricksPlayed, + hands: room.hands.map(h => h.slice()), + handPoints: room.handPoints.slice(), + heartsBroken: room.heartsBroken, + trickLead: room.trickLead, + currentTurn: room.trickLead, + lastTrick: room.lastTrick ? room.lastTrick.slice() : null, + lastTrickWinner: room.lastTrickWinner, + }; +} + +function restoreTrickSnapshot(room) { + const snap = room.trickSnapshot; + if (!snap) return false; + room.tricksPlayed = snap.tricksPlayed; + room.hands = snap.hands.map(h => h.slice()); + room.handPoints = snap.handPoints.slice(); + room.heartsBroken = snap.heartsBroken; + room.trickLead = snap.trickLead; + room.currentTurn = snap.currentTurn; + room.lastTrick = snap.lastTrick ? snap.lastTrick.slice() : null; + room.lastTrickWinner = snap.lastTrickWinner; + room.trick = []; + return true; +} + +// Returns true when the hand is sound. When it isn't, rewinds the room to the +// start of the last good trick, tells everyone, and returns false β€” the caller +// must abandon whatever it was about to do next. +function checkHandIntegrity(room, where) { + const expected = 13 - room.tricksPlayed; + if (handsBalanced(room) && room.hands[0].length === expected) return true; + + console.warn(`[${room.id}] card-count desync at ${where}: ` + + `hands ${room.hands.map(h => h.length).join('/')}, expected ${expected} each`); + + if (!restoreTrickSnapshot(room)) { + console.error(`[${room.id}] no sound snapshot to restore β€” hand left as-is`); + return true; // nothing better to do than let the hand run out + } + + clearAfkTimer(room); + if (room.trickTimer) { clearTimeout(room.trickTimer); room.trickTimer = null; } + broadcast(room, 'stateReverted', { trick: room.tricksPlayed + 1 }); + broadcastState(room, 'roomInfo'); + scheduleBotPlay(room); + scheduleAfkTimer(room); + return false; +} + // ─── Game flow ──────────────────────────────────────────────── function startPassing(room) { clearAfkTimer(room); @@ -639,17 +818,26 @@ function startPlaying(room) { room.trickLead = find2Clubs(room); room.currentTurn = room.trickLead; room.trick = []; + snapshotTrickStart(room); broadcastState(room, 'roomInfo'); scheduleBotPlay(room); scheduleAfkTimer(room); } function onCardPlayed(room, player, card) { - // Remove from hand - room.hands[player] = room.hands[player].filter(c => c !== card); - // Add to trick + // ── Guards against a seat getting two cards into one trick ── + // Bots, the AFK auto-player and the socket handler all land here, so the + // checks live in one place rather than at each call site. + if (room.state !== 'PLAYING') return; + if (room.trick.length >= 4) return; // trick already resolving + if (room.currentTurn !== player) return; // not this seat's turn + if (room.trick.some(t => t.player === player)) return; // already played this trick + + const idx = room.hands[player].indexOf(card); + if (idx === -1) return; // card isn't in hand β€” stale or duplicated click + room.hands[player].splice(idx, 1); // remove exactly one copy + room.trick.push({ card, player }); - // Break hearts if (isHeart(card) || card === 'S-Q') room.heartsBroken = true; if (room.trick.length < 4) { @@ -660,27 +848,33 @@ function onCardPlayed(room, player, card) { return; } - // Trick complete - const winner = trickWinner(room.trick); + // Trick complete. Nobody is on turn during the resolution window, so the + // player who closed the trick can't slip a second card in before the + // trick-advance timer fires. + room.currentTurn = -1; + clearAfkTimer(room); + if (room.trickTimer) clearTimeout(room.trickTimer); + + const winner = trickWinner(room.trick); const trickPts = room.trick.reduce((s, t) => s + points(t.card), 0); room.handPoints[winner] += trickPts; room.tricksPlayed++; room.lastTrick = room.trick.slice(); room.lastTrickWinner = winner; + // Every hand must now hold the same number of cards. If not, rewind. + if (!checkHandIntegrity(room, `end of trick ${room.tricksPlayed}`)) return; + + broadcastState(room, 'trickWon'); + if (room.tricksPlayed === 13) { - broadcastState(room, 'trickWon'); - clearAfkTimer(room); - if (room.trickTimer) clearTimeout(room.trickTimer); room.trickTimer = setTimeout(() => finishHand(room), 1400); } else { - broadcastState(room, 'trickWon'); - clearAfkTimer(room); - if (room.trickTimer) clearTimeout(room.trickTimer); room.trickTimer = setTimeout(() => { room.trickLead = winner; room.currentTurn = winner; room.trick = []; + snapshotTrickStart(room); broadcastState(room, 'roomInfo'); scheduleBotPlay(room); scheduleAfkTimer(room); @@ -690,6 +884,11 @@ function onCardPlayed(room, player, card) { function finishHand(room) { clearAfkTimer(room); + // A hand can only end with every hand empty. If cards are left over the + // deal desynced somewhere; rewind rather than score a broken hand. + if (room.hands.some(h => h.length > 0)) { + if (!checkHandIntegrity(room, 'end of hand')) return; + } for (const s of room.aiControlledSeats) broadcastAiControl(room, s, false); room.aiControlledSeats.clear(); // Check shoot the moon (one player has all 26 pts) @@ -706,6 +905,17 @@ function finishHand(room) { room.handDeltas = deltas; for (let i = 0; i < 4; i++) room.scores[i] += deltas[i]; + if (shooter !== -1) room.moonsBySeat[shooter]++; + + // Record the hand for the end-of-hand breakdown, the game-over screen and + // the saved game history. + room.roundHistory.push({ + hand: room.roundHistory.length + 1, + deltas: deltas.slice(), + scores: room.scores.slice(), + moonShooter: shooter, + moonShooterName: shooter !== -1 ? (room.names[shooter] || '') : '', + }); // Check game over: someone hit winScore if (room.scores.some(s => s >= room.winScore)) { @@ -740,7 +950,8 @@ function finishGame(room) { room.state = 'GAME_OVER'; broadcastState(room, 'gameOver'); - // Skip stats entirely if any bot participated β€” games vs bots don't count + // Skip stats entirely if any bot participated β€” games vs bots don't count, + // and they aren't written to history either, to keep both lists clean. if (room.bots.some(Boolean)) return; const winners = new Set(room.gameWinner); @@ -750,16 +961,58 @@ function finishGame(room) { addStats(uid, { games_played: 1, games_won: winners.has(i) ? 1 : 0, - moon_shots: 0, + moon_shots: room.moonsBySeat[i], total_score: room.scores[i], }); } + + saveGameHistory(room, winners); +} + +// Persist a finished game so each participant can review it later. +function saveGameHistory(room, winners) { + pushHistory({ + id: `${room.id}-${Date.now()}`, + endedAt: Date.now(), + winScore: room.winScore, + playerNames: room.names.slice(), + finalScores: room.scores.slice(), + winners: [...winners], + players: room.userIds + .map((uid, seat) => ({ uid, seat, name: room.names[seat] })) + .filter(p => p.uid != null), + rounds: room.roundHistory, + }); +} + +// ─── Room teardown / leave recovery ─────────────────────────── +function destroyRoom(room) { + if (room.trickTimer) clearTimeout(room.trickTimer); + if (room.afkTimer) clearTimeout(room.afkTimer); + rooms.delete(room.id); +} + +// A human who leaves mid-game has their seat handed to AI; nudge the current +// phase so that AI acts if the game is now waiting on that seat. +function resumeAfterLeave(room, seat) { + if (room.state === 'PASSING') { + if (room.bots[seat] && room.passCards[seat] === null) { + setTimeout(() => botPass(room, seat), 700); + } + } else if (room.state === 'PLAYING') { + scheduleAfkTimer(room); + scheduleBotPlay(room); + } } // ─── Start the game ─────────────────────────────────────────── function tryStartGame(room) { if (room.seats.filter(Boolean).length + room.bots.filter(Boolean).length < 4) return; if (room.seats.some((s, i) => !s && !room.bots[i])) return; + // Fresh game β€” clear the accumulators that span hands + room.scores = [0, 0, 0, 0]; + room.roundHistory = []; + room.moonsBySeat = [0, 0, 0, 0]; dealHand(room); // handNumber is 0 from newRoom; passDirection set inside if (room.passDirection === 'hold') { startPlaying(room); @@ -995,6 +1248,42 @@ io.on('connection', (socket) => { tryStartGame(room); }); + // ── Swap seats (creator only, while still in the waiting room) ── + socket.on('swapSeats', ({ roomId, seatA, seatB } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room || room.state !== 'WAITING') return; + const isCreator = room.seats[0] === socket.id || + (user?.id && room.userIds[0] === user.id); + if (!isCreator) return; + if (seatA === seatB || ![0,1,2,3].includes(seatA) || ![0,1,2,3].includes(seatB)) return; + + for (const key of ['names', 'seats', 'tokens', 'userIds', 'bots']) { + [room[key][seatA], room[key][seatB]] = [room[key][seatB], room[key][seatA]]; + } + // Each moved player needs their new seat number and token + if (room.seats[seatA]) io.to(room.seats[seatA]).emit('seatChanged', { seat: seatA, token: room.tokens[seatA] }); + if (room.seats[seatB]) io.to(room.seats[seatB]).emit('seatChanged', { seat: seatB, token: room.tokens[seatB] }); + broadcastState(room, 'roomInfo'); + }); + + // ── Force start (creator only, fills the remaining seats with bots) ── + socket.on('forceStart', ({ roomId } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room || room.state !== 'WAITING') return; + const isCreator = room.seats[0] === socket.id || + (user?.id && room.userIds[0] === user.id); + if (!isCreator) return; + const botNames = ['Alice', 'Bob', 'Charlie', 'Diana']; + for (let i = 0; i < 4; i++) { + if (!room.seats[i] && !room.bots[i]) { + room.bots[i] = true; + room.names[i] = botNames[i]; + } + } + broadcastState(room, 'roomInfo'); + tryStartGame(room); + }); + // ── Pass cards ───────────────────────────────────────────── socket.on('passCards', ({ roomId, seat, token, cards } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); @@ -1022,6 +1311,10 @@ io.on('connection', (socket) => { if (room.state !== 'PLAYING') return; if (room.tokens[seat] !== token) return; if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn'); + // Belt and braces against a double-tap or a duplicated socket event landing + // twice: one seat may only ever contribute one card to a trick. + if (room.trick.some(t => t.player === seat)) return socket.emit('playError', 'Already played this trick'); + if (!room.hands[seat].includes(card)) return socket.emit('playError', 'Card not in hand'); if (!isLegal(room, seat, card)) return socket.emit('playError', 'Illegal card'); if (room.aiControlledSeats.has(seat)) { room.aiControlledSeats.delete(seat); @@ -1062,12 +1355,34 @@ io.on('connection', (socket) => { socket.on('leave', ({ roomId, seat, token } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room) return; - if (room.tokens[seat] === token) { - room.seats[seat] = null; - room.tokens[seat] = null; - room.userIds[seat] = null; - } + if (room.tokens[seat] !== token) { socket.leave(room.id); return; } + socket.leave(room.id); + + // Vacate the seat. Nulling userIds also stops hasActiveGame pulling this + // player straight back into the room they just left. + room.seats[seat] = null; + room.tokens[seat] = null; + room.userIds[seat] = null; + room.aiControlledSeats.delete(seat); + + const inProgress = room.state !== 'WAITING' && room.state !== 'GAME_OVER'; + + // Nobody left to play for β€” tear the room down + if (!room.seats.some(Boolean) && room.spectators.size === 0) { + destroyRoom(room); + return; + } + + if (inProgress) { + // Hand the seat to AI so the game keeps going for everyone else + room.bots[seat] = true; + broadcastState(room, 'roomInfo'); + resumeAfterLeave(room, seat); + } else { + room.names[seat] = ''; + broadcastState(room, 'roomInfo'); + } }); socket.on('disconnect', () => {