commit 460c9d57edd739f57f7274f449c715ec6fbf83ea Author: goyban Date: Sun Apr 26 11:52:07 2026 +0000 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b969a66 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +JWT_SECRET=hearts-secret-change-me +ADMIN_USERNAME= + +PORT=4000 +HTTPS_PORT=4443 + +# Point to Hokm's users.json so Hokm accounts work here too. +# When running via docker-compose this is the container path (matches the volume mount in docker-compose.yml). +# When running directly with `node server.js`, change to: /root/hokm/data/users.json +SHARED_USERS_FILE=/hokm-data/users.json + +# Optional: Resend email API for verification codes +RESEND_API_KEY= +RESEND_FROM=noreply@example.com + +# Optional: Cloudflare Turnstile CAPTCHA +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..166be1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +rerun.sh +data/ +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..00a7c2c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +# Self-signed TLS cert for local HTTPS access +RUN apk add --no-cache openssl && \ + mkdir -p /app/ssl && \ + openssl req -x509 -newkey rsa:2048 \ + -keyout /app/ssl/key.pem -out /app/ssl/cert.pem \ + -days 3650 -nodes -subj "/CN=hearts-game" && \ + apk del openssl + +COPY server.js ./ +COPY gen-icons.js ./ +COPY public/ ./public/ +# cards/ is a symlink to Hokm's cards — supplied at runtime via docker-compose volume mount +RUN rm -rf /app/public/cards && mkdir -p /app/public/cards + +RUN node gen-icons.js + +EXPOSE 4000 4443 + +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dd8bab5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + hearts: + build: . + ports: + - "4000:4000" + - "4443:4443" + restart: unless-stopped + volumes: + - ./data:/app/data + # Hokm's data dir (read-only) so Hearts can verify Hokm accounts + - /root/hokm/data:/hokm-data:ro + # Share Hokm's card SVGs — same card assets, no duplication + - /root/hokm/public/cards:/app/public/cards:ro + env_file: + - .env diff --git a/gen-icons.js b/gen-icons.js new file mode 100644 index 0000000..b56c003 --- /dev/null +++ b/gen-icons.js @@ -0,0 +1,126 @@ +// Generates simple PNG icons using pure Node.js (no external packages needed). +'use strict'; +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +const outDir = path.join(__dirname, 'public', 'icons'); +if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + +function writePNG(filePath, size) { + const channels = 4; // RGBA + const row = size * channels; + const raw = Buffer.alloc(size * row); + + // Background: dark green #145228 + const bgR = 0x14, bgG = 0x52, bgB = 0x28; + // Heart color: red #e53935 + const hR = 0xe5, hG = 0x39, hB = 0x35; + + // Draw pixel by pixel: background + centered heart shape + // Heart expressed as two overlapping circles + a downward triangle + const cx = size / 2; + const cy = size / 2 + size * 0.05; + const r = size * 0.22; + + // Two circle centers + const lx = cx - r * 0.6, ly = cy - r * 0.35; + const rx = cx + r * 0.6, ry = cy - r * 0.35; + // Triangle tip + const tipY = cy + r * 1.1; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const px = (y * size + x) * channels; + const nx = x + 0.5, ny = y + 0.5; + + // Rounded corners (radius ~20% of size) + const cr = size * 0.2; + const inCorner = (nx < cr && ny < cr && Math.hypot(nx - cr, ny - cr) > cr) || + (nx > size - cr && ny < cr && Math.hypot(nx - (size - cr), ny - cr) > cr) || + (nx < cr && ny > size - cr && Math.hypot(nx - cr, ny - (size - cr)) > cr) || + (nx > size - cr && ny > size - cr && Math.hypot(nx - (size - cr), ny - (size - cr)) > cr); + if (inCorner) { raw[px+3] = 0; continue; } + + // Heart: point in left circle OR right circle OR downward triangle region + const inL = Math.hypot(nx - lx, ny - ly) <= r; + const inR = Math.hypot(nx - rx, ny - ry) <= r; + // Triangle: below the circle union and above the tip + // Approximate with two lines from the outer circle edges to the tip + const inT = ny >= Math.min(ly, ry) && nx >= lx - r + (nx - (lx - r)) * 0 && + (ny - (cy - r * 0.35)) / (tipY - (cy - r * 0.35)) <= + 1 - Math.abs(nx - cx) / (r * 1.2); + + const inHeart = inL || inR || inT; + + if (inHeart) { + raw[px] = hR; + raw[px+1] = hG; + raw[px+2] = hB; + raw[px+3] = 255; + } else { + raw[px] = bgR; + raw[px+1] = bgG; + raw[px+2] = bgB; + raw[px+3] = 255; + } + } + } + + // Encode to PNG + const chunks = []; + + // PNG signature + chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + + function crc32(buf) { + let c = 0xffffffff; + const table = crc32.table || (crc32.table = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let v = i; + for (let k = 0; k < 8; k++) v = v & 1 ? 0xedb88320 ^ (v >>> 1) : v >>> 1; + t[i] = v; + } + return t; + })()); + for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + } + + function chunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length); + const tp = Buffer.from(type); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([tp, data]))); + return Buffer.concat([len, tp, data, crc]); + } + + // IHDR + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; + chunks.push(chunk('IHDR', ihdr)); + + // IDAT: add filter byte (0) before each row + const filtered = Buffer.alloc(size * (row + 1)); + for (let y = 0; y < size; y++) { + filtered[y * (row + 1)] = 0; // None filter + raw.copy(filtered, y * (row + 1) + 1, y * row, (y + 1) * row); + } + const compressed = zlib.deflateSync(filtered); + chunks.push(chunk('IDAT', compressed)); + + // IEND + chunks.push(chunk('IEND', Buffer.alloc(0))); + + fs.writeFileSync(filePath, Buffer.concat(chunks)); + console.log(`${path.basename(filePath)} written (${size}×${size})`); +} + +writePNG(path.join(outDir, 'icon-192.png'), 192); +writePNG(path.join(outDir, 'icon-512.png'), 512); +console.log('Icons generated.'); diff --git a/package.json b/package.json new file mode 100644 index 0000000..46eef56 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "hearts", + "version": "1.0.0", + "description": "Hearts card game - multiplayer", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "socket.io": "^4.7.2", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..f79aafb --- /dev/null +++ b/public/app.js @@ -0,0 +1,1235 @@ +'use strict'; + +// ─── State ──────────────────────────────────────────────────── +let socket; +let myName = ''; +let mySeat = -1; +let myToken = null; +let myRoomId = null; +let spectating = false; +let authToken = localStorage.getItem('hearts_token') || null; +let authUser = localStorage.getItem('hearts_user') || null; +let lastState = null; +let deferredInstallPrompt = null; +let selectedScore = 100; + +// ─── Play mode & hand display mode ─────────────────────────── +function isTouchDevice() { + return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; +} +function loadPlayMode() { + const s = localStorage.getItem('hearts_play_mode'); + if (s === 'tap' || s === 'drag') return s; + return isTouchDevice() ? 'drag' : 'tap'; +} +function savePlayMode(m) { localStorage.setItem('hearts_play_mode', m); } +function loadHandMode() { + const s = localStorage.getItem('hearts_hand_mode'); + return s === 'fan' ? 'fan' : 'scroll'; +} +function saveHandMode(m) { localStorage.setItem('hearts_hand_mode', m); } + +let playMode = loadPlayMode(); +let handMode = loadHandMode(); + +function updatePlayModeBtn() { + const btn = $('btn-play-mode'); + if (!btn) return; + const isDrag = playMode === 'drag'; + btn.textContent = isDrag ? '☝ Drag' : '👆 Tap'; + btn.classList.toggle('drag-mode', isDrag); + btn.title = isDrag ? 'Switch to tap mode' : 'Switch to drag mode'; +} +const HAND_MODES = ['scroll', 'fan', 'playables']; +const HAND_MODE_LABEL = { scroll: '📜 Scroll', fan: '🃏 Fan', playables: '✅ Playables' }; + +function updateHandModeBtn() { + const btn = $('btn-hand-mode'); + if (!btn) return; + btn.textContent = HAND_MODE_LABEL[handMode] || '📜 Scroll'; + btn.classList.toggle('fan-mode', handMode === 'fan' || handMode === 'playables'); +} +function applyHandMode() { + const fanLike = handMode === 'fan' || handMode === 'playables'; + const handEl = $('my-hand'); + handEl?.classList.toggle('fan-mode', fanLike); + $('my-area')?.classList.toggle('fan-active', fanLike); + // Reset inline justify so scroll mode's CSS takes over cleanly + if (!fanLike && handEl) handEl.style.justifyContent = ''; +} + +// Dynamic card spacing for fan/playables mode. +// If cards overflow → minimum overlap to fit (+2px buffer). +// If cards fit → spread up to 16px but never exceed available space +// (prevents fan overflow on screens where cards are borderline). +function updateHandSpacing() { + const handEl = $('my-hand'); + if (!handEl || handMode === 'scroll') return; + const cards = Array.from(handEl.querySelectorAll('.card')); + const n = cards.length; + if (n === 0) return; + if (n === 1) { cards[0].style.marginLeft = '0'; return; } + + const cardW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 68; + const containerW = handEl.offsetWidth || (window.innerWidth - 24); + const overflow = n * cardW - containerW; + + let ml; + if (overflow > 0) { + // Cards don't fit — overlap just enough, plus 2px safety buffer + ml = -(Math.ceil(overflow / (n - 1)) + 2); + handEl.style.justifyContent = 'flex-start'; + } else { + // Cards fit — spread up to 16px, capped to available space + ml = Math.min(16, Math.floor(-overflow / (n - 1))); + // Center the group so few cards don't hug the left edge + handEl.style.justifyContent = 'center'; + } + + cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; }); +} + +// Attach touch drag-to-play handlers to a card element +function addDragHandlers(cardEl, code) { + const handEl = $('my-hand'); + // Magnifier only helps when the hand is dense (>8 cards); sparse fans are readable + const cardCount = $('my-hand')?.querySelectorAll('.card').length ?? 0; + const useMagnifier = isTouchDevice() && playMode === 'drag' + && (handMode === 'fan' || handMode === 'playables') + && cardCount > 8; + let touchStartY = 0, touchStartX = 0, isDragging = false, ghost = null; + + cardEl.addEventListener('touchstart', e => { + touchStartY = e.touches[0].clientY; + touchStartX = e.touches[0].clientX; + isDragging = false; + if (useMagnifier) cardEl.classList.add('magnified'); + }, { passive: true }); + + cardEl.addEventListener('touchmove', e => { + const dy = touchStartY - e.touches[0].clientY; + + // Horizontal sweep in fan+drag mode — magnifier picks cards + if (useMagnifier && !isDragging && dy < 20) { + cardEl.classList.remove('magnified'); + const el2 = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); + const tgt = el2?.closest('.card'); + if (tgt) tgt.classList.add('magnified'); + return; + } + + if (dy > 20) { + e.preventDefault(); + isDragging = true; + handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); + if (!ghost) { + ghost = document.createElement('div'); + ghost.className = 'drag-ghost'; + ghost.appendChild(cardImg(code)); + document.body.appendChild(ghost); + $('trick-area')?.classList.add('drag-active'); + } + ghost.style.left = (e.touches[0].clientX - cardEl.offsetWidth / 2) + 'px'; + ghost.style.top = (e.touches[0].clientY - cardEl.offsetHeight * 0.7) + 'px'; + } + }, { passive: false }); + + const endDrag = e => { + if (ghost) { ghost.remove(); ghost = null; } + $('trick-area')?.classList.remove('drag-active'); + handEl?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); + if (isDragging) { + const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY; + if (endY < window.innerHeight * 0.70) playCard(code); + } + isDragging = false; + }; + cardEl.addEventListener('touchend', endDrag); + cardEl.addEventListener('touchcancel', endDrag); +} + +const SUIT_ICON = { H: '♥', D: '♦', C: '♣', S: '♠' }; +const SUIT_COLOR = { H: '#e53935', D: '#e53935', C: '#111', S: '#111' }; +const PASS_DIR_LABEL = { left: '← Pass Left', right: '→ Pass Right', across: '↑ Pass Across', hold: 'No Pass (Hold)' }; + +// Map from internal code (e.g. "S-Q") to SVG filename (e.g. "SPADE-12-QUEEN.svg") +const SUIT_FILE = { H: 'HEART', D: 'DIAMOND', C: 'CLUB', S: 'SPADE' }; +const RANK_FILE = { + 'A': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', + '7': '7', '8': '8', '9': '9', '10': '10', + 'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING', +}; + +// ─── Helpers ────────────────────────────────────────────────── +function $(id) { return document.getElementById(id); } +function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); } +function hide(...ids) { ids.forEach(id => $(id)?.classList.add('hidden')); } +function showScreen(id) { + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + $(id).classList.add('active'); +} +function cardImg(code) { + const [s, r] = code.split('-'); + const img = document.createElement('img'); + img.src = `/cards/${SUIT_FILE[s]}-${RANK_FILE[r]}.svg`; + img.alt = code; + img.draggable = false; + return img; +} +function suitOf(code) { return code.split('-')[0]; } +function rankOf(code) { return code.split('-')[1]; } +function isRed(code) { const s = suitOf(code); return s === 'H' || s === 'D'; } +function isHeart(code){ return suitOf(code) === 'H'; } +function isQoS(code) { return code === 'S-Q'; } +function cardPoints(code) { return isHeart(code) ? 1 : isQoS(code) ? 13 : 0; } + +// Seat mapping: my seat is bottom; left/top/right follow clockwise +function relativePosition(mySeat, theirSeat) { + const delta = (theirSeat - mySeat + 4) % 4; + return ['bottom', 'right', 'top', 'left'][delta]; +} +function positionToArea(pos) { + return { top: 'top', left: 'left', right: 'right', bottom: 'bottom' }[pos]; +} + +// ─── Auth helpers ───────────────────────────────────────────── +function authHeaders() { + return authToken ? { 'Authorization': `Bearer ${authToken}` } : {}; +} +async function apiFetch(url, opts = {}) { + const res = await fetch(url, { + ...opts, + headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(opts.headers || {}) }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + return res.json(); +} + +function setAuth(token, username) { + authToken = token; + authUser = username; + if (token) { + localStorage.setItem('hearts_token', token); + localStorage.setItem('hearts_user', username); + } else { + localStorage.removeItem('hearts_token'); + localStorage.removeItem('hearts_user'); + } + updateAuthBar(); +} + +function updateAuthBar() { + if (authUser) { + $('auth-status').textContent = `Logged in as ${authUser}`; + show('btn-show-profile', 'btn-logout'); + hide('btn-show-auth'); + // Pre-fill the name input so the user doesn't have to type it + const nameInput = $('input-name'); + if (nameInput && !nameInput.value) nameInput.value = authUser; + } else { + $('auth-status').textContent = 'Playing as guest'; + hide('btn-show-profile', 'btn-logout'); + show('btn-show-auth'); + } +} + +// ─── Session helpers ────────────────────────────────────────── +function saveSession(roomId, seat, token) { + localStorage.setItem('hearts_session', JSON.stringify({ roomId, seat, token, ts: Date.now() })); +} +function loadSession() { + try { + const s = JSON.parse(localStorage.getItem('hearts_session')); + if (s && Date.now() - s.ts < 3 * 60 * 60 * 1000) return s; + } catch { /* ignore */ } + return null; +} +function clearSession() { localStorage.removeItem('hearts_session'); } + +// ─── Socket init ────────────────────────────────────────────── +function initSocket() { + socket = io({ auth: { token: authToken } }); + + socket.on('connect', () => { + // Try to rejoin an active session + const sess = loadSession(); + if (sess && sess.roomId) { + socket.emit('rejoin', { roomId: sess.roomId, seat: sess.seat, token: sess.token }); + } + }); + + socket.on('created', ({ roomId, seat, token }) => { + myRoomId = roomId; + mySeat = seat; + myToken = token; + saveSession(roomId, seat, token); + $('display-room-code').textContent = roomId; + showScreen('screen-waiting'); + }); + + socket.on('joined', ({ roomId, seat, token }) => { + myRoomId = roomId; + mySeat = seat; + myToken = token; + saveSession(roomId, seat, token); + $('display-room-code').textContent = roomId; + showScreen('screen-waiting'); + }); + + socket.on('spectating', ({ roomId }) => { + myRoomId = roomId; + mySeat = -1; + spectating = true; + showScreen('screen-game'); + show('spectator-banner'); + }); + + socket.on('rejoined', ({ roomId, seat, token }) => { + myRoomId = roomId; + mySeat = seat; + myToken = token; + saveSession(roomId, seat, token); + hide('overlay-pass', 'overlay-hand', 'overlay-gameover'); + }); + + socket.on('rejoinError', (msg) => { + clearSession(); + $('lobby-error').textContent = msg || 'Could not rejoin'; + }); + + // Core game events — all handled by renderState + socket.on('roomInfo', state => handleState(state)); + socket.on('cardPlayed',state => handleState(state)); + socket.on('trickWon', state => handleTrickWon(state)); + socket.on('handOver', state => handleHandOver(state)); + socket.on('gameOver', state => handleGameOver(state)); + + socket.on('error', msg => { $('lobby-error').textContent = msg; }); + socket.on('joinError', msg => { $('lobby-error').textContent = msg; }); + socket.on('passError', msg => console.warn('passError:', msg)); + socket.on('playError', msg => console.warn('playError:', msg)); +} + +// ─── State rendering ────────────────────────────────────────── +function handleState(state) { + lastState = state; + + // Always dismiss result overlays when fresh state arrives + hide('overlay-hand', 'overlay-gameover'); + + if (state.state === 'WAITING') { + renderWaitingRoom(state); + return; + } + + showScreen('screen-game'); + renderInfoBar(state); + renderTable(state); + + if (state.state === 'PASSING') { + renderPassOverlay(state); + } else { + hide('overlay-pass'); + } +} + +function handleTrickWon(state) { + lastState = state; + renderInfoBar(state); + renderTable(state); + // Flash the winning slot briefly + const winnerPos = spectating ? null : relativePosition(mySeat, state.lastTrickWinner); + const slot = winnerPos ? $(`trick-${winnerPos}`) : null; + if (slot) slot.classList.add('trick-winner-flash'); + setTimeout(() => slot?.classList.remove('trick-winner-flash'), 600); +} + +function handleHandOver(state) { + lastState = state; + renderInfoBar(state); + renderTable(state); + hide('overlay-pass'); + showHandOverlay(state); +} + +function handleGameOver(state) { + lastState = state; + renderInfoBar(state); + renderTable(state); + hide('overlay-pass', 'overlay-hand'); + showGameOverOverlay(state); +} + +// ── Info bar ────────────────────────────────────────────────── +function renderInfoBar(state) { + // Score display + const scoreDiv = $('score-display'); + scoreDiv.innerHTML = ''; + const hpDiv = $('hand-points-display'); + hpDiv.innerHTML = ''; + + const minScore = Math.min(...state.scores); + state.names.forEach((name, i) => { + const isMine = i === mySeat; + const entry = document.createElement('div'); + entry.className = 'score-entry' + (isMine ? ' my-score' : '') + (state.scores[i] === minScore && state.scores.filter(s => s === minScore).length < 4 ? ' leading' : ''); + const nameEl = document.createElement('span'); + nameEl.className = 'score-name'; + nameEl.textContent = name || `P${i+1}`; + const valEl = document.createElement('span'); + valEl.className = 'score-val'; + valEl.textContent = state.scores[i]; + entry.appendChild(nameEl); + entry.appendChild(valEl); + scoreDiv.appendChild(entry); + + }); + // Hand points row: only show the local player's own points + const myHpVal = state.handPoints[mySeat]; + if (myHpVal != null && myHpVal > 0 && mySeat >= 0) { + const hp = document.createElement('span'); + hp.className = 'hp-entry'; + const hn = document.createElement('span'); hn.className = 'hp-name'; hn.textContent = 'You:'; + const hv = document.createElement('span'); hv.className = 'hp-val has-pts'; hv.textContent = myHpVal; + hp.appendChild(hn); hp.appendChild(hv); + hpDiv.appendChild(hp); + } + + // Hearts broken + if (state.heartsBroken) { show('hearts-broken-display'); } + else { hide('hearts-broken-display'); } + + // Pass direction + if (state.state === 'PASSING') { + $('pass-dir-display').textContent = PASS_DIR_LABEL[state.passDirection] || state.passDirection; + show('pass-dir-display'); + } else { + hide('pass-dir-display'); + } +} + +// ── Waiting room ────────────────────────────────────────────── +function renderWaitingRoom(state) { + showScreen('screen-waiting'); + const filled = state.names.filter((n, i) => n && (state.bots[i] || true)).length; + const humanCount = state.names.filter((n, i) => n && !state.bots[i]).length; + const botCount = state.bots.filter(Boolean).length; + const total = humanCount + botCount; + + for (let i = 0; i < 4; i++) { + const slot = document.querySelector(`.seat-slot[data-seat="${i}"]`); + if (!slot) continue; + const nameSpan = slot.querySelector('.seat-name'); + if (state.names[i]) { + nameSpan.textContent = state.names[i] + (state.bots[i] ? ' 🤖' : ''); + slot.classList.add('filled'); + } else { + nameSpan.textContent = '—'; + slot.classList.remove('filled'); + } + } + + const remaining = 4 - total; + $('waiting-status').textContent = remaining > 0 + ? `Waiting for ${remaining} more player${remaining > 1 ? 's' : ''}…` + : 'All players ready!'; + + // Show fill-bots button if we're the first seat and there are empty seats + if (mySeat === 0 && total < 4) { + show('btn-fill-bots'); + } else { + hide('btn-fill-bots'); + } +} + +// ── Game table ──────────────────────────────────────────────── +function renderTable(state) { + if (spectating) { + renderSpectatorTable(state); + return; + } + + const positions = ['top', 'left', 'right']; + const otherSeats = [ + (mySeat + 2) % 4, // top (across) + (mySeat + 3) % 4, // left + (mySeat + 1) % 4, // right + ]; + + positions.forEach((pos, idx) => { + const seat = otherSeats[idx]; + $(`${pos}-name`).textContent = state.names[seat] || `P${seat+1}`; + $(`${pos}-score`).textContent = state.scores[seat]; + + // Turn indicator + if (state.currentTurn === seat && state.state === 'PLAYING') { + show(`${pos}-turn`); + } else { + hide(`${pos}-turn`); + } + // No card-back display — opponents' hand size is hidden + $(`${pos}-cards`).innerHTML = ''; + }); + + // My area + $('my-name').textContent = state.names[mySeat] || 'You'; + $('my-score').textContent = state.scores[mySeat]; + // Only show my own in-hand points (server sends null for others) + const myHp = state.handPoints[mySeat]; + $('my-hand-pts').textContent = myHp > 0 ? `+${myHp}♥` : ''; + + if (state.currentTurn === mySeat && state.state === 'PLAYING') { + show('my-turn'); + } else { + hide('my-turn'); + } + + // Render my hand + const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; + renderMyHand(myHand, state); + + // Render trick + renderTrick(state); + + // Phase message + renderPhaseMsg(state); +} + +function renderSpectatorTable(state) { + // For spectators, show all hands as backs, no interaction + ['top', 'left', 'right'].forEach((pos, idx) => { + const seat = [2, 3, 1][idx]; + $(`${pos}-name`).textContent = state.names[seat] || `P${seat+1}`; + $(`${pos}-score`).textContent = state.scores[seat]; + $(`${pos}-cards`).innerHTML = ''; // no card backs + if (state.currentTurn === seat && state.state === 'PLAYING') show(`${pos}-turn`); + else hide(`${pos}-turn`); + }); + $('my-name').textContent = state.names[0] || 'P1'; + $('my-score').textContent = state.scores[0]; + $('my-hand-pts').textContent = ''; + $('my-hand').innerHTML = ''; + renderTrick(state); + renderPhaseMsg(state); +} + +function renderOppCards(containerId, count, vertical) { + const el = $(containerId); + if (!el) return; + el.innerHTML = ''; + const max = vertical ? 8 : 13; + for (let i = 0; i < Math.min(count, max); i++) { + const back = document.createElement('div'); + back.className = 'card-back'; + el.appendChild(back); + } +} + +function renderMyHand(hand, state) { + const el = $('my-hand'); + el.innerHTML = ''; + applyHandMode(); + if (!hand.length) return; + + const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat; + const legal = isMyTurn ? legalCards(hand, state) : []; + + // "Only Playables" mode: when it's my turn, render ONLY legal cards + // (all cards shown when not my turn or during passing — player still needs to plan) + const displayHand = (handMode === 'playables' && isMyTurn && state.state === 'PLAYING') + ? legal + : hand; + + displayHand.forEach(code => { + const div = document.createElement('div'); + div.className = 'card'; + if (isQoS(code)) div.classList.add('card-qos'); + if (isHeart(code)) div.classList.add('card-heart'); + + if (state.state === 'PASSING') { + const selected = state.passSelected || []; + if (selected.includes(code)) div.classList.add('selected'); + div.addEventListener('click', () => togglePassSelect(code, state)); + } else if (isMyTurn) { + // In playables mode every shown card is legal — no illegal overlay needed + const isLegal = legal.includes(code); + if (!isLegal) { + div.classList.add('illegal'); + } else { + if (playMode === 'tap') { + div.addEventListener('click', () => playCard(code)); + } + addDragHandlers(div, code); + } + } + + div.appendChild(cardImg(code)); + el.appendChild(div); + }); + + // Show/hide play-mode toggle (touch devices only, during play) + if (!spectating) { + if (isTouchDevice() && state.state === 'PLAYING') { + show('btn-play-mode'); + } else { + hide('btn-play-mode'); + } + updatePlayModeBtn(); + updateHandModeBtn(); + } + + // Fan / Playables: dynamic overlap spacing; Scroll: center visible window + if (handMode === 'fan' || handMode === 'playables') { + requestAnimationFrame(updateHandSpacing); + } else { + requestAnimationFrame(() => { + const overflow = el.scrollWidth - el.clientWidth; + if (overflow > 0) el.scrollLeft = overflow / 2; + }); + } +} + +function renderTrick(state) { + const trick = state.trick || []; + const seats = spectating + ? { bottom: 0, right: 1, top: 2, left: 3 } + : { + bottom: mySeat, + right: (mySeat + 1) % 4, + top: (mySeat + 2) % 4, + left: (mySeat + 3) % 4, + }; + + ['top', 'left', 'right', 'bottom'].forEach(pos => { + const slot = $(`trick-${pos}`); + if (!slot) return; + slot.innerHTML = ''; + const seat = seats[pos]; + const entry = trick.find(t => t.player === seat); + if (entry) { + slot.appendChild(cardImg(entry.card)); + } + }); +} + +function renderPhaseMsg(state) { + const el = $('phase-msg'); + if (!el) return; + + if (state.state === 'WAITING') { + el.textContent = 'Waiting…'; + } else if (state.state === 'PASSING') { + const pending = state.passReady.filter(r => !r).length; + el.textContent = pending > 0 ? `${pending} player${pending > 1 ? 's' : ''} passing…` : 'Exchanging…'; + } else if (state.state === 'PLAYING') { + if (state.trick.length === 0) { + const name = state.names[state.currentTurn] || `P${state.currentTurn + 1}`; + el.textContent = state.currentTurn === mySeat ? 'Your lead' : `${name} leads`; + } else { + const name = state.names[state.currentTurn] || `P${state.currentTurn + 1}`; + el.textContent = state.currentTurn === mySeat ? 'Your turn' : `${name}…`; + } + } else { + el.textContent = ''; + } +} + +// ── Pass overlay ────────────────────────────────────────────── +// Clubs → Diamonds → Spades → Hearts, one row per suit +const PASS_SUIT_ORDER = ['C', 'D', 'S', 'H']; +const PASS_SUIT_LABEL = { C: '♣', D: '♦', S: '♠', H: '♥' }; + +function renderPassOverlay(state) { + if (state.passDirection === 'hold') { + hide('overlay-pass'); + return; + } + + const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; + const selected = state.passSelected || []; + + $('pass-title').textContent = 'Pass 3 cards'; + $('pass-hint').textContent = PASS_DIR_LABEL[state.passDirection] || ''; + + // Render hand grouped by suit, one row per suit + const passHandEl = $('pass-hand'); + passHandEl.innerHTML = ''; + PASS_SUIT_ORDER.forEach(s => { + const suitCards = myHand.filter(c => suitOf(c) === s); + if (suitCards.length === 0) return; + + const row = document.createElement('div'); + row.className = 'pass-suit-row'; + + const lbl = document.createElement('span'); + lbl.className = 'pass-suit-label suit-' + s.toLowerCase(); + lbl.textContent = PASS_SUIT_LABEL[s]; + row.appendChild(lbl); + + const cardsWrap = document.createElement('div'); + cardsWrap.className = 'pass-suit-cards'; + suitCards.forEach(code => { + const div = document.createElement('div'); + div.className = 'card' + (selected.includes(code) ? ' selected' : ''); + div.appendChild(cardImg(code)); + div.addEventListener('click', () => togglePassSelect(code, state)); + cardsWrap.appendChild(div); + }); + row.appendChild(cardsWrap); + passHandEl.appendChild(row); + }); + + // Preview selected + const preview = $('pass-selected-preview'); + preview.innerHTML = ''; + for (let i = 0; i < 3; i++) { + if (selected[i]) { + const div = document.createElement('div'); + div.className = 'card'; + div.appendChild(cardImg(selected[i])); + preview.appendChild(div); + } else { + const ph = document.createElement('div'); + ph.className = 'pass-placeholder'; + preview.appendChild(ph); + } + } + + // Confirm button + $('btn-confirm-pass').disabled = selected.length !== 3; + + // Show waiting status + const readyCount = state.passReady.filter(Boolean).length; + $('pass-waiting').textContent = readyCount > 0 ? `${readyCount}/4 ready…` : ''; + + // If already passed, show waiting message + if (state.passReady[mySeat]) { + $('btn-confirm-pass').disabled = true; + $('pass-waiting').textContent = 'Waiting for others…'; + } + + show('overlay-pass'); +} + +let passSelectedLocal = []; + +function togglePassSelect(code, state) { + if (state.passReady[mySeat]) return; // already passed + + const myHand = Array.isArray(state.hands[mySeat]) ? state.hands[mySeat] : []; + const selected = [...(state.passSelected || [])]; + const idx = selected.indexOf(code); + + if (idx !== -1) { + selected.splice(idx, 1); + } else if (selected.length < 3) { + selected.push(code); + } else { + return; // already 3 selected + } + + // Optimistic update for immediate feedback + state.passSelected = selected; + renderPassOverlay(state); + // Also update my-hand in game table + renderMyHand(myHand, state); +} + +function confirmPass() { + const state = lastState; + if (!state) return; + const selected = state.passSelected || []; + if (selected.length !== 3) return; + socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected }); +} + +// ── Play ────────────────────────────────────────────────────── +function playCard(code) { + socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code }); +} + +// ── Client-side legal card computation (mirrors server) ─────── +function legalCards(hand, state) { + const trick = state.trick || []; + + // First card of first trick + if (trick.length === 0 && state.tricksPlayed === 0) { + return hand.includes('C-2') ? ['C-2'] : hand; + } + + // Leading + if (trick.length === 0) { + const nonHearts = hand.filter(c => !isHeart(c)); + if (!state.heartsBroken && nonHearts.length > 0) return nonHearts; + return hand; + } + + // Following + const leadSuit = suitOf(trick[0].card); + const suitCards = hand.filter(c => suitOf(c) === leadSuit); + if (suitCards.length > 0) return suitCards; + + // Can't follow — first trick avoids points if possible + if (state.tricksPlayed === 0) { + const safe = hand.filter(c => cardPoints(c) === 0); + if (safe.length > 0) return safe; + } + + return hand; +} + +// ── Hand over overlay ───────────────────────────────────────── +function showHandOverlay(state) { + const moon = state.moonShooter; + if (moon !== -1) { + $('hand-result-icon').textContent = '🌙'; + $('hand-result-title').textContent = `${state.names[moon]} shot the moon!`; + $('hand-result-detail').textContent = 'Everyone else gets 26 points.'; + } else { + $('hand-result-icon').textContent = '🃏'; + $('hand-result-title').textContent = 'Hand over'; + $('hand-result-detail').textContent = ''; + } + + const scoresEl = $('hand-result-scores'); + scoresEl.innerHTML = ''; + const deltas = state.handDeltas || [0,0,0,0]; + state.names.forEach((name, i) => { + const row = document.createElement('div'); + row.className = 'result-row' + (moon === i ? ' moon' : ''); + const nameEl = document.createElement('span'); + nameEl.textContent = name || `P${i+1}`; + const deltaEl = document.createElement('span'); + deltaEl.className = 'result-delta ' + (deltas[i] === 0 ? 'zero' : deltas[i] >= 13 ? 'red' : ''); + deltaEl.textContent = `+${deltas[i]} → ${state.scores[i]}`; + row.appendChild(nameEl); + row.appendChild(deltaEl); + scoresEl.appendChild(row); + }); + + show('overlay-hand'); +} + +// ── Game over overlay ───────────────────────────────────────── +function showGameOverOverlay(state) { + const winners = Array.isArray(state.gameWinner) ? state.gameWinner : [state.gameWinner]; + const minScore = Math.min(...state.scores); + + $('gameover-title').textContent = winners.length === 1 + ? `${state.names[winners[0]]} wins!` + : `Tie game!`; + + const scoresEl = $('gameover-scores'); + scoresEl.innerHTML = ''; + // Sort by score ascending for display + const order = state.names.map((n, i) => i).sort((a, b) => state.scores[a] - state.scores[b]); + order.forEach(i => { + const row = document.createElement('div'); + const isWin = winners.includes(i); + row.className = 'go-row' + (isWin ? ' winner' : ''); + const nameEl = document.createElement('span'); + nameEl.className = 'go-name'; + nameEl.textContent = state.names[i] || `P${i+1}`; + const scoreEl = document.createElement('span'); + scoreEl.className = 'go-score'; + scoreEl.textContent = state.scores[i]; + if (isWin) { + const badge = document.createElement('span'); + badge.className = 'go-badge'; + badge.textContent = '🏆'; + scoreEl.appendChild(badge); + } + row.appendChild(nameEl); + row.appendChild(scoreEl); + scoresEl.appendChild(row); + }); + + show('overlay-gameover'); +} + +// ─── Auth flow ──────────────────────────────────────────────── +async function doLogin() { + const username = $('auth-login-user').value.trim(); + const password = $('auth-login-pass').value; + $('auth-login-error').textContent = ''; + if (!username || !password) { $('auth-login-error').textContent = 'Fill in all fields'; return; } + + const data = await apiFetch('/api/login', { method: 'POST', body: { username, password } }); + if (data.error) { $('auth-login-error').textContent = data.error; return; } + setAuth(data.token, data.username); + hide('overlay-auth'); + // Re-init socket with new token + socket.disconnect(); + initSocket(); +} + +let regEmail = ''; +async function doRegister() { + const username = $('auth-reg-user').value.trim(); + const email = $('auth-reg-email').value.trim(); + const password = $('auth-reg-pass').value; + $('auth-reg-error').textContent = ''; + + const body = { username, email, password }; + const cfWidget = window.turnstile; + if (cfWidget) body.cfToken = cfWidget.getResponse?.() || ''; + + const data = await apiFetch('/api/register/initiate', { method: 'POST', body }); + if (data.error) { $('auth-reg-error').textContent = data.error; return; } + + if (data.done) { + setAuth(data.token, data.username); + hide('overlay-auth'); + socket.disconnect(); + initSocket(); + return; + } + + // Email verification step + regEmail = data.email; + $('reg-verify-hint').textContent = `A 6-digit code was sent to ${data.email}`; + hide('reg-step-form'); + show('reg-step-verify'); +} + +async function doVerify() { + const code = $('auth-reg-code').value.trim(); + $('auth-verify-error').textContent = ''; + const data = await apiFetch('/api/register/confirm', { method: 'POST', body: { email: regEmail, code } }); + if (data.error) { $('auth-verify-error').textContent = data.error; return; } + setAuth(data.token, data.username); + hide('overlay-auth'); + socket.disconnect(); + initSocket(); +} + +async function loadProfile() { + if (!authUser) return; + const data = await apiFetch(`/api/profile/${encodeURIComponent(authUser)}`); + if (data.error) return; + $('profile-username').textContent = data.username; + $('stat-games-played').textContent = data.games_played || 0; + $('stat-games-won').textContent = data.games_won || 0; + $('stat-moon-shots').textContent = data.moon_shots || 0; + $('stat-total-score').textContent = data.total_score || 0; + + // Admin panel + if (data.username === (window._adminUser || '')) { + show('admin-panel'); + loadAdminState(); + } else { + hide('admin-panel'); + } +} + +async function loadAdminState() { + const data = await apiFetch('/api/config'); + if (data.signupsOpen !== undefined) { + $('btn-toggle-signups').textContent = data.signupsOpen ? 'Open ✓' : 'Closed ✗'; + } +} + +async function doChangePassword() { + const cur = $('change-pass-current').value; + const nw = $('change-pass-new').value; + $('change-pass-msg').textContent = ''; + const data = await apiFetch('/api/change-password', { method: 'POST', body: { currentPassword: cur, newPassword: nw } }); + if (data.error) { $('change-pass-msg').textContent = data.error; return; } + $('change-pass-msg').style.color = '#69f0ae'; + $('change-pass-msg').textContent = 'Password updated!'; +} + +async function loadLeaderboard() { + const data = await apiFetch('/api/leaderboard'); + if (!Array.isArray(data)) return; + const tbody = $('lb-body'); + tbody.innerHTML = ''; + data.forEach((row, idx) => { + const tr = document.createElement('tr'); + if (idx < 3) tr.className = 'lb-top'; + const avg = row.score_per_game != null ? row.score_per_game.toFixed(1) : '—'; + tr.innerHTML = ` + ${['🥇','🥈','🥉'][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)); + }); +} + +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,'"'); +} + +// ─── PWA install ────────────────────────────────────────────── +window.addEventListener('beforeinstallprompt', e => { + e.preventDefault(); + deferredInstallPrompt = e; + show('btn-install'); + // Show banner after 3s + setTimeout(() => { + const banner = $('pwa-banner'); + banner.classList.remove('hidden'); + banner.classList.add('show'); + }, 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 + 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 }); + show('auth-captcha-wrap'); + } + }; + } + }); + + 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'); + }); + }); + + // 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 }); + }); + + // Join game + $('btn-join').addEventListener('click', () => { + 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; } + $('lobby-error').textContent = ''; + myName = name; + socket.emit('join', { name, roomId }); + }); + + // 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); + + // Leave game + $('btn-leave-game').addEventListener('click', () => { + if (spectating) { + spectating = false; + clearSession(); + showScreen('screen-lobby'); + return; + } + show('overlay-exit-confirm'); + hide('game-menu-dropdown'); + }); + + $('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; + 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; + 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 + updatePlayModeBtn(); + updateHandModeBtn(); + applyHandMode(); + if (!isTouchDevice()) hide('btn-play-mode'); + + $('btn-game-menu').addEventListener('click', (e) => { + e.stopPropagation(); + $('game-menu-dropdown').classList.toggle('hidden'); + }); + document.addEventListener('click', () => { hide('game-menu-dropdown'); }); + + $('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(); + }); +}); diff --git a/public/cards b/public/cards new file mode 120000 index 0000000..0bf5a3a --- /dev/null +++ b/public/cards @@ -0,0 +1 @@ +/root/hokm/public/cards \ No newline at end of file diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..4ae9e6b --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..bc8048a --- /dev/null +++ b/public/index.html @@ -0,0 +1,415 @@ + + + + + + Hearts + + + + + + + + + + + + +
+
+

♥ Hearts ♠

+

The classic trick-taking card game

+ + +
+ Playing as guest +
+ + + + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ + + +
+
+ +
+ +
+
+ + +
+ +
+
+ + +
+ +
+ +

+
+
+ + +
+
+ +

Waiting for Players

+
+ Room Code + —— + +
+

Share this code — 4 players needed

+
+
1
+
2
+
3
+
4
+
+

Waiting for 3 more players…

+ +
+
+ + +
+ + +
+ + + +
+
+
+
+ + + + + + + +
+ + +
+
+ + + + + +
+ + +
+
+ + + +
+
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + +
+
+
+ +
+ + +
+
+ You + + + + + +
+
+
+ +
+ + + + + + + + + + + + + + +
+
+
+ +
+

+ +
+
+ + Games Played +
+
+ + Games Won +
+
+ + Moon Shots 🌙 +
+
+ + 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
  • +
  • Game ends when any player reaches the score limit. Lowest score wins.
  • +
+
+ +
+ + +
+ + +
+
+
+ + +
+
+
+ +
🏆
+

Leaderboard

+ + + + + + + + + + + + + +
#PlayerAvg ♥WPlayed🌙
+
+
+
+ + + + + + + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..318b3bd --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "Hearts - Card Game", + "short_name": "Hearts", + "description": "The classic trick-taking card game", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0d2744", + "theme_color": "#0d2744", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..6144d6e --- /dev/null +++ b/public/style.css @@ -0,0 +1,1021 @@ +/* ── Reset & Base ──────────────────────────────── */ +*, *::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); +} + +html, body { + height: 100%; + overflow: hidden; + font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--felt-dark); + color: #fff; + touch-action: manipulation; +} + +/* ── Screens ──────────────────────────────────── */ +.screen { display: none; } +.screen.active { display: flex; flex-direction: column; height: 100vh; } + +/* ── Lobby ────────────────────────────────────── */ +#screen-lobby { + justify-content: center; + align-items: center; + background: radial-gradient(ellipse at center, #1d6b3a 0%, #0c3a20 100%); +} + +.lobby-box { + background: rgba(0,0,0,.45); + border: 1px solid rgba(255,255,255,.15); + border-radius: 16px; + padding: 32px 36px; + width: min(420px, 94vw); + display: flex; + flex-direction: column; + gap: 16px; +} + +.logo { + font-size: 2.4rem; + text-align: center; + color: var(--heart-red); + text-shadow: 0 2px 8px rgba(0,0,0,.5); + letter-spacing: 2px; +} + +.tagline { + text-align: center; + color: rgba(255,255,255,.6); + font-size: .9rem; + margin-top: -10px; +} + +.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; + 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); + 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; } + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + width: 100%; +} +.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; } + +.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; + cursor: pointer; +} +.lb-btn:hover { background: rgba(255,255,255,.1); } + +/* ── Waiting Room ─────────────────────────────── */ +#screen-waiting { + justify-content: center; + align-items: center; + background: radial-gradient(ellipse at center, #1d6b3a 0%, #0c3a20 100%); +} +.waiting-box { + background: rgba(0,0,0,.45); + border: 1px solid rgba(255,255,255,.15); + border-radius: 16px; + padding: 24px 36px 32px; + width: min(440px, 94vw); + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; +} +.waiting-box .btn-leave-screen { align-self: flex-start; } +.waiting-box h2 { font-size: 1.4rem; color: var(--heart-red); } + +.room-code-box { + display: flex; + align-items: center; + gap: 12px; + background: rgba(0,0,0,.3); + 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); +} +.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; +} +.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; + 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); + border-radius: 8px; + color: rgba(255,255,255,.85); + cursor: pointer; + font-size: .9rem; + transition: background .15s; +} +.btn-fill-bots:hover { background: rgba(255,255,255,.18); } + +/* ── 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%); +} + +.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; } + +/* Info bar */ +#info-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 16px; + background: rgba(0,0,0,.35); + border-bottom: 1px solid rgba(255,255,255,.1); + flex-shrink: 0; + font-size: .85rem; + gap: 10px; + order: 0; +} +#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; + flex: 1; +} +#score-display { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: center; +} +.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; + 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; + padding: 2px 8px; + white-space: nowrap; + flex-shrink: 0; +} + +.pass-dir-display { + font-size: .78rem; + color: rgba(255,255,255,.75); + background: rgba(100,180,255,.15); + border: 1px solid rgba(100,180,255,.3); + border-radius: 10px; + padding: 2px 8px; + white-space: nowrap; + flex-shrink: 0; +} + +/* Hamburger menu */ +.game-menu-wrap { position: relative; flex-shrink: 0; } +.game-menu-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: #0e2235; + border: 1px solid rgba(255,255,255,.18); + border-radius: 10px; + padding: 6px 0; + min-width: 160px; + z-index: 200; + box-shadow: 0 8px 28px rgba(0,0,0,.55); + display: flex; + flex-direction: column; +} +.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; +} +.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); } + +.hidden { display: none !important; } +.spectator-banner { + text-align: center; + padding: 6px; + font-size: .8rem; + background: rgba(0,0,0,.4); + color: rgba(255,255,255,.6); + border-bottom: 1px solid rgba(255,255,255,.08); + flex-shrink: 0; +} + +/* 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; + 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-label { + display: flex; + align-items: center; + gap: 4px; + font-size: .8rem; + font-weight: 600; + white-space: nowrap; + background: rgba(0,0,0,.3); + border-radius: 12px; + padding: 3px 8px; +} +.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; } } + +.player-score-badge { + font-size: .7rem; + font-weight: 700; + color: var(--gold); + min-width: 18px; + text-align: center; +} + +.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 { + width: 36px; + height: 52px; + background: linear-gradient(135deg, #1565c0 0%, #0d47a1 50%, #1565c0 100%); + border-radius: 4px; + border: 1px solid rgba(255,255,255,.25); + box-shadow: 0 1px 3px rgba(0,0,0,.4); +} +.opp-cards.vertical .card-back { width: 30px; height: 42px; } + +/* 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)); +} +#my-label { + display: flex; + align-items: center; + gap: 6px; + font-size: .82rem; + font-weight: 600; +} +/* ── Hand display: scroll mode (default) ─────────────── */ +.my-hand { + display: flex; + align-items: flex-end; + gap: 4px; + overflow-x: auto; + overflow-y: visible; + padding: 2px 0 4px; + scrollbar-width: none; + justify-content: safe center; +} +.my-hand::-webkit-scrollbar { display: none; } + +/* ── Hand display: fan/overlap mode (Hokm-style) ─────── */ +.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 ─────────────── */ +.btn-play-mode, +.btn-hand-mode { + margin-left: auto; + padding: 2px 8px; + background: rgba(255,255,255,.12); + border: 1px solid rgba(255,255,255,.25); + border-radius: 6px; + color: rgba(255,255,255,.8); + font-size: .72rem; + cursor: pointer; + white-space: nowrap; +} +.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; + color: #a5d6a7; +} +.btn-hand-mode.fan-mode { + background: rgba(100,180,255,.18); + border-color: rgba(100,180,255,.5); + 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 { + 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; +} +.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; } } + +/* ── Pass overlay ─────────────────────────────── */ +.pass-box { + 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; } + +/* One row per suit */ +.pass-hand { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; + width: 100%; +} +.pass-suit-row { + display: flex; + align-items: center; + gap: 6px; +} +.pass-suit-label { + font-size: 1.3rem; + width: 28px; + text-align: center; + flex-shrink: 0; + line-height: 1; +} +.pass-suit-label.suit-h, +.pass-suit-label.suit-d { color: var(--heart-red); } +.pass-suit-label.suit-c, +.pass-suit-label.suit-s { color: #ddd; } +.pass-suit-cards { + display: flex; + gap: 4px; + flex-wrap: wrap; +} +.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; +} +.pass-selected-label { font-size: .8rem; color: var(--muted); white-space: nowrap; } +.pass-selected-preview { + display: flex; + gap: 6px; +} +.pass-selected-preview .card { width: 48px; height: 68px; } +.pass-selected-preview .card img { width: 48px; height: 68px; } +.pass-placeholder { + width: 48px; height: 68px; + border: 2px dashed rgba(255,255,255,.2); + 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; } + +.result-scores { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} +.result-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; +} +.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; } + +.gameover-scores { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + margin-bottom: 8px; +} +.go-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 14px; + 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; } +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..e10bad5 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,57 @@ +'use strict'; +const CACHE_NAME = 'hearts-v1'; + +const PRECACHE = [ + '/', + '/index.html', + '/style.css', + '/app.js', + '/manifest.json', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/icon.svg', +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', event => { + const url = event.request.url; + if (event.request.method !== 'GET') return; + if (url.includes('/socket.io/')) return; + + 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; + }); + }) + ); + return; + } + + event.respondWith( + fetch(event.request) + .then(res => { + caches.open(CACHE_NAME).then(c => c.put(event.request, res.clone())); + return res; + }) + .catch(() => caches.match(event.request)) + ); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..098cd51 --- /dev/null +++ b/server.js @@ -0,0 +1,960 @@ +'use strict'; + +const express = require('express'); +const http = require('http'); +const https = require('https'); +const { Server } = require('socket.io'); +const path = require('path'); +const fs = require('fs'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); + +const app = express(); +const httpServer = http.createServer(app); + +// HTTPS with self-signed cert for local network / PWA mic access +let httpsServer = null; +const SSL_KEY = path.join(__dirname, 'ssl', 'key.pem'); +const SSL_CERT = path.join(__dirname, 'ssl', 'cert.pem'); +if (fs.existsSync(SSL_KEY) && fs.existsSync(SSL_CERT)) { + httpsServer = https.createServer( + { key: fs.readFileSync(SSL_KEY), cert: fs.readFileSync(SSL_CERT) }, app + ); +} + +const io = new Server(httpServer); +if (httpsServer) io.attach(httpsServer); + +const JWT_SECRET = process.env.JWT_SECRET || 'hearts-secret-change-me'; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ''; +const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET || ''; +const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || ''; +const RESEND_API_KEY = process.env.RESEND_API_KEY || ''; +const RESEND_FROM = process.env.RESEND_FROM || 'noreply@example.com'; +const HTTP_PORT = parseInt(process.env.PORT || '4000'); +const HTTPS_PORT = parseInt(process.env.HTTPS_PORT || '4443'); +// Path to Hokm's users.json — allows Hokm accounts to log into Hearts +const SHARED_USERS_FILE = process.env.SHARED_USERS_FILE || ''; + +// ─── JSON file database ──────────────────────────────────────── +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'); + +function readJSON(file, def) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; } +} +function writeJSON(file, data) { + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)); + fs.renameSync(tmp, file); +} + +let users = readJSON(USERS_FILE, []); +let stats = readJSON(STATS_FILE, []); +let config = readJSON(CONFIG_FILE, { signupsOpen: true }); + +function saveUsers() { writeJSON(USERS_FILE, users); } +function saveStats() { writeJSON(STATS_FILE, stats); } +function saveConfig() { writeJSON(CONFIG_FILE, config); } + +function isAdmin(user) { + return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME; +} + +// ─── Pending email verifications ────────────────────────────── +const pendingRegistrations = new Map(); +const PENDING_TTL_MS = 15 * 60 * 1000; + +function sendVerificationEmail(toEmail, code) { + return new Promise((resolve) => { + if (!RESEND_API_KEY) return resolve(true); + const body = JSON.stringify({ + from: RESEND_FROM, + to: [toEmail], + subject: 'Your Hearts verification code', + text: `Your Hearts verification code is: ${code}\n\nThis code expires in 15 minutes.`, + }); + const opts = { + hostname: 'api.resend.com', + path: '/emails', + method: 'POST', + headers: { + 'Authorization': `Bearer ${RESEND_API_KEY}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }; + const req = https.request(opts, (res) => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { resolve(res.statusCode >= 200 && res.statusCode < 300); }); + }); + req.on('error', () => resolve(false)); + req.write(body); + req.end(); + }); +} + +function verifyTurnstile(token) { + return new Promise((resolve) => { + if (!TURNSTILE_SECRET) return resolve(true); + const body = `secret=${encodeURIComponent(TURNSTILE_SECRET)}&response=${encodeURIComponent(token || '')}`; + const opts = { + hostname: 'challenges.cloudflare.com', + path: '/turnstile/v0/siteverify', + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(body) }, + }; + const req = https.request(opts, (res) => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { try { resolve(JSON.parse(d).success === true); } catch { resolve(false); } }); + }); + req.on('error', () => resolve(false)); + req.write(body); + req.end(); + }); +} + +let _nextId = users.length > 0 ? Math.max(...users.map(u => u.id)) + 1 : 1; +function nextId() { return _nextId++; } + +function findUser(username) { + const local = users.find(u => u.username.toLowerCase() === username.toLowerCase()); + if (local) return local; + // Fall back to Hokm's users so shared accounts work + if (SHARED_USERS_FILE) { + try { + const shared = readJSON(SHARED_USERS_FILE, []); + const s = shared.find(u => u.username.toLowerCase() === username.toLowerCase()); + if (s) return { ...s, _fromShared: true }; + } catch { /* ignore */ } + } + return null; +} + +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 }; + stats.push(s); + saveStats(); + } + return s; +} + +function addStats(userId, delta) { + const s = getStats(userId); + s.games_played = (s.games_played || 0) + (delta.games_played || 0); + s.games_won = (s.games_won || 0) + (delta.games_won || 0); + s.moon_shots = (s.moon_shots || 0) + (delta.moon_shots || 0); + s.total_score = (s.total_score || 0) + (delta.total_score || 0); + saveStats(); +} + +// ─── Auth middleware ─────────────────────────────────────────── +function requireAuth(req, res, next) { + const h = req.headers.authorization; + if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Not authenticated' }); + try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); } + catch { res.status(401).json({ error: 'Invalid or expired token' }); } +} + +// ─── Middleware ──────────────────────────────────────────────── +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'), { + setHeaders: (res, filePath) => { + if (/\.svg$/.test(filePath)) { + res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); + } else if (/\.(js|css|html)$/.test(filePath)) { + res.setHeader('Cache-Control', 'no-cache'); + } + } +})); + +// ─── Auth API ───────────────────────────────────────────────── +app.get('/api/config', (_req, res) => { + res.json({ turnstileSiteKey: TURNSTILE_SITE_KEY || null, signupsOpen: config.signupsOpen }); +}); + +app.post('/api/register/initiate', async (req, res) => { + if (!config.signupsOpen) + return res.status(403).json({ error: 'New registrations are currently closed.' }); + + const { username, password, email, cfToken } = req.body || {}; + if (!username || !password || !email) + return res.status(400).json({ error: 'Username, email and password are required' }); + if (username.trim().length < 2 || username.trim().length > 16) + return res.status(400).json({ error: 'Username must be 2–16 characters' }); + if (password.length < 4) + return res.status(400).json({ error: 'Password must be at least 4 characters' }); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) + return res.status(400).json({ error: 'Please enter a valid email address' }); + if (findUser(username)) + return res.status(409).json({ error: 'Username already taken' }); + if (users.some(u => u.email && u.email.toLowerCase() === email.trim().toLowerCase())) + return res.status(409).json({ error: 'Email already registered' }); + + if (TURNSTILE_SECRET) { + const ok = await verifyTurnstile(cfToken); + if (!ok) return res.status(400).json({ error: 'CAPTCHA verification failed. Please try again.' }); + } + + const code = String(Math.floor(100000 + Math.random() * 900000)); + const hashedPassword = bcrypt.hashSync(password, 10); + const emailKey = email.trim().toLowerCase(); + + pendingRegistrations.set(emailKey, { + username: username.trim(), + hashedPassword, + code, + expires: Date.now() + PENDING_TTL_MS, + }); + + if (!RESEND_API_KEY) { + const pending = pendingRegistrations.get(emailKey); + pendingRegistrations.delete(emailKey); + const id = nextId(); + users.push({ id, username: pending.username, password: pending.hashedPassword, email: emailKey }); + saveUsers(); + getStats(id); + const token = jwt.sign({ id, username: pending.username }, JWT_SECRET, { expiresIn: '30d' }); + return res.json({ done: true, token, username: pending.username }); + } + + const sent = await sendVerificationEmail(emailKey, code); + if (!sent) return res.status(500).json({ error: 'Failed to send verification email. Please try again.' }); + res.json({ pending: true, email: emailKey }); +}); + +app.post('/api/register/confirm', (req, res) => { + const { email, code } = req.body || {}; + if (!email || !code) + return res.status(400).json({ error: 'Email and code are required' }); + + const emailKey = email.trim().toLowerCase(); + const pending = pendingRegistrations.get(emailKey); + if (!pending) + return res.status(400).json({ error: 'No pending registration for this email. Please start over.' }); + if (Date.now() > pending.expires) { + pendingRegistrations.delete(emailKey); + return res.status(400).json({ error: 'Verification code expired. Please register again.' }); + } + if (pending.code !== code.trim()) + return res.status(400).json({ error: 'Incorrect code. Please try again.' }); + + pendingRegistrations.delete(emailKey); + if (findUser(pending.username)) + return res.status(409).json({ error: 'Username was just taken. Please choose another.' }); + if (users.some(u => u.email && u.email.toLowerCase() === emailKey)) + return res.status(409).json({ error: 'Email already registered.' }); + + const id = nextId(); + users.push({ id, username: pending.username, password: pending.hashedPassword, email: emailKey }); + saveUsers(); + getStats(id); + + const token = jwt.sign({ id, username: pending.username }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ token, username: pending.username }); +}); + +app.post('/api/login', (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) + return res.status(400).json({ error: 'Username and password required' }); + + const user = findUser(username); + if (!user || !bcrypt.compareSync(password, user.password)) + return res.status(401).json({ error: 'Invalid username or password' }); + + // For shared (Hokm) users, their userId may clash with local IDs; + // prefix shared IDs to avoid collisions in stats + const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id; + const effectiveUsername = user.username; + + const token = jwt.sign({ id: effectiveId, username: effectiveUsername }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ token, username: effectiveUsername }); +}); + +app.post('/api/change-password', requireAuth, (req, res) => { + const { currentPassword, newPassword } = req.body || {}; + if (!currentPassword || !newPassword) + return res.status(400).json({ error: 'Both passwords required' }); + if (newPassword.length < 4) + return res.status(400).json({ error: 'New password must be at least 4 characters' }); + + const user = users.find(u => u.id === req.user.id); + if (!user) return res.status(404).json({ error: 'User not found (shared accounts cannot change password here)' }); + if (!bcrypt.compareSync(currentPassword, user.password)) + return res.status(401).json({ error: 'Current password is incorrect' }); + + user.password = bcrypt.hashSync(newPassword, 10); + saveUsers(); + res.json({ ok: true }); +}); + +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); + res.json({ + username: user.username, + games_played: s.games_played || 0, + games_won: s.games_won || 0, + moon_shots: s.moon_shots || 0, + total_score: s.total_score || 0, + }); +}); + +app.get('/api/leaderboard', (_req, res) => { + const allUsers = [...users]; + if (SHARED_USERS_FILE) { + try { + const shared = readJSON(SHARED_USERS_FILE, []); + for (const su of shared) { + if (!allUsers.find(u => u.username.toLowerCase() === su.username.toLowerCase())) { + allUsers.push({ ...su, _fromShared: true }); + } + } + } catch { /* ignore */ } + } + const rows = allUsers.map(u => { + const eid = u._fromShared ? `hokm_${u.id}` : u.id; + const s = getStats(eid); + const played = s.games_played || 0; + return { + username: u.username, + games_played: played, + games_won: s.games_won || 0, + 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, + }; + }) + .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); +}); + +app.post('/api/admin/toggle-signups', requireAuth, (req, res) => { + if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' }); + config.signupsOpen = !config.signupsOpen; + saveConfig(); + res.json({ signupsOpen: config.signupsOpen }); +}); + +// ═══════════════════════════════════════════════════════════════ +// HEARTS GAME ENGINE +// ═══════════════════════════════════════════════════════════════ + +const SUITS = ['C', 'D', 'H', 'S']; +const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']; +const RANK_VAL = Object.fromEntries(RANKS.map((r, i) => [r, i + 2])); // 2→2 … A→14 + +function makeDeck() { + const d = []; + for (const s of SUITS) for (const r of RANKS) d.push(`${s}-${r}`); + return d; +} +function shuffle(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} +function suit(c) { return c.split('-')[0]; } +function rank(c) { return c.split('-')[1]; } +function rankVal(c) { return RANK_VAL[rank(c)]; } +function points(c) { return suit(c) === 'H' ? 1 : c === 'S-Q' ? 13 : 0; } +function isHeart(c) { return suit(c) === 'H'; } + +// Sort: Clubs, Diamonds, Hearts, Spades; within suit low→high +const SUIT_ORDER = { C: 0, D: 1, S: 2, H: 3 }; // C, D, S, H +function sortCards(a, b) { + const sd = SUIT_ORDER[suit(a)] - SUIT_ORDER[suit(b)]; + return sd !== 0 ? sd : rankVal(a) - rankVal(b); +} + +const PASS_DIRS = ['left', 'right', 'across', 'hold']; + +function newRoom(id) { + return { + id, + state: 'WAITING', + names: ['', '', '', ''], + seats: [null, null, null, null], + tokens: [null, null, null, null], + userIds: [null, null, null, null], + bots: [false, false, false, false], + hands: [[], [], [], []], + passCards: [null, null, null, null], + passDirection: 'left', + handNumber: 0, + trick: [], + trickLead: 0, + currentTurn: 0, + tricksPlayed: 0, + heartsBroken: false, + scores: [0, 0, 0, 0], + handPoints: [0, 0, 0, 0], + lastTrick: null, + lastTrickWinner: -1, + moonShooter: -1, + handDeltas: null, + winScore: 100, + spectators: new Set(), + trickTimer: null, + }; +} + +const rooms = new Map(); // roomId → room +const userSockets = new Map(); // userId → socket + +function makeToken() { + return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); +} + +function publicInfo(room, seat = -1) { + return { + id: room.id, + state: room.state, + names: room.names, + bots: room.bots, + hands: room.hands.map((h, i) => (i === seat ? h : h.length)), + passDirection: room.passDirection, + passReady: room.passCards.map(p => p !== null), + passSelected: seat >= 0 ? (room.passCards[seat] || []) : [], + trick: room.trick, + trickLead: room.trickLead, + currentTurn: room.currentTurn, + tricksPlayed: room.tricksPlayed, + heartsBroken: room.heartsBroken, + scores: room.scores, + // Each player only sees their own in-hand points (secret during play) + handPoints: room.handPoints.map((hp, i) => (seat < 0 || i === seat) ? hp : null), + handNumber: room.handNumber, + winScore: room.winScore, + lastTrick: room.lastTrick, + lastTrickWinner: room.lastTrickWinner, + moonShooter: room.moonShooter, + handDeltas: room.handDeltas, + gameWinner: room.gameWinner, + spectatorCount: room.spectators.size, + }; +} + +// Emit full state to each seated player (showing their own hand) +function broadcastState(room, event = 'roomInfo') { + for (let i = 0; i < 4; i++) { + const sid = room.seats[i]; + if (sid) io.to(sid).emit(event, publicInfo(room, i)); + } + // Spectators see no hand + for (const sid of room.spectators) { + io.to(sid).emit(event, publicInfo(room, -1)); + } +} + +// ─── Dealing ────────────────────────────────────────────────── +function dealHand(room) { + const deck = shuffle(makeDeck()); + for (let i = 0; i < 4; i++) { + room.hands[i] = deck.slice(i * 13, (i + 1) * 13).sort(sortCards); + } + room.trick = []; + room.lastTrick = null; + room.lastTrickWinner = -1; + room.passCards = [null, null, null, null]; + room.heartsBroken = false; + room.handPoints = [0, 0, 0, 0]; + room.tricksPlayed = 0; + room.moonShooter = -1; + room.handDeltas = null; + room.passDirection = PASS_DIRS[room.handNumber % 4]; +} + +function find2Clubs(room) { + for (let i = 0; i < 4; i++) { + if (room.hands[i].includes('C-2')) return i; + } + return 0; +} + +// ─── Card validation ────────────────────────────────────────── +function legalCards(room, player) { + const hand = room.hands[player]; + const trick = room.trick; + + // First card of the very first trick must be 2♣ + if (trick.length === 0 && room.tricksPlayed === 0) { + return hand.includes('C-2') ? ['C-2'] : hand; + } + + // Leading a trick + if (trick.length === 0) { + const nonHearts = hand.filter(c => !isHeart(c)); + if (!room.heartsBroken && nonHearts.length > 0) return nonHearts; + return hand; + } + + // Following: must follow suit if possible + const leadSuit = suit(trick[0].card); + const suitCards = hand.filter(c => suit(c) === leadSuit); + if (suitCards.length > 0) return suitCards; + + // Can't follow suit — on trick 0 avoid point cards if possible + if (room.tricksPlayed === 0) { + const safe = hand.filter(c => points(c) === 0); + if (safe.length > 0) return safe; + } + + return hand; +} + +function isLegal(room, player, card) { + return legalCards(room, player).includes(card); +} + +function trickWinner(trick) { + const ls = suit(trick[0].card); + let best = trick[0]; + for (const t of trick.slice(1)) { + if (suit(t.card) === ls && rankVal(t.card) > rankVal(best.card)) best = t; + } + return best.player; +} + +// ─── Pass exchange ──────────────────────────────────────────── +function exchangePassCards(room) { + const dir = room.passDirection; + if (dir === 'hold') return; + const sending = room.passCards.map(p => [...p]); + // Remove from senders + for (let p = 0; p < 4; p++) { + for (const c of sending[p]) { + const idx = room.hands[p].indexOf(c); + if (idx !== -1) room.hands[p].splice(idx, 1); + } + } + // Add to receivers + 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 + : dir === 'across' ? (from + 2) % 4 + : (from + 1) % 4; // right + for (const c of sending[from]) room.hands[to].push(c); + room.hands[to].sort(sortCards); + } + room.passCards = [null, null, null, null]; +} + +// ─── Game flow ──────────────────────────────────────────────── +function startPassing(room) { + room.state = 'PASSING'; + broadcastState(room, 'roomInfo'); + // Schedule bots to pass + for (let i = 0; i < 4; i++) { + if (room.bots[i]) setTimeout(() => botPass(room, i), 600 + Math.random() * 400); + } +} + +function startPlaying(room) { + room.state = 'PLAYING'; + room.trickLead = find2Clubs(room); + room.currentTurn = room.trickLead; + room.trick = []; + broadcastState(room, 'roomInfo'); + scheduleBotPlay(room); +} + +function onCardPlayed(room, player, card) { + // Remove from hand + room.hands[player] = room.hands[player].filter(c => c !== card); + // Add to trick + room.trick.push({ card, player }); + // Break hearts + if (isHeart(card) || card === 'S-Q') room.heartsBroken = true; + + if (room.trick.length < 4) { + room.currentTurn = (player + 3) % 4; // anti-clockwise + broadcastState(room, 'cardPlayed'); + scheduleBotPlay(room); + return; + } + + // Trick complete + 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; + + if (room.tricksPlayed === 13) { + broadcastState(room, 'trickWon'); + if (room.trickTimer) clearTimeout(room.trickTimer); + room.trickTimer = setTimeout(() => finishHand(room), 1400); + } else { + broadcastState(room, 'trickWon'); + if (room.trickTimer) clearTimeout(room.trickTimer); + room.trickTimer = setTimeout(() => { + room.trickLead = winner; + room.currentTurn = winner; + room.trick = []; + broadcastState(room, 'roomInfo'); + scheduleBotPlay(room); + }, 1400); + } +} + +function finishHand(room) { + // Check shoot the moon (one player has all 26 pts) + const shooter = room.handPoints.findIndex(p => p === 26); + let deltas; + if (shooter !== -1) { + deltas = [26, 26, 26, 26]; + deltas[shooter] = 0; + room.moonShooter = shooter; + } else { + deltas = room.handPoints.slice(); + room.moonShooter = -1; + } + + room.handDeltas = deltas; + for (let i = 0; i < 4; i++) room.scores[i] += deltas[i]; + + // Check game over: someone hit winScore + if (room.scores.some(s => s >= room.winScore)) { + finishGame(room); + return; + } + + room.state = 'HAND_OVER'; + broadcastState(room, 'handOver'); + + // Auto-start next hand + if (room.trickTimer) clearTimeout(room.trickTimer); + room.trickTimer = setTimeout(() => { + room.handNumber++; + dealHand(room); + if (room.passDirection === 'hold') { + startPlaying(room); + } else { + startPassing(room); + } + }, 4000); +} + +function finishGame(room) { + const minScore = Math.min(...room.scores); + // Multiple players can tie for lowest + room.gameWinner = room.scores + .map((s, i) => ({ s, i })) + .filter(x => x.s === minScore) + .map(x => x.i); + + room.state = 'GAME_OVER'; + broadcastState(room, 'gameOver'); + + // Skip stats entirely if any bot participated — games vs bots don't count + if (room.bots.some(Boolean)) return; + + const winners = new Set(room.gameWinner); + for (let i = 0; i < 4; i++) { + const uid = room.userIds[i]; + if (!uid) continue; + addStats(uid, { + games_played: 1, + games_won: winners.has(i) ? 1 : 0, + moon_shots: 0, + total_score: room.scores[i], + }); + } +} + +// ─── 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; + dealHand(room); // handNumber is 0 from newRoom; passDirection set inside + if (room.passDirection === 'hold') { + startPlaying(room); + } else { + startPassing(room); + } +} + +// ─── Bot logic ──────────────────────────────────────────────── +function botPass(room, bot) { + if (room.state !== 'PASSING') return; + if (room.passCards[bot] !== null) return; + + const hand = [...room.hands[bot]]; + const selected = []; + + // Priority: S-Q, high hearts, high of short suits + const danger = (c) => { + if (c === 'S-Q') return 100; + if (c === 'S-K') return 60; + if (c === 'S-A') return 55; + if (suit(c) === 'H') return 40 + rankVal(c); + return rankVal(c); + }; + + hand.sort((a, b) => danger(b) - danger(a)); + + // Don't pass S-Q if we have plenty of spades protection (5+ spades) + const spades = hand.filter(c => suit(c) === 'S'); + const filtered = (spades.length >= 5 && hand[0] === 'S-Q') + ? hand.slice(1) + : hand; + + for (let i = 0; i < 3 && i < filtered.length; i++) { + selected.push(filtered[i]); + } + // Fill up to 3 if we skipped S-Q + while (selected.length < 3) { + const c = hand.find(c => !selected.includes(c)); + if (c) selected.push(c); + else break; + } + + room.passCards[bot] = selected; + checkAllPassed(room); +} + +function checkAllPassed(room) { + if (room.passDirection === 'hold') { + startPlaying(room); + return; + } + if (room.passCards.every(p => p !== null)) { + exchangePassCards(room); + startPlaying(room); + } else { + broadcastState(room, 'roomInfo'); + } +} + +function scheduleBotPlay(room) { + if (room.state !== 'PLAYING') return; + const bot = room.currentTurn; + if (!room.bots[bot]) return; + const delay = 500 + Math.random() * 600; + setTimeout(() => { + if (room.state !== 'PLAYING' || room.currentTurn !== bot) return; + const card = botChooseCard(room, bot); + if (card) onCardPlayed(room, bot, card); + }, delay); +} + +function botChooseCard(room, bot) { + const legal = legalCards(room, bot); + if (legal.length === 1) return legal[0]; + const trick = room.trick; + + // Leading a trick + if (trick.length === 0) { + // Prefer to lead low clubs, then diamonds, avoid hearts/spades unless forced + const nonPoint = legal.filter(c => points(c) === 0 && suit(c) !== 'S'); + if (nonPoint.length > 0) return nonPoint.sort(sortCards)[0]; // lowest safe + const nonHeart = legal.filter(c => !isHeart(c)); + if (nonHeart.length > 0) return nonHeart.sort(sortCards)[0]; + return legal.sort(sortCards)[0]; // lowest heart + } + + // Following suit + const leadSuit = suit(trick[0].card); + const followingCards = legal.filter(c => suit(c) === leadSuit); + if (followingCards.length > 0) { + // Find current winning card + const curWinner = trickWinner(trick); + const winCard = trick.find(t => t.player === curWinner).card; + // Try to duck (play below winner) + const duck = followingCards.filter(c => rankVal(c) < rankVal(winCard)); + if (duck.length > 0) { + // Play highest duck to preserve low cards + return duck.sort(sortCards)[duck.length - 1]; + } + // Must win — play lowest winning card + return followingCards.sort(sortCards)[0]; + } + + // Discarding (can't follow suit) — dump high-danger cards + // S-Q first + if (legal.includes('S-Q')) return 'S-Q'; + // High hearts + const hearts = legal.filter(c => isHeart(c)).sort(sortCards); + if (hearts.length > 0) return hearts[hearts.length - 1]; // highest heart + // Highest remaining card + return legal.sort(sortCards)[legal.length - 1]; +} + +// ─── Socket.IO ──────────────────────────────────────────────── +io.use((socket, next) => { + const token = socket.handshake.auth?.token; + if (token) { + try { socket.data.user = jwt.verify(token, JWT_SECRET); } + catch { /* guest */ } + } + next(); +}); + +io.on('connection', (socket) => { + const user = socket.data.user; + if (user) userSockets.set(user.id, socket); + + // ── Create room ──────────────────────────────────────────── + socket.on('create', ({ name, winScore } = {}) => { + if (!name?.trim()) return socket.emit('error', 'Name is required'); + const id = Math.random().toString(36).slice(2, 8).toUpperCase(); + const room = newRoom(id); + room.winScore = Number.isFinite(+winScore) && winScore >= 50 ? Math.min(+winScore, 500) : 100; + room.names[0] = name.trim().slice(0, 16); + room.userIds[0] = user?.id || null; + room.seats[0] = socket.id; + room.tokens[0] = makeToken(); + rooms.set(id, room); + socket.join(id); + socket.emit('created', { roomId: id, seat: 0, token: room.tokens[0] }); + socket.emit('roomInfo', publicInfo(room, 0)); + }); + + // ── Join room ────────────────────────────────────────────── + socket.on('join', ({ name, roomId } = {}) => { + if (!name?.trim()) return socket.emit('joinError', 'Name is required'); + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return socket.emit('joinError', 'Room not found'); + if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress'); + + // Find first open seat (not filled by a human or bot) + let openSeat = -1; + for (let i = 0; i < 4; i++) { + if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; } + } + if (openSeat === -1) return socket.emit('joinError', 'Room is full'); + + room.names[openSeat] = name.trim().slice(0, 16); + room.userIds[openSeat] = user?.id || null; + room.seats[openSeat] = socket.id; + room.tokens[openSeat] = makeToken(); + socket.join(room.id); + socket.emit('joined', { roomId: room.id, seat: openSeat, token: room.tokens[openSeat] }); + broadcastState(room, 'roomInfo'); + + const humanCount = room.seats.filter(Boolean).length; + const botCount = room.bots.filter(Boolean).length; + if (humanCount + botCount === 4) tryStartGame(room); + }); + + // ── Spectate ─────────────────────────────────────────────── + socket.on('spectate', ({ roomId } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return socket.emit('spectateError', 'Room not found'); + room.spectators.add(socket.id); + socket.join(room.id); + socket.emit('spectating', { roomId: room.id }); + socket.emit('roomInfo', publicInfo(room, -1)); + }); + + // ── Rejoin ───────────────────────────────────────────────── + socket.on('rejoin', ({ roomId, seat, token } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return socket.emit('rejoinError', 'Room no longer exists'); + if (room.tokens[seat] !== token) return socket.emit('rejoinError', 'Invalid session token'); + + room.seats[seat] = socket.id; + socket.join(room.id); + socket.emit('rejoined', { roomId: room.id, seat, token }); + socket.emit('roomInfo', publicInfo(room, seat)); + broadcastState(room, 'roomInfo'); + }); + + // ── Fill with bots ───────────────────────────────────────── + socket.on('fillBots', ({ roomId } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room || room.state !== 'WAITING') return; + // Only the first seated player can fill bots + if (room.seats[0] !== socket.id && !room.seats.includes(socket.id)) 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()); + if (!room) return; + if (room.state !== 'PASSING') return; + if (room.tokens[seat] !== token) return; + if (!Array.isArray(cards) || cards.length !== 3) return socket.emit('passError', 'Must pass exactly 3 cards'); + if (room.passCards[seat] !== null) return; // already passed + + // Validate cards are in hand and distinct + const hand = room.hands[seat]; + const unique = [...new Set(cards)]; + if (unique.length !== 3) return socket.emit('passError', 'Cards must be distinct'); + if (!unique.every(c => hand.includes(c))) return socket.emit('passError', 'Invalid card'); + + room.passCards[seat] = unique; + broadcastState(room, 'roomInfo'); + checkAllPassed(room); + }); + + // ── Play card ────────────────────────────────────────────── + socket.on('play', ({ roomId, seat, token, card } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return; + if (room.state !== 'PLAYING') return; + if (room.tokens[seat] !== token) return; + if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn'); + if (!isLegal(room, seat, card)) return socket.emit('playError', 'Illegal card'); + onCardPlayed(room, seat, card); + }); + + // ── Leave ────────────────────────────────────────────────── + 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; + } + socket.leave(room.id); + }); + + socket.on('disconnect', () => { + if (user) userSockets.delete(user.id); + // Don't remove from room — allow rejoin + }); +}); + +// ─── Server startup ──────────────────────────────────────────── +httpServer.listen(HTTP_PORT, () => + console.log(`Hearts HTTP → http://localhost:${HTTP_PORT}`) +); +if (httpsServer) { + httpsServer.listen(HTTPS_PORT, () => + console.log(`Hearts HTTPS → https://localhost:${HTTPS_PORT}`) + ); +}