e499e89027
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1101 lines
40 KiB
JavaScript
1101 lines
40 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();
|
||
const httpServer = http.createServer(app);
|
||
|
||
// HTTPS with self-signed cert for local network / PWA mic access
|
||
let httpsServer = null;
|
||
const SSL_KEY = path.join(__dirname, 'ssl', 'key.pem');
|
||
const SSL_CERT = path.join(__dirname, 'ssl', 'cert.pem');
|
||
if (fs.existsSync(SSL_KEY) && fs.existsSync(SSL_CERT)) {
|
||
httpsServer = https.createServer(
|
||
{ key: fs.readFileSync(SSL_KEY), cert: fs.readFileSync(SSL_CERT) }, app
|
||
);
|
||
}
|
||
|
||
const io = new Server(httpServer);
|
||
if (httpsServer) io.attach(httpsServer);
|
||
|
||
const JWT_SECRET = process.env.JWT_SECRET || 'hearts-secret-change-me';
|
||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || '';
|
||
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET || '';
|
||
const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '';
|
||
const RESEND_API_KEY = process.env.RESEND_API_KEY || '';
|
||
const RESEND_FROM = process.env.RESEND_FROM || 'noreply@example.com';
|
||
const HTTP_PORT = parseInt(process.env.PORT || '4000');
|
||
const HTTPS_PORT = parseInt(process.env.HTTPS_PORT || '4443');
|
||
// Path to Hokm's users.json — allows Hokm accounts to log into Hearts
|
||
const SHARED_USERS_FILE = process.env.SHARED_USERS_FILE || '';
|
||
|
||
// ─── JSON file database ────────────────────────────────────────
|
||
const DATA_DIR = path.join(__dirname, 'data');
|
||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
|
||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
|
||
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||
|
||
function readJSON(file, def) {
|
||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; }
|
||
}
|
||
function writeJSON(file, data) {
|
||
const tmp = file + '.tmp';
|
||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
||
fs.renameSync(tmp, file);
|
||
}
|
||
|
||
let users = readJSON(USERS_FILE, []);
|
||
let stats = readJSON(STATS_FILE, []);
|
||
let config = readJSON(CONFIG_FILE, { signupsOpen: true });
|
||
|
||
function saveUsers() { writeJSON(USERS_FILE, users); }
|
||
function saveStats() { writeJSON(STATS_FILE, stats); }
|
||
function saveConfig() { writeJSON(CONFIG_FILE, config); }
|
||
|
||
function isAdmin(user) {
|
||
return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME;
|
||
}
|
||
|
||
// ─── Pending email verifications ──────────────────────────────
|
||
const pendingRegistrations = new Map();
|
||
const PENDING_TTL_MS = 15 * 60 * 1000;
|
||
|
||
function sendVerificationEmail(toEmail, code) {
|
||
return new Promise((resolve) => {
|
||
if (!RESEND_API_KEY) return resolve(true);
|
||
const body = JSON.stringify({
|
||
from: RESEND_FROM,
|
||
to: [toEmail],
|
||
subject: 'Your Hearts verification code',
|
||
text: `Your Hearts verification code is: ${code}\n\nThis code expires in 15 minutes.`,
|
||
});
|
||
const opts = {
|
||
hostname: 'api.resend.com',
|
||
path: '/emails',
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(body),
|
||
},
|
||
};
|
||
const req = https.request(opts, (res) => {
|
||
let d = '';
|
||
res.on('data', c => d += c);
|
||
res.on('end', () => { resolve(res.statusCode >= 200 && res.statusCode < 300); });
|
||
});
|
||
req.on('error', () => resolve(false));
|
||
req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function verifyTurnstile(token) {
|
||
return new Promise((resolve) => {
|
||
if (!TURNSTILE_SECRET) return resolve(true);
|
||
const body = `secret=${encodeURIComponent(TURNSTILE_SECRET)}&response=${encodeURIComponent(token || '')}`;
|
||
const opts = {
|
||
hostname: 'challenges.cloudflare.com',
|
||
path: '/turnstile/v0/siteverify',
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
|
||
'Content-Length': Buffer.byteLength(body) },
|
||
};
|
||
const req = https.request(opts, (res) => {
|
||
let d = '';
|
||
res.on('data', c => d += c);
|
||
res.on('end', () => { try { resolve(JSON.parse(d).success === true); } catch { resolve(false); } });
|
||
});
|
||
req.on('error', () => resolve(false));
|
||
req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
let _nextId = users.length > 0 ? Math.max(...users.map(u => u.id)) + 1 : 1;
|
||
function nextId() { return _nextId++; }
|
||
|
||
function findUser(username) {
|
||
const local = users.find(u => u.username.toLowerCase() === username.toLowerCase());
|
||
if (local) return local;
|
||
// Fall back to Hokm's users so shared accounts work
|
||
if (SHARED_USERS_FILE) {
|
||
try {
|
||
const shared = readJSON(SHARED_USERS_FILE, []);
|
||
const s = shared.find(u => u.username.toLowerCase() === username.toLowerCase());
|
||
if (s) return { ...s, _fromShared: true };
|
||
} catch { /* ignore */ }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getStats(userId) {
|
||
let s = stats.find(s => s.userId === userId);
|
||
if (!s) {
|
||
s = { userId, games_played: 0, games_won: 0, moon_shots: 0, total_score: 0 };
|
||
stats.push(s);
|
||
saveStats();
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function addStats(userId, delta) {
|
||
const s = getStats(userId);
|
||
s.games_played = (s.games_played || 0) + (delta.games_played || 0);
|
||
s.games_won = (s.games_won || 0) + (delta.games_won || 0);
|
||
s.moon_shots = (s.moon_shots || 0) + (delta.moon_shots || 0);
|
||
s.total_score = (s.total_score || 0) + (delta.total_score || 0);
|
||
saveStats();
|
||
}
|
||
|
||
// ─── Auth middleware ───────────────────────────────────────────
|
||
function requireAuth(req, res, next) {
|
||
const h = req.headers.authorization;
|
||
if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Not authenticated' });
|
||
try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); }
|
||
catch { res.status(401).json({ error: 'Invalid or expired token' }); }
|
||
}
|
||
|
||
// ─── Middleware ────────────────────────────────────────────────
|
||
app.use(express.json());
|
||
app.use(express.static(path.join(__dirname, 'public'), {
|
||
setHeaders: (res, filePath) => {
|
||
if (/\.svg$/.test(filePath)) {
|
||
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable');
|
||
} else if (/\.(js|css|html)$/.test(filePath)) {
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
}
|
||
}
|
||
}));
|
||
|
||
// ─── Auth API ─────────────────────────────────────────────────
|
||
app.get('/api/config', (_req, res) => {
|
||
res.json({ turnstileSiteKey: TURNSTILE_SITE_KEY || null, signupsOpen: config.signupsOpen });
|
||
});
|
||
|
||
app.post('/api/register/initiate', async (req, res) => {
|
||
if (!config.signupsOpen)
|
||
return res.status(403).json({ error: 'New registrations are currently closed.' });
|
||
|
||
const { username, password, email, cfToken } = req.body || {};
|
||
if (!username || !password || !email)
|
||
return res.status(400).json({ error: 'Username, email and password are required' });
|
||
if (username.trim().length < 2 || username.trim().length > 16)
|
||
return res.status(400).json({ error: 'Username must be 2–16 characters' });
|
||
if (password.length < 4)
|
||
return res.status(400).json({ error: 'Password must be at least 4 characters' });
|
||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()))
|
||
return res.status(400).json({ error: 'Please enter a valid email address' });
|
||
if (findUser(username))
|
||
return res.status(409).json({ error: 'Username already taken' });
|
||
if (users.some(u => u.email && u.email.toLowerCase() === email.trim().toLowerCase()))
|
||
return res.status(409).json({ error: 'Email already registered' });
|
||
|
||
if (TURNSTILE_SECRET) {
|
||
const ok = await verifyTurnstile(cfToken);
|
||
if (!ok) return res.status(400).json({ error: 'CAPTCHA verification failed. Please try again.' });
|
||
}
|
||
|
||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||
const hashedPassword = bcrypt.hashSync(password, 10);
|
||
const emailKey = email.trim().toLowerCase();
|
||
|
||
pendingRegistrations.set(emailKey, {
|
||
username: username.trim(),
|
||
hashedPassword,
|
||
code,
|
||
expires: Date.now() + PENDING_TTL_MS,
|
||
});
|
||
|
||
if (!RESEND_API_KEY) {
|
||
const pending = pendingRegistrations.get(emailKey);
|
||
pendingRegistrations.delete(emailKey);
|
||
const id = nextId();
|
||
users.push({ id, username: pending.username, password: pending.hashedPassword, email: emailKey });
|
||
saveUsers();
|
||
getStats(id);
|
||
const token = jwt.sign({ id, username: pending.username }, JWT_SECRET, { expiresIn: '30d' });
|
||
return res.json({ done: true, token, username: pending.username });
|
||
}
|
||
|
||
const sent = await sendVerificationEmail(emailKey, code);
|
||
if (!sent) return res.status(500).json({ error: 'Failed to send verification email. Please try again.' });
|
||
res.json({ pending: true, email: emailKey });
|
||
});
|
||
|
||
app.post('/api/register/confirm', (req, res) => {
|
||
const { email, code } = req.body || {};
|
||
if (!email || !code)
|
||
return res.status(400).json({ error: 'Email and code are required' });
|
||
|
||
const emailKey = email.trim().toLowerCase();
|
||
const pending = pendingRegistrations.get(emailKey);
|
||
if (!pending)
|
||
return res.status(400).json({ error: 'No pending registration for this email. Please start over.' });
|
||
if (Date.now() > pending.expires) {
|
||
pendingRegistrations.delete(emailKey);
|
||
return res.status(400).json({ error: 'Verification code expired. Please register again.' });
|
||
}
|
||
if (pending.code !== code.trim())
|
||
return res.status(400).json({ error: 'Incorrect code. Please try again.' });
|
||
|
||
pendingRegistrations.delete(emailKey);
|
||
if (findUser(pending.username))
|
||
return res.status(409).json({ error: 'Username was just taken. Please choose another.' });
|
||
if (users.some(u => u.email && u.email.toLowerCase() === emailKey))
|
||
return res.status(409).json({ error: 'Email already registered.' });
|
||
|
||
const id = nextId();
|
||
users.push({ id, username: pending.username, password: pending.hashedPassword, email: emailKey });
|
||
saveUsers();
|
||
getStats(id);
|
||
|
||
const token = jwt.sign({ id, username: pending.username }, JWT_SECRET, { expiresIn: '30d' });
|
||
res.json({ token, username: pending.username });
|
||
});
|
||
|
||
app.post('/api/login', (req, res) => {
|
||
const { username, password } = req.body || {};
|
||
if (!username || !password)
|
||
return res.status(400).json({ error: 'Username and password required' });
|
||
|
||
const user = findUser(username);
|
||
if (!user || !bcrypt.compareSync(password, user.password))
|
||
return res.status(401).json({ error: 'Invalid username or password' });
|
||
|
||
// For shared (Hokm) users, their userId may clash with local IDs;
|
||
// prefix shared IDs to avoid collisions in stats
|
||
const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id;
|
||
const effectiveUsername = user.username;
|
||
|
||
const token = jwt.sign({ id: effectiveId, username: effectiveUsername }, JWT_SECRET, { expiresIn: '30d' });
|
||
res.json({ token, username: effectiveUsername });
|
||
});
|
||
|
||
app.post('/api/change-password', requireAuth, (req, res) => {
|
||
const { currentPassword, newPassword } = req.body || {};
|
||
if (!currentPassword || !newPassword)
|
||
return res.status(400).json({ error: 'Both passwords required' });
|
||
if (newPassword.length < 4)
|
||
return res.status(400).json({ error: 'New password must be at least 4 characters' });
|
||
|
||
const user = users.find(u => u.id === req.user.id);
|
||
if (!user) return res.status(404).json({ error: 'User not found (shared accounts cannot change password here)' });
|
||
if (!bcrypt.compareSync(currentPassword, user.password))
|
||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||
|
||
user.password = bcrypt.hashSync(newPassword, 10);
|
||
saveUsers();
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
app.get('/api/profile/:username', (req, res) => {
|
||
const user = findUser(req.params.username);
|
||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||
const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id;
|
||
const s = getStats(effectiveId);
|
||
res.json({
|
||
username: user.username,
|
||
games_played: s.games_played || 0,
|
||
games_won: s.games_won || 0,
|
||
moon_shots: s.moon_shots || 0,
|
||
total_score: s.total_score || 0,
|
||
});
|
||
});
|
||
|
||
app.get('/api/leaderboard', (_req, res) => {
|
||
const allUsers = [...users];
|
||
if (SHARED_USERS_FILE) {
|
||
try {
|
||
const shared = readJSON(SHARED_USERS_FILE, []);
|
||
for (const su of shared) {
|
||
if (!allUsers.find(u => u.username.toLowerCase() === su.username.toLowerCase())) {
|
||
allUsers.push({ ...su, _fromShared: true });
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
const rows = allUsers.map(u => {
|
||
const eid = u._fromShared ? `hokm_${u.id}` : u.id;
|
||
const s = getStats(eid);
|
||
const played = s.games_played || 0;
|
||
return {
|
||
username: u.username,
|
||
games_played: played,
|
||
games_won: s.games_won || 0,
|
||
moon_shots: s.moon_shots || 0,
|
||
total_score: s.total_score || 0,
|
||
score_per_game: played > 0 ? +(( s.total_score || 0) / played).toFixed(2) : null,
|
||
};
|
||
})
|
||
.filter(r => r.games_played > 0)
|
||
// Lower score per game = better player — sort ascending
|
||
.sort((a, b) => {
|
||
if (a.score_per_game === null) return 1;
|
||
if (b.score_per_game === null) return -1;
|
||
return a.score_per_game - b.score_per_game || b.games_played - a.games_played;
|
||
})
|
||
.slice(0, 30);
|
||
res.json(rows);
|
||
});
|
||
|
||
app.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,
|
||
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,
|
||
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);
|
||
const seat = room.currentTurn;
|
||
if (room.bots[seat]) return;
|
||
if (room.aiControlledSeats.has(seat)) {
|
||
room.afkTimer = setTimeout(() => {
|
||
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
|
||
const card = botChooseCard(room, seat);
|
||
if (card) onCardPlayed(room, seat, card);
|
||
}, 10000);
|
||
} else {
|
||
room.afkSeat = seat;
|
||
room.afkTimer = setTimeout(() => {
|
||
if (room.state !== 'PLAYING') return;
|
||
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
|
||
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) {
|
||
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 = [];
|
||
broadcastState(room, 'roomInfo');
|
||
scheduleBotPlay(room);
|
||
scheduleAfkTimer(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);
|
||
scheduleAfkTimer(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');
|
||
clearAfkTimer(room);
|
||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||
room.trickTimer = setTimeout(() => finishHand(room), 1400);
|
||
} else {
|
||
broadcastState(room, 'trickWon');
|
||
clearAfkTimer(room);
|
||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||
room.trickTimer = setTimeout(() => {
|
||
room.trickLead = winner;
|
||
room.currentTurn = winner;
|
||
room.trick = [];
|
||
broadcastState(room, 'roomInfo');
|
||
scheduleBotPlay(room);
|
||
scheduleAfkTimer(room);
|
||
}, 1400);
|
||
}
|
||
}
|
||
|
||
function finishHand(room) {
|
||
clearAfkTimer(room);
|
||
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];
|
||
|
||
// 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);
|
||
// 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);
|
||
});
|
||
|
||
// ── 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');
|
||
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) {
|
||
room.seats[seat] = null;
|
||
room.tokens[seat] = null;
|
||
room.userIds[seat] = null;
|
||
}
|
||
socket.leave(room.id);
|
||
});
|
||
|
||
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}`)
|
||
);
|
||
}
|