Initial commit

This commit is contained in:
goyban
2026-04-26 11:52:07 +00:00
commit 460c9d57ed
14 changed files with 3927 additions and 0 deletions
+960
View File
@@ -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 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}`)
);
}