'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}`) ); }