From dc051cbe459225675606adf9799b0afc4f2f978e Mon Sep 17 00:00:00 2001 From: goyban Date: Sun, 30 Aug 2026 14:13:46 +0000 Subject: [PATCH] Port Shelem layout/engine, add history, fix card flashing and count desync Layout & engine borrowed from Shelem: waiting-room menu with seat swap and force-start, game menu with bar-to-bottom, escalating turn reminder, three hand modes defaulting to fan on touch, card-count badges instead of card backs, leave buttons inside phase overlays, version footer with tap-to-update. Seats now run clockwise from the viewer (left = +1, across = +2, right = +3) so play flows anti-clockwise on screen as the server advances it; the pass exchange was flipped to match, otherwise "pass left" delivered to the right. Game history: finished games are saved with a per-hand breakdown, readable from the profile and shown on the end-of-hand and end-of-game screens. Card flashing: the service worker precaches all 52 faces, each face is decoded once into a template image, and the renderers reconcile against existing DOM instead of rebuilding it, so card nodes and their images survive a re-render. Card-count integrity: a seat can no longer put two cards into one trick, and the server snapshots each trick start and rewinds to the last sound state if the four hands ever drift apart. Leaderboard: ranked by win rate with a 5-game minimum; below-threshold players are listed unranked. Moon shots are now recorded instead of always zero, and stat reads no longer create and persist a row per user on every request. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 +- README.md | 51 +- package.json | 2 +- public/app.js | 2611 ++++++++++++++++++++++++--------------------- public/index.html | 166 +-- public/style.css | 1830 ++++++++++++++++--------------- public/sw.js | 50 +- rerun.sh | 6 +- server.js | 419 +++++++- 9 files changed, 2935 insertions(+), 2205 deletions(-) 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', () => {