Files
hearts/server.js
T
2026-04-26 11:52:07 +00:00

961 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 216 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}`)
);
}