dc051cbe45
Layout & engine borrowed from Shelem: waiting-room menu with seat swap and force-start, game menu with bar-to-bottom, escalating turn reminder, three hand modes defaulting to fan on touch, card-count badges instead of card backs, leave buttons inside phase overlays, version footer with tap-to-update. Seats now run clockwise from the viewer (left = +1, across = +2, right = +3) so play flows anti-clockwise on screen as the server advances it; the pass exchange was flipped to match, otherwise "pass left" delivered to the right. Game history: finished games are saved with a per-hand breakdown, readable from the profile and shown on the end-of-hand and end-of-game screens. Card flashing: the service worker precaches all 52 faces, each face is decoded once into a template image, and the renderers reconcile against existing DOM instead of rebuilding it, so card nodes and their images survive a re-render. Card-count integrity: a seat can no longer put two cards into one trick, and the server snapshots each trick start and rewinds to the last sound state if the four hands ever drift apart. Leaderboard: ranked by win rate with a 5-game minimum; below-threshold players are listed unranked. Moon shots are now recorded instead of always zero, and stat reads no longer create and persist a row per user on every request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1416 lines
53 KiB
JavaScript
1416 lines
53 KiB
JavaScript
'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();
|
||
app.disable('x-powered-by'); // don't advertise the tech stack
|
||
|
||
// Safe security headers (no CSP here — the inline SW registration script and
|
||
// socket.io would both need 'unsafe-inline'/wss exceptions)
|
||
app.use((_req, res, next) => {
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
res.setHeader('X-Frame-Options', 'DENY');
|
||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||
next();
|
||
});
|
||
|
||
const httpServer = http.createServer(app);
|
||
|
||
// HTTPS with self-signed cert for local network / PWA mic access
|
||
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');
|
||
const HISTORY_FILE = path.join(DATA_DIR, 'history.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 });
|
||
// Finished games, newest last. Each participant can replay the round-by-round
|
||
// breakdown from their profile.
|
||
let history = readJSON(HISTORY_FILE, []);
|
||
|
||
function saveUsers() { writeJSON(USERS_FILE, users); }
|
||
function saveStats() { writeJSON(STATS_FILE, stats); }
|
||
function saveConfig() { writeJSON(CONFIG_FILE, config); }
|
||
function saveHistory() { writeJSON(HISTORY_FILE, history); }
|
||
|
||
// Keep the history file from growing without bound.
|
||
const MAX_HISTORY = 2000;
|
||
function pushHistory(record) {
|
||
history.push(record);
|
||
if (history.length > MAX_HISTORY) history = history.slice(-MAX_HISTORY);
|
||
saveHistory();
|
||
}
|
||
|
||
function isAdmin(user) {
|
||
return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME;
|
||
}
|
||
|
||
// ─── 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;
|
||
}
|
||
|
||
const EMPTY_STATS = { games_played: 0, games_won: 0, moon_shots: 0, total_score: 0 };
|
||
|
||
// Read-only lookup. Never creates or persists a row — the leaderboard walks
|
||
// every known user, and creating-on-read there meant one full stats.json write
|
||
// per user on every page load, plus a file steadily filling with empty rows.
|
||
function readStats(userId) {
|
||
return stats.find(s => s.userId === userId) || { userId, ...EMPTY_STATS };
|
||
}
|
||
|
||
// Write path: materialises the row so addStats has something to accumulate into.
|
||
function getStats(userId) {
|
||
let s = stats.find(s => s.userId === userId);
|
||
if (!s) {
|
||
s = { userId, ...EMPTY_STATS };
|
||
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 ─────────────────────────────────────────────────
|
||
const APP_VERSION = require('./package.json').version;
|
||
|
||
app.get('/api/config', (_req, res) => {
|
||
res.json({
|
||
turnstileSiteKey: TURNSTILE_SITE_KEY || null,
|
||
signupsOpen: config.signupsOpen,
|
||
version: APP_VERSION,
|
||
minRankedGames: MIN_RANKED_GAMES,
|
||
});
|
||
});
|
||
|
||
// Validate the stored auth token; the client calls this at startup so a stale
|
||
// or corrupted token doesn't leave it running as a "logged in" ghost.
|
||
app.get('/api/me', requireAuth, (req, res) => {
|
||
res.json({ id: req.user.id, username: req.user.username });
|
||
});
|
||
|
||
app.post('/api/register/initiate', async (req, res) => {
|
||
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 = readStats(effectiveId);
|
||
const played = s.games_played || 0;
|
||
res.json({
|
||
username: user.username,
|
||
games_played: played,
|
||
games_won: s.games_won || 0,
|
||
moon_shots: s.moon_shots || 0,
|
||
total_score: s.total_score || 0,
|
||
win_rate: played > 0 ? +(((s.games_won || 0) / played) * 100).toFixed(1) : 0,
|
||
avg_score: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : 0,
|
||
isAdmin: ADMIN_USERNAME !== '' && user.username === ADMIN_USERNAME,
|
||
signupsOpen: config.signupsOpen,
|
||
});
|
||
});
|
||
|
||
// Every finished game this player took part in, newest first, with the full
|
||
// round-by-round breakdown so the client can render it without another call.
|
||
app.get('/api/history/:username', (req, res) => {
|
||
const user = findUser(req.params.username);
|
||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||
const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id;
|
||
const games = history
|
||
.filter(g => g.players?.some(p => p.uid === effectiveId))
|
||
.sort((a, b) => b.endedAt - a.endedAt)
|
||
.map(g => {
|
||
const me = g.players.find(p => p.uid === effectiveId);
|
||
const mySeat = me ? me.seat : -1;
|
||
return {
|
||
id: g.id,
|
||
endedAt: g.endedAt,
|
||
playerNames: g.playerNames,
|
||
finalScores: g.finalScores,
|
||
winners: g.winners,
|
||
winScore: g.winScore,
|
||
mySeat,
|
||
myScore: mySeat >= 0 ? g.finalScores[mySeat] : null,
|
||
won: mySeat >= 0 && g.winners.includes(mySeat),
|
||
rounds: g.rounds,
|
||
};
|
||
});
|
||
res.json(games);
|
||
});
|
||
|
||
// Finished games a player needs before they appear in the ranking proper.
|
||
const MIN_RANKED_GAMES = 5;
|
||
|
||
app.get('/api/leaderboard', (_req, res) => {
|
||
const allUsers = [...users];
|
||
if (SHARED_USERS_FILE) {
|
||
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 = readStats(eid);
|
||
const played = s.games_played || 0;
|
||
const won = s.games_won || 0;
|
||
return {
|
||
username: u.username,
|
||
games_played: played,
|
||
games_won: won,
|
||
moon_shots: s.moon_shots || 0,
|
||
total_score: s.total_score || 0,
|
||
win_rate: played > 0 ? +((won / played) * 100).toFixed(1) : null,
|
||
score_per_game: played > 0 ? +(( s.total_score || 0) / played).toFixed(1) : null,
|
||
};
|
||
})
|
||
.filter(r => r.games_played > 0);
|
||
|
||
// Win rate is the primary ranking; average score per game breaks ties, and
|
||
// there lower is better because Hearts scores are penalties.
|
||
const byWinRate = (a, b) =>
|
||
(b.win_rate - a.win_rate) ||
|
||
(a.score_per_game - b.score_per_game) ||
|
||
(b.games_played - a.games_played);
|
||
|
||
// A single lucky game shouldn't top the board, so a player needs
|
||
// MIN_RANKED_GAMES finished games before they get a rank. Everyone below that
|
||
// is still listed, unranked, so a new player can see their progress.
|
||
const ranked = rows.filter(r => r.games_played >= MIN_RANKED_GAMES)
|
||
.sort(byWinRate)
|
||
.slice(0, 30)
|
||
.map(r => ({ ...r, ranked: true, gamesToRank: 0 }));
|
||
|
||
const provisional = rows.filter(r => r.games_played < MIN_RANKED_GAMES)
|
||
.sort((a, b) => b.games_played - a.games_played || byWinRate(a, b))
|
||
.slice(0, 10)
|
||
.map(r => ({ ...r, ranked: false,
|
||
gamesToRank: MIN_RANKED_GAMES - r.games_played }));
|
||
|
||
res.json([...ranked, ...provisional]);
|
||
});
|
||
|
||
app.get('/api/rooms', (_req, res) => {
|
||
const list = [];
|
||
for (const [, room] of rooms) {
|
||
if (!room.isPublic || room.state !== 'WAITING') continue;
|
||
list.push({
|
||
id: room.id,
|
||
playerCount: room.seats.filter(Boolean).length + room.bots.filter(Boolean).length,
|
||
winScore: room.winScore,
|
||
hostName: room.names[0] || '?',
|
||
});
|
||
}
|
||
res.json(list);
|
||
});
|
||
|
||
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,
|
||
roundHistory: [], // per-hand record for the breakdown + history
|
||
moonsBySeat: [0, 0, 0, 0], // moon shots this game, for the stats table
|
||
trickSnapshot: null, // last known-good state (see checkHandIntegrity)
|
||
spectators: new Set(),
|
||
isPublic: false,
|
||
createdAt: Date.now(),
|
||
trickTimer: null,
|
||
afkTimer: null,
|
||
afkSeat: -1,
|
||
afkVotes: new Set(),
|
||
aiControlledSeats: new Set(),
|
||
};
|
||
}
|
||
|
||
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,
|
||
roundHistory: room.roundHistory,
|
||
spectatorCount: room.spectators.size,
|
||
isPublic: room.isPublic,
|
||
aiControlledSeats: [...room.aiControlledSeats],
|
||
};
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
}
|
||
|
||
// ─── AFK / AI-takeover helpers ────────────────────────────────
|
||
function broadcast(room, event, data) {
|
||
for (let i = 0; i < 4; i++) {
|
||
if (room.seats[i]) io.to(room.seats[i]).emit(event, data);
|
||
}
|
||
for (const sid of room.spectators) io.to(sid).emit(event, data);
|
||
}
|
||
|
||
function clearAfkTimer(room) {
|
||
if (room.afkTimer) { clearTimeout(room.afkTimer); room.afkTimer = null; }
|
||
if (room.afkSeat >= 0) {
|
||
broadcast(room, 'afkResolved', {});
|
||
room.afkSeat = -1;
|
||
room.afkVotes.clear();
|
||
}
|
||
}
|
||
|
||
function broadcastAiControl(room, seat, active) {
|
||
broadcast(room, 'aiControl', { seat, active, name: room.names[seat] });
|
||
}
|
||
|
||
function scheduleAfkTimer(room) {
|
||
clearAfkTimer(room);
|
||
if (room.state !== 'PLAYING') return;
|
||
const seat = room.currentTurn;
|
||
if (seat < 0 || room.bots[seat] || !room.seats[seat]) return;
|
||
|
||
if (room.aiControlledSeats.has(seat)) {
|
||
// Already AI-controlled — keep the game moving on a short timer
|
||
room.afkTimer = setTimeout(() => {
|
||
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
|
||
if (!room.aiControlledSeats.has(seat)) return;
|
||
const card = botChooseCard(room, seat);
|
||
if (card) onCardPlayed(room, seat, card);
|
||
}, 10000);
|
||
return;
|
||
}
|
||
|
||
// Normal seat: only flag it as AFK once the minute is actually up, so a
|
||
// takeover vote can't be cast against a player who is still thinking.
|
||
room.afkTimer = setTimeout(() => {
|
||
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
|
||
room.afkSeat = seat;
|
||
room.afkVotes.clear();
|
||
broadcast(room, 'afkWarning', { seat, name: room.names[seat] });
|
||
}, 60000);
|
||
}
|
||
|
||
// ─── Dealing ──────────────────────────────────────────────────
|
||
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. Seats are laid out clockwise from the viewer, matching
|
||
// the table: your left neighbour is seat+1, across is seat+2, right is seat+3.
|
||
// (Play runs anti-clockwise, seat → seat+3, i.e. bottom → right → top → left.)
|
||
for (let from = 0; from < 4; from++) {
|
||
const to = dir === 'left' ? (from + 1) % 4
|
||
: dir === 'across' ? (from + 2) % 4
|
||
: (from + 3) % 4; // right
|
||
for (const c of sending[from]) room.hands[to].push(c);
|
||
room.hands[to].sort(sortCards);
|
||
}
|
||
room.passCards = [null, null, null, null];
|
||
}
|
||
|
||
// ─── Hand integrity: every player must hold the same card count ───
|
||
// A player who somehow gets two cards out in one trick ends the hand early for
|
||
// everyone (they run dry while the others still hold cards). Playing is guarded
|
||
// against that at several points; this is the safety net that catches whatever
|
||
// slips through, by rewinding to the last state that was known to be sound.
|
||
|
||
function handsBalanced(room) {
|
||
const n = room.hands[0].length;
|
||
return room.hands.every(h => h.length === n);
|
||
}
|
||
|
||
// Save the state at the start of a trick, but only when it is actually sound —
|
||
// so the snapshot is always the last *correct* state we can fall back to.
|
||
function snapshotTrickStart(room) {
|
||
if (!handsBalanced(room)) return;
|
||
room.trickSnapshot = {
|
||
tricksPlayed: room.tricksPlayed,
|
||
hands: room.hands.map(h => h.slice()),
|
||
handPoints: room.handPoints.slice(),
|
||
heartsBroken: room.heartsBroken,
|
||
trickLead: room.trickLead,
|
||
currentTurn: room.trickLead,
|
||
lastTrick: room.lastTrick ? room.lastTrick.slice() : null,
|
||
lastTrickWinner: room.lastTrickWinner,
|
||
};
|
||
}
|
||
|
||
function restoreTrickSnapshot(room) {
|
||
const snap = room.trickSnapshot;
|
||
if (!snap) return false;
|
||
room.tricksPlayed = snap.tricksPlayed;
|
||
room.hands = snap.hands.map(h => h.slice());
|
||
room.handPoints = snap.handPoints.slice();
|
||
room.heartsBroken = snap.heartsBroken;
|
||
room.trickLead = snap.trickLead;
|
||
room.currentTurn = snap.currentTurn;
|
||
room.lastTrick = snap.lastTrick ? snap.lastTrick.slice() : null;
|
||
room.lastTrickWinner = snap.lastTrickWinner;
|
||
room.trick = [];
|
||
return true;
|
||
}
|
||
|
||
// Returns true when the hand is sound. When it isn't, rewinds the room to the
|
||
// start of the last good trick, tells everyone, and returns false — the caller
|
||
// must abandon whatever it was about to do next.
|
||
function checkHandIntegrity(room, where) {
|
||
const expected = 13 - room.tricksPlayed;
|
||
if (handsBalanced(room) && room.hands[0].length === expected) return true;
|
||
|
||
console.warn(`[${room.id}] card-count desync at ${where}: ` +
|
||
`hands ${room.hands.map(h => h.length).join('/')}, expected ${expected} each`);
|
||
|
||
if (!restoreTrickSnapshot(room)) {
|
||
console.error(`[${room.id}] no sound snapshot to restore — hand left as-is`);
|
||
return true; // nothing better to do than let the hand run out
|
||
}
|
||
|
||
clearAfkTimer(room);
|
||
if (room.trickTimer) { clearTimeout(room.trickTimer); room.trickTimer = null; }
|
||
broadcast(room, 'stateReverted', { trick: room.tricksPlayed + 1 });
|
||
broadcastState(room, 'roomInfo');
|
||
scheduleBotPlay(room);
|
||
scheduleAfkTimer(room);
|
||
return false;
|
||
}
|
||
|
||
// ─── Game flow ────────────────────────────────────────────────
|
||
function startPassing(room) {
|
||
clearAfkTimer(room);
|
||
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 = [];
|
||
snapshotTrickStart(room);
|
||
broadcastState(room, 'roomInfo');
|
||
scheduleBotPlay(room);
|
||
scheduleAfkTimer(room);
|
||
}
|
||
|
||
function onCardPlayed(room, player, card) {
|
||
// ── Guards against a seat getting two cards into one trick ──
|
||
// Bots, the AFK auto-player and the socket handler all land here, so the
|
||
// checks live in one place rather than at each call site.
|
||
if (room.state !== 'PLAYING') return;
|
||
if (room.trick.length >= 4) return; // trick already resolving
|
||
if (room.currentTurn !== player) return; // not this seat's turn
|
||
if (room.trick.some(t => t.player === player)) return; // already played this trick
|
||
|
||
const idx = room.hands[player].indexOf(card);
|
||
if (idx === -1) return; // card isn't in hand — stale or duplicated click
|
||
room.hands[player].splice(idx, 1); // remove exactly one copy
|
||
|
||
room.trick.push({ card, player });
|
||
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);
|
||
scheduleAfkTimer(room);
|
||
return;
|
||
}
|
||
|
||
// Trick complete. Nobody is on turn during the resolution window, so the
|
||
// player who closed the trick can't slip a second card in before the
|
||
// trick-advance timer fires.
|
||
room.currentTurn = -1;
|
||
clearAfkTimer(room);
|
||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||
|
||
const winner = trickWinner(room.trick);
|
||
const trickPts = room.trick.reduce((s, t) => s + points(t.card), 0);
|
||
room.handPoints[winner] += trickPts;
|
||
room.tricksPlayed++;
|
||
room.lastTrick = room.trick.slice();
|
||
room.lastTrickWinner = winner;
|
||
|
||
// Every hand must now hold the same number of cards. If not, rewind.
|
||
if (!checkHandIntegrity(room, `end of trick ${room.tricksPlayed}`)) return;
|
||
|
||
broadcastState(room, 'trickWon');
|
||
|
||
if (room.tricksPlayed === 13) {
|
||
room.trickTimer = setTimeout(() => finishHand(room), 1400);
|
||
} else {
|
||
room.trickTimer = setTimeout(() => {
|
||
room.trickLead = winner;
|
||
room.currentTurn = winner;
|
||
room.trick = [];
|
||
snapshotTrickStart(room);
|
||
broadcastState(room, 'roomInfo');
|
||
scheduleBotPlay(room);
|
||
scheduleAfkTimer(room);
|
||
}, 1400);
|
||
}
|
||
}
|
||
|
||
function finishHand(room) {
|
||
clearAfkTimer(room);
|
||
// A hand can only end with every hand empty. If cards are left over the
|
||
// deal desynced somewhere; rewind rather than score a broken hand.
|
||
if (room.hands.some(h => h.length > 0)) {
|
||
if (!checkHandIntegrity(room, 'end of hand')) return;
|
||
}
|
||
for (const s of room.aiControlledSeats) broadcastAiControl(room, s, false);
|
||
room.aiControlledSeats.clear();
|
||
// Check shoot the moon (one player has all 26 pts)
|
||
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];
|
||
if (shooter !== -1) room.moonsBySeat[shooter]++;
|
||
|
||
// Record the hand for the end-of-hand breakdown, the game-over screen and
|
||
// the saved game history.
|
||
room.roundHistory.push({
|
||
hand: room.roundHistory.length + 1,
|
||
deltas: deltas.slice(),
|
||
scores: room.scores.slice(),
|
||
moonShooter: shooter,
|
||
moonShooterName: shooter !== -1 ? (room.names[shooter] || '') : '',
|
||
});
|
||
|
||
// Check game over: someone hit winScore
|
||
if (room.scores.some(s => s >= room.winScore)) {
|
||
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,
|
||
// and they aren't written to history either, to keep both lists clean.
|
||
if (room.bots.some(Boolean)) return;
|
||
|
||
const winners = new Set(room.gameWinner);
|
||
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: room.moonsBySeat[i],
|
||
total_score: room.scores[i],
|
||
});
|
||
}
|
||
|
||
saveGameHistory(room, winners);
|
||
}
|
||
|
||
// Persist a finished game so each participant can review it later.
|
||
function saveGameHistory(room, winners) {
|
||
pushHistory({
|
||
id: `${room.id}-${Date.now()}`,
|
||
endedAt: Date.now(),
|
||
winScore: room.winScore,
|
||
playerNames: room.names.slice(),
|
||
finalScores: room.scores.slice(),
|
||
winners: [...winners],
|
||
players: room.userIds
|
||
.map((uid, seat) => ({ uid, seat, name: room.names[seat] }))
|
||
.filter(p => p.uid != null),
|
||
rounds: room.roundHistory,
|
||
});
|
||
}
|
||
|
||
// ─── Room teardown / leave recovery ───────────────────────────
|
||
function destroyRoom(room) {
|
||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||
if (room.afkTimer) clearTimeout(room.afkTimer);
|
||
rooms.delete(room.id);
|
||
}
|
||
|
||
// A human who leaves mid-game has their seat handed to AI; nudge the current
|
||
// phase so that AI acts if the game is now waiting on that seat.
|
||
function resumeAfterLeave(room, seat) {
|
||
if (room.state === 'PASSING') {
|
||
if (room.bots[seat] && room.passCards[seat] === null) {
|
||
setTimeout(() => botPass(room, seat), 700);
|
||
}
|
||
} else if (room.state === 'PLAYING') {
|
||
scheduleAfkTimer(room);
|
||
scheduleBotPlay(room);
|
||
}
|
||
}
|
||
|
||
// ─── Start the game ───────────────────────────────────────────
|
||
function tryStartGame(room) {
|
||
if (room.seats.filter(Boolean).length + room.bots.filter(Boolean).length < 4) return;
|
||
if (room.seats.some((s, i) => !s && !room.bots[i])) return;
|
||
// Fresh game — clear the accumulators that span hands
|
||
room.scores = [0, 0, 0, 0];
|
||
room.roundHistory = [];
|
||
room.moonsBySeat = [0, 0, 0, 0];
|
||
dealHand(room); // handNumber is 0 from newRoom; passDirection set inside
|
||
if (room.passDirection === 'hold') {
|
||
startPlaying(room);
|
||
} 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);
|
||
// Push active game to this user on any device (cross-device rejoin)
|
||
for (const [, room] of rooms) {
|
||
const seat = room.userIds.indexOf(user.id);
|
||
if (seat >= 0 && room.tokens[seat] !== null && room.state !== 'GAME_OVER') {
|
||
socket.emit('hasActiveGame', { roomId: room.id, seat, token: room.tokens[seat] });
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Create room ────────────────────────────────────────────
|
||
socket.on('create', ({ name, winScore, isPublic } = {}) => {
|
||
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.isPublic = isPublic === true;
|
||
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');
|
||
|
||
const openSeats = [];
|
||
for (let i = 0; i < 4; i++) {
|
||
if (!room.seats[i] && !room.bots[i]) openSeats.push(i);
|
||
}
|
||
if (openSeats.length === 0) return socket.emit('joinError', 'Room is full');
|
||
const openSeat = room.isPublic
|
||
? openSeats[Math.floor(Math.random() * openSeats.length)]
|
||
: openSeats[0];
|
||
|
||
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');
|
||
|
||
const tokenOk = room.tokens[seat] === token;
|
||
const userOk = user &&
|
||
room.userIds[seat] != null &&
|
||
room.userIds[seat] === user.id &&
|
||
room.tokens[seat] !== null; // null means player explicitly left
|
||
|
||
if (!tokenOk && !userOk) return socket.emit('rejoinError', 'Invalid session token');
|
||
|
||
// Re-issue a fresh token when only userId matched (old token lives on the other device)
|
||
if (!tokenOk) room.tokens[seat] = makeToken();
|
||
|
||
room.seats[seat] = socket.id;
|
||
socket.join(room.id);
|
||
socket.emit('rejoined', { roomId: room.id, seat, token: room.tokens[seat] });
|
||
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);
|
||
});
|
||
|
||
// ── Swap seats (creator only, while still in the waiting room) ──
|
||
socket.on('swapSeats', ({ roomId, seatA, seatB } = {}) => {
|
||
const room = rooms.get((roomId || '').toUpperCase());
|
||
if (!room || room.state !== 'WAITING') return;
|
||
const isCreator = room.seats[0] === socket.id ||
|
||
(user?.id && room.userIds[0] === user.id);
|
||
if (!isCreator) return;
|
||
if (seatA === seatB || ![0,1,2,3].includes(seatA) || ![0,1,2,3].includes(seatB)) return;
|
||
|
||
for (const key of ['names', 'seats', 'tokens', 'userIds', 'bots']) {
|
||
[room[key][seatA], room[key][seatB]] = [room[key][seatB], room[key][seatA]];
|
||
}
|
||
// Each moved player needs their new seat number and token
|
||
if (room.seats[seatA]) io.to(room.seats[seatA]).emit('seatChanged', { seat: seatA, token: room.tokens[seatA] });
|
||
if (room.seats[seatB]) io.to(room.seats[seatB]).emit('seatChanged', { seat: seatB, token: room.tokens[seatB] });
|
||
broadcastState(room, 'roomInfo');
|
||
});
|
||
|
||
// ── Force start (creator only, fills the remaining seats with bots) ──
|
||
socket.on('forceStart', ({ roomId } = {}) => {
|
||
const room = rooms.get((roomId || '').toUpperCase());
|
||
if (!room || room.state !== 'WAITING') return;
|
||
const isCreator = room.seats[0] === socket.id ||
|
||
(user?.id && room.userIds[0] === user.id);
|
||
if (!isCreator) return;
|
||
const botNames = ['Alice', 'Bob', 'Charlie', 'Diana'];
|
||
for (let i = 0; i < 4; i++) {
|
||
if (!room.seats[i] && !room.bots[i]) {
|
||
room.bots[i] = true;
|
||
room.names[i] = botNames[i];
|
||
}
|
||
}
|
||
broadcastState(room, 'roomInfo');
|
||
tryStartGame(room);
|
||
});
|
||
|
||
// ── Pass cards ─────────────────────────────────────────────
|
||
socket.on('passCards', ({ roomId, seat, token, cards } = {}) => {
|
||
const room = rooms.get((roomId || '').toUpperCase());
|
||
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');
|
||
// Belt and braces against a double-tap or a duplicated socket event landing
|
||
// twice: one seat may only ever contribute one card to a trick.
|
||
if (room.trick.some(t => t.player === seat)) return socket.emit('playError', 'Already played this trick');
|
||
if (!room.hands[seat].includes(card)) return socket.emit('playError', 'Card not in hand');
|
||
if (!isLegal(room, seat, card)) return socket.emit('playError', 'Illegal card');
|
||
if (room.aiControlledSeats.has(seat)) {
|
||
room.aiControlledSeats.delete(seat);
|
||
broadcastAiControl(room, seat, false);
|
||
clearAfkTimer(room);
|
||
}
|
||
onCardPlayed(room, seat, card);
|
||
});
|
||
|
||
// ── Vote to let AI take over an AFK player ────────────────
|
||
socket.on('voteAITakeover', ({ roomId } = {}) => {
|
||
const room = rooms.get((roomId || '').toUpperCase());
|
||
if (!room || room.state !== 'PLAYING') return;
|
||
if (room.afkSeat < 0) return;
|
||
|
||
const voterSeat = room.seats.indexOf(socket.id);
|
||
if (voterSeat < 0 || voterSeat === room.afkSeat || room.bots[voterSeat]) return;
|
||
|
||
room.afkVotes.add(socket.id);
|
||
|
||
const eligible = room.seats.filter((s, i) => s && !room.bots[i] && i !== room.afkSeat).length;
|
||
const needed = eligible <= 1 ? 1 : Math.floor(eligible / 2) + 1;
|
||
const votes = [...room.afkVotes].filter(sid => {
|
||
const idx = room.seats.indexOf(sid);
|
||
return idx >= 0 && idx !== room.afkSeat && !room.bots[idx];
|
||
}).length;
|
||
|
||
if (votes >= needed) {
|
||
const seat = room.afkSeat;
|
||
room.aiControlledSeats.add(seat);
|
||
broadcastAiControl(room, seat, true);
|
||
clearAfkTimer(room);
|
||
scheduleAfkTimer(room);
|
||
}
|
||
});
|
||
|
||
// ── Leave ──────────────────────────────────────────────────
|
||
socket.on('leave', ({ roomId, seat, token } = {}) => {
|
||
const room = rooms.get((roomId || '').toUpperCase());
|
||
if (!room) return;
|
||
if (room.tokens[seat] !== token) { socket.leave(room.id); return; }
|
||
|
||
socket.leave(room.id);
|
||
|
||
// Vacate the seat. Nulling userIds also stops hasActiveGame pulling this
|
||
// player straight back into the room they just left.
|
||
room.seats[seat] = null;
|
||
room.tokens[seat] = null;
|
||
room.userIds[seat] = null;
|
||
room.aiControlledSeats.delete(seat);
|
||
|
||
const inProgress = room.state !== 'WAITING' && room.state !== 'GAME_OVER';
|
||
|
||
// Nobody left to play for — tear the room down
|
||
if (!room.seats.some(Boolean) && room.spectators.size === 0) {
|
||
destroyRoom(room);
|
||
return;
|
||
}
|
||
|
||
if (inProgress) {
|
||
// Hand the seat to AI so the game keeps going for everyone else
|
||
room.bots[seat] = true;
|
||
broadcastState(room, 'roomInfo');
|
||
resumeAfterLeave(room, seat);
|
||
} else {
|
||
room.names[seat] = '';
|
||
broadcastState(room, 'roomInfo');
|
||
}
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
if (user) userSockets.delete(user.id);
|
||
// Don't remove from room — allow rejoin
|
||
});
|
||
});
|
||
|
||
// ─── Stale public room cleanup ─────────────────────────────────
|
||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [id, room] of rooms) {
|
||
if (room.isPublic && room.state === 'WAITING' && now - room.createdAt > SIX_HOURS_MS) {
|
||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||
if (room.afkTimer) clearTimeout(room.afkTimer);
|
||
rooms.delete(id);
|
||
}
|
||
}
|
||
}, 30 * 60 * 1000);
|
||
|
||
// ─── 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}`)
|
||
);
|
||
}
|