Initial commit

This commit is contained in:
goyban
2026-04-26 11:52:07 +00:00
commit 460c9d57ed
14 changed files with 3927 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
JWT_SECRET=hearts-secret-change-me
ADMIN_USERNAME=
PORT=4000
HTTPS_PORT=4443
# Point to Hokm's users.json so Hokm accounts work here too.
# When running via docker-compose this is the container path (matches the volume mount in docker-compose.yml).
# When running directly with `node server.js`, change to: /root/hokm/data/users.json
SHARED_USERS_FILE=/hokm-data/users.json
# Optional: Resend email API for verification codes
RESEND_API_KEY=
RESEND_FROM=noreply@example.com
# Optional: Cloudflare Turnstile CAPTCHA
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET=
+4
View File
@@ -0,0 +1,4 @@
.env
rerun.sh
data/
node_modules/
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
# Self-signed TLS cert for local HTTPS access
RUN apk add --no-cache openssl && \
mkdir -p /app/ssl && \
openssl req -x509 -newkey rsa:2048 \
-keyout /app/ssl/key.pem -out /app/ssl/cert.pem \
-days 3650 -nodes -subj "/CN=hearts-game" && \
apk del openssl
COPY server.js ./
COPY gen-icons.js ./
COPY public/ ./public/
# cards/ is a symlink to Hokm's cards — supplied at runtime via docker-compose volume mount
RUN rm -rf /app/public/cards && mkdir -p /app/public/cards
RUN node gen-icons.js
EXPOSE 4000 4443
CMD ["node", "server.js"]
+15
View File
@@ -0,0 +1,15 @@
services:
hearts:
build: .
ports:
- "4000:4000"
- "4443:4443"
restart: unless-stopped
volumes:
- ./data:/app/data
# Hokm's data dir (read-only) so Hearts can verify Hokm accounts
- /root/hokm/data:/hokm-data:ro
# Share Hokm's card SVGs — same card assets, no duplication
- /root/hokm/public/cards:/app/public/cards:ro
env_file:
- .env
+126
View File
@@ -0,0 +1,126 @@
// Generates simple PNG icons using pure Node.js (no external packages needed).
'use strict';
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const outDir = path.join(__dirname, 'public', 'icons');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
function writePNG(filePath, size) {
const channels = 4; // RGBA
const row = size * channels;
const raw = Buffer.alloc(size * row);
// Background: dark green #145228
const bgR = 0x14, bgG = 0x52, bgB = 0x28;
// Heart color: red #e53935
const hR = 0xe5, hG = 0x39, hB = 0x35;
// Draw pixel by pixel: background + centered heart shape
// Heart expressed as two overlapping circles + a downward triangle
const cx = size / 2;
const cy = size / 2 + size * 0.05;
const r = size * 0.22;
// Two circle centers
const lx = cx - r * 0.6, ly = cy - r * 0.35;
const rx = cx + r * 0.6, ry = cy - r * 0.35;
// Triangle tip
const tipY = cy + r * 1.1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const px = (y * size + x) * channels;
const nx = x + 0.5, ny = y + 0.5;
// Rounded corners (radius ~20% of size)
const cr = size * 0.2;
const inCorner = (nx < cr && ny < cr && Math.hypot(nx - cr, ny - cr) > cr) ||
(nx > size - cr && ny < cr && Math.hypot(nx - (size - cr), ny - cr) > cr) ||
(nx < cr && ny > size - cr && Math.hypot(nx - cr, ny - (size - cr)) > cr) ||
(nx > size - cr && ny > size - cr && Math.hypot(nx - (size - cr), ny - (size - cr)) > cr);
if (inCorner) { raw[px+3] = 0; continue; }
// Heart: point in left circle OR right circle OR downward triangle region
const inL = Math.hypot(nx - lx, ny - ly) <= r;
const inR = Math.hypot(nx - rx, ny - ry) <= r;
// Triangle: below the circle union and above the tip
// Approximate with two lines from the outer circle edges to the tip
const inT = ny >= Math.min(ly, ry) && nx >= lx - r + (nx - (lx - r)) * 0 &&
(ny - (cy - r * 0.35)) / (tipY - (cy - r * 0.35)) <=
1 - Math.abs(nx - cx) / (r * 1.2);
const inHeart = inL || inR || inT;
if (inHeart) {
raw[px] = hR;
raw[px+1] = hG;
raw[px+2] = hB;
raw[px+3] = 255;
} else {
raw[px] = bgR;
raw[px+1] = bgG;
raw[px+2] = bgB;
raw[px+3] = 255;
}
}
}
// Encode to PNG
const chunks = [];
// PNG signature
chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
function crc32(buf) {
let c = 0xffffffff;
const table = crc32.table || (crc32.table = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let v = i;
for (let k = 0; k < 8; k++) v = v & 1 ? 0xedb88320 ^ (v >>> 1) : v >>> 1;
t[i] = v;
}
return t;
})());
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const tp = Buffer.from(type);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([tp, data])));
return Buffer.concat([len, tp, data, crc]);
}
// IHDR
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // RGBA
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
chunks.push(chunk('IHDR', ihdr));
// IDAT: add filter byte (0) before each row
const filtered = Buffer.alloc(size * (row + 1));
for (let y = 0; y < size; y++) {
filtered[y * (row + 1)] = 0; // None filter
raw.copy(filtered, y * (row + 1) + 1, y * row, (y + 1) * row);
}
const compressed = zlib.deflateSync(filtered);
chunks.push(chunk('IDAT', compressed));
// IEND
chunks.push(chunk('IEND', Buffer.alloc(0)));
fs.writeFileSync(filePath, Buffer.concat(chunks));
console.log(`${path.basename(filePath)} written (${size}×${size})`);
}
writePNG(path.join(outDir, 'icon-192.png'), 192);
writePNG(path.join(outDir, 'icon-512.png'), 512);
console.log('Icons generated.');
+15
View File
@@ -0,0 +1,15 @@
{
"name": "hearts",
"version": "1.0.0",
"description": "Hearts card game - multiplayer",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2"
}
}
+1235
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
/root/hokm/public/cards
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="22" fill="#145228"/>
<text x="50" y="68" font-size="58" text-anchor="middle" fill="#e53935"></text>
</svg>

After

Width:  |  Height:  |  Size: 212 B

+415
View File
@@ -0,0 +1,415 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Hearts</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0d2744">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Hearts">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ══════════════ LOBBY ══════════════ -->
<div id="screen-lobby" class="screen active">
<div class="lobby-box">
<h1 class="logo">♥ Hearts ♠</h1>
<p class="tagline">The classic trick-taking card game</p>
<button id="btn-install" class="btn-install hidden">📲 Add to Home Screen</button>
<div id="auth-bar" class="auth-bar">
<span id="auth-status" class="auth-status">Playing as guest</span>
<div class="auth-bar-actions">
<button id="btn-show-leaderboard" class="btn-text">🏆 Leaderboard</button>
<button id="btn-show-profile" class="btn-text hidden">👤 Profile</button>
<button id="btn-logout" class="btn-text hidden">Logout</button>
<button id="btn-show-auth" class="btn-text">Login / Register</button>
</div>
</div>
<div class="field">
<label>Your Name</label>
<input id="input-name" type="text" maxlength="16" placeholder="Enter your name…" autocomplete="off">
</div>
<div class="lobby-tabs">
<button class="tab-btn active" data-tab="create">Create Game</button>
<button class="tab-btn" data-tab="join">Join Game</button>
</div>
<div id="tab-create" class="tab-panel active">
<div class="field">
<label>Score limit <small>(game ends when a player reaches this)</small></label>
<div class="score-limit-row">
<button class="score-btn active" data-score="100">100</button>
<button class="score-btn" data-score="50">50</button>
<button class="score-btn" data-score="200">200</button>
</div>
</div>
<button id="btn-create" class="btn-primary">Create Game</button>
</div>
<div id="tab-join" class="tab-panel">
<div class="field">
<label>Room Code</label>
<input id="input-code" type="text" maxlength="8" placeholder="e.g. AB12CD" autocomplete="off" style="text-transform:uppercase">
</div>
<button id="btn-join" class="btn-primary">Join Game</button>
<hr style="border-color:rgba(255,255,255,.1);margin:4px 0">
<div class="field">
<label>Watch as spectator</label>
<input id="input-spectate-code" type="text" maxlength="8" placeholder="Room code" style="text-transform:uppercase" autocomplete="off">
</div>
<button id="btn-spectate" class="btn-secondary">👁 Watch Game</button>
</div>
<p id="lobby-error" class="error-msg"></p>
</div>
</div>
<!-- ══════════════ WAITING ROOM ══════════════ -->
<div id="screen-waiting" class="screen">
<div class="waiting-box">
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
<h2>Waiting for Players</h2>
<div class="room-code-box">
<span class="label">Room Code</span>
<span id="display-room-code" class="room-code">——</span>
<button id="btn-copy" class="btn-copy" title="Copy code"></button>
</div>
<p class="hint">Share this code — 4 players needed</p>
<div class="waiting-seats" id="waiting-seats">
<div class="seat-slot" data-seat="0"><span class="seat-num">1</span><span class="seat-name"></span></div>
<div class="seat-slot" data-seat="1"><span class="seat-num">2</span><span class="seat-name"></span></div>
<div class="seat-slot" data-seat="2"><span class="seat-num">3</span><span class="seat-name"></span></div>
<div class="seat-slot" data-seat="3"><span class="seat-num">4</span><span class="seat-name"></span></div>
</div>
<p id="waiting-status" class="waiting-status">Waiting for 3 more players…</p>
<button id="btn-fill-bots" class="btn-fill-bots hidden">🤖 Fill empty seats with bots</button>
</div>
</div>
<!-- ══════════════ GAME TABLE ══════════════ -->
<div id="screen-game" class="screen">
<!-- Info bar -->
<div id="info-bar">
<button id="btn-leave-game" class="btn-leave-screen">← Menu</button>
<!-- Individual scores -->
<div id="score-block">
<div id="score-display"></div>
<div id="hand-points-display"></div>
</div>
<!-- Hearts broken indicator -->
<div id="hearts-broken-display" class="hearts-broken hidden">♥ Broken</div>
<!-- Pass direction indicator -->
<div id="pass-dir-display" class="pass-dir-display hidden"></div>
<div class="game-menu-wrap">
<button id="btn-game-menu" class="btn-leave-screen" title="Options"></button>
<div id="game-menu-dropdown" class="game-menu-dropdown hidden">
<button id="btn-info-pos" class="game-menu-item">↕ Move score bar</button>
<button id="btn-refresh-game" class="game-menu-item">↺ Reload</button>
<button id="btn-exit-game" class="game-menu-item game-menu-exit">🚪 Exit</button>
</div>
</div>
</div>
<!-- Spectator banner -->
<div id="spectator-banner" class="spectator-banner hidden">👁 Spectating — watching only</div>
<!-- Table grid -->
<div id="table-grid">
<!-- Top player -->
<div id="area-top" class="player-area area-top">
<div class="player-label">
<span id="top-name"></span>
<span id="top-turn" class="turn-dot hidden"></span>
<span id="top-score" class="player-score-badge"></span>
</div>
<div id="top-cards" class="opp-cards"></div>
</div>
<!-- Left player -->
<div id="area-left" class="player-area area-left">
<div class="player-label vertical">
<span id="left-name"></span>
<span id="left-turn" class="turn-dot hidden"></span>
<span id="left-score" class="player-score-badge"></span>
</div>
<div id="left-cards" class="opp-cards vertical"></div>
</div>
<!-- Center / trick area -->
<div id="trick-area">
<div id="trick-top" class="trick-slot"></div>
<div id="trick-middle" class="trick-middle-row">
<div id="trick-left" class="trick-slot"></div>
<div id="trick-center" class="trick-center-info">
<div id="phase-msg" class="phase-msg"></div>
</div>
<div id="trick-right" class="trick-slot"></div>
</div>
<div id="trick-bottom" class="trick-slot"></div>
</div>
<!-- Right player -->
<div id="area-right" class="player-area area-right">
<div class="player-label vertical">
<span id="right-name"></span>
<span id="right-turn" class="turn-dot hidden"></span>
<span id="right-score" class="player-score-badge"></span>
</div>
<div id="right-cards" class="opp-cards vertical"></div>
</div>
</div><!-- /table-grid -->
<!-- Bottom: my hand -->
<div id="my-area">
<div id="my-label">
<span id="my-name">You</span>
<span id="my-turn" class="turn-dot hidden"></span>
<span id="my-score" class="player-score-badge"></span>
<span id="my-hand-pts" class="hand-pts-badge"></span>
<button id="btn-play-mode" class="btn-play-mode hidden" title="Switch play mode">👆 Tap</button>
<button id="btn-hand-mode" class="btn-hand-mode" title="Switch hand display">📜 Scroll</button>
</div>
<div id="my-hand" class="my-hand"></div>
</div>
</div><!-- /screen-game -->
<!-- ══════════════ PASS OVERLAY ══════════════ -->
<div id="overlay-pass" class="overlay hidden">
<div class="overlay-box pass-box">
<h3 id="pass-title">Pass 3 cards</h3>
<p id="pass-hint" class="pass-hint"></p>
<div id="pass-hand" class="pass-hand"></div>
<div class="pass-selected-row">
<span class="pass-selected-label">Selected:</span>
<div id="pass-selected-preview" class="pass-selected-preview"></div>
</div>
<button id="btn-confirm-pass" class="btn-primary" disabled>Confirm Pass</button>
<p id="pass-waiting" class="hint" style="min-height:18px"></p>
</div>
</div>
<!-- ══════════════ HAND RESULT OVERLAY ══════════════ -->
<div id="overlay-hand" class="overlay hidden">
<div class="overlay-box">
<div id="hand-result-icon" class="result-icon"></div>
<h3 id="hand-result-title"></h3>
<p id="hand-result-detail"></p>
<div id="hand-result-scores" class="result-scores"></div>
<p class="hint">Next hand starting…</p>
</div>
</div>
<!-- ══════════════ GAME OVER OVERLAY ══════════════ -->
<div id="overlay-gameover" class="overlay hidden">
<div class="overlay-box">
<div class="result-icon big">🏆</div>
<h2 id="gameover-title"></h2>
<div id="gameover-scores" class="gameover-scores"></div>
<button id="btn-new-game" class="btn-primary">New Game</button>
</div>
</div>
<!-- ══════════════ AUTH MODAL ══════════════ -->
<div id="overlay-auth" class="overlay hidden">
<div class="overlay-box auth-box">
<button id="btn-auth-close" class="btn-close"></button>
<div class="auth-tabs">
<button class="auth-tab active" data-auth-tab="login">Login</button>
<button class="auth-tab" data-auth-tab="register">Register</button>
</div>
<div id="auth-panel-login" class="auth-panel active">
<div class="field">
<label>Username</label>
<input id="auth-login-user" type="text" maxlength="16" placeholder="Username" autocomplete="username">
</div>
<div class="field">
<label>Password</label>
<input id="auth-login-pass" type="password" placeholder="Password" autocomplete="current-password">
</div>
<p class="hint" style="font-size:.8rem;color:rgba(255,255,255,.5)">Hokm accounts work here too</p>
<button id="btn-do-login" class="btn-primary">Login</button>
<p id="auth-login-error" class="error-msg"></p>
</div>
<div id="auth-panel-register" class="auth-panel">
<div id="reg-step-form">
<div class="field">
<label>Username <small>(216 chars)</small></label>
<input id="auth-reg-user" type="text" maxlength="16" placeholder="Choose a username" autocomplete="username">
</div>
<div class="field">
<label>Email</label>
<input id="auth-reg-email" type="email" placeholder="your@email.com" autocomplete="email">
</div>
<div class="field">
<label>Password <small>(min 4 chars)</small></label>
<input id="auth-reg-pass" type="password" placeholder="Choose a password" autocomplete="new-password">
</div>
<div id="auth-captcha-wrap" class="hidden" style="margin:6px 0"></div>
<button id="btn-do-register" class="btn-primary">Send Verification Code</button>
<p id="auth-reg-error" class="error-msg"></p>
</div>
<div id="reg-step-verify" class="hidden">
<p id="reg-verify-hint" style="margin:0 0 12px;color:var(--muted);font-size:.9em"></p>
<div class="field">
<label>Verification Code</label>
<input id="auth-reg-code" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code">
</div>
<button id="btn-do-verify" class="btn-primary">Create Account</button>
<button id="btn-reg-back" class="btn-secondary" style="margin-top:6px;width:100%">← Back</button>
<p id="auth-verify-error" class="error-msg"></p>
</div>
</div>
</div>
</div>
<!-- ══════════════ PROFILE SCREEN ══════════════ -->
<div id="screen-profile" class="screen">
<div class="profile-wrap">
<div class="profile-box">
<button id="btn-profile-back" class="btn-back">← Back</button>
<div class="profile-avatar"></div>
<h2 id="profile-username" class="profile-name"></h2>
<div class="stat-grid">
<div class="stat-card">
<span class="stat-num" id="stat-games-played"></span>
<span class="stat-label">Games Played</span>
</div>
<div class="stat-card">
<span class="stat-num" id="stat-games-won"></span>
<span class="stat-label">Games Won</span>
</div>
<div class="stat-card">
<span class="stat-num" id="stat-moon-shots"></span>
<span class="stat-label">Moon Shots 🌙</span>
</div>
<div class="stat-card">
<span class="stat-num" id="stat-total-score"></span>
<span class="stat-label">Total Score</span>
</div>
</div>
<div class="points-legend">
<h4>How to win</h4>
<ul>
<li><strong>1 pt</strong> each ♥ heart taken</li>
<li><strong>13 pts</strong> taking the ♠Q (Queen of Spades)</li>
<li><strong>Shoot the Moon</strong> take all 13 hearts + ♠Q: you score 0, everyone else scores 26</li>
<li>Game ends when any player reaches the score limit. <strong>Lowest score wins.</strong></li>
</ul>
</div>
<div class="profile-section">
<button id="btn-show-change-pass" class="btn-secondary" style="width:100%">🔑 Change Password</button>
<div id="change-pass-form" class="hidden" style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
<div class="field">
<label>Current Password</label>
<input id="change-pass-current" type="password" placeholder="Current password" autocomplete="current-password">
</div>
<div class="field">
<label>New Password <small>(min 4 chars)</small></label>
<input id="change-pass-new" type="password" placeholder="New password" autocomplete="new-password">
</div>
<button id="btn-do-change-pass" class="btn-primary">Update Password</button>
<p id="change-pass-msg" class="error-msg"></p>
</div>
</div>
<div id="admin-panel" class="profile-section admin-section hidden">
<h4 class="admin-title">⚙ Admin</h4>
<div class="admin-row">
<span>New registrations</span>
<button id="btn-toggle-signups" class="btn-secondary btn-admin-toggle"></button>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════ LEADERBOARD ══════════════ -->
<div id="screen-leaderboard" class="screen">
<div class="profile-wrap">
<div class="profile-box" style="max-width:520px">
<button id="btn-lb-back" class="btn-back">← Back</button>
<div class="profile-avatar">🏆</div>
<h2 class="profile-name">Leaderboard</h2>
<table class="lb-table">
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>Avg ♥</th>
<th>W</th>
<th>Played</th>
<th>🌙</th>
<th></th>
</tr>
</thead>
<tbody id="lb-body"></tbody>
</table>
</div>
</div>
</div>
<!-- ══════════════ PLAYER DETAILS MODAL ══════════════ -->
<div id="overlay-player-details" class="overlay hidden">
<div class="modal-box">
<button id="btn-details-close" class="btn-back" style="margin-bottom:12px">← Close</button>
<div class="profile-avatar" style="font-size:2rem"></div>
<h3 id="details-username" style="margin:8px 0 16px;color:#fff"></h3>
<table class="lb-table" style="width:100%">
<tbody id="details-body"></tbody>
</table>
</div>
</div>
<!-- ══════════════ EXIT CONFIRM ══════════════ -->
<div id="overlay-exit-confirm" class="overlay hidden">
<div class="overlay-box small" style="text-align:center">
<h3>Leave Game?</h3>
<p style="font-size:.88rem;color:rgba(255,255,255,.65);margin:8px 0 20px">Your session will be discarded.<br>You will not be able to rejoin.</p>
<div style="display:flex;gap:10px;justify-content:center">
<button id="btn-exit-confirm-yes" class="btn-primary" style="background:rgba(200,50,50,.7);border-color:rgba(200,50,50,.9)">Leave & Exit</button>
<button id="btn-exit-confirm-no" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
<!-- ── PWA Install Banner ── -->
<div id="pwa-banner" class="pwa-banner hidden">
<img src="/icons/icon-192.png" class="pwa-banner-icon" alt="">
<div class="pwa-banner-text">
<strong>Install Hearts</strong>
<span>Play offline, launch like an app</span>
</div>
<button id="pwa-banner-install" class="pwa-banner-btn">Install</button>
<button id="pwa-banner-dismiss" class="pwa-banner-close" aria-label="Dismiss"></button>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="app.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "Hearts - Card Game",
"short_name": "Hearts",
"description": "The classic trick-taking card game",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0d2744",
"theme_color": "#0d2744",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+1021
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
'use strict';
const CACHE_NAME = 'hearts-v1';
const PRECACHE = [
'/',
'/index.html',
'/style.css',
'/app.js',
'/manifest.json',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon.svg',
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE))
);
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', event => {
const url = event.request.url;
if (event.request.method !== 'GET') return;
if (url.includes('/socket.io/')) return;
if (url.includes('/cards/')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(res => {
caches.open(CACHE_NAME).then(c => c.put(event.request, res.clone()));
return res;
});
})
);
return;
}
event.respondWith(
fetch(event.request)
.then(res => {
caches.open(CACHE_NAME).then(c => c.put(event.request, res.clone()));
return res;
})
.catch(() => caches.match(event.request))
);
});
+960
View File
@@ -0,0 +1,960 @@
'use strict';
const express = require('express');
const http = require('http');
const https = require('https');
const { Server } = require('socket.io');
const path = require('path');
const fs = require('fs');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const app = express();
const httpServer = http.createServer(app);
// HTTPS with self-signed cert for local network / PWA mic access
let httpsServer = null;
const SSL_KEY = path.join(__dirname, 'ssl', 'key.pem');
const SSL_CERT = path.join(__dirname, 'ssl', 'cert.pem');
if (fs.existsSync(SSL_KEY) && fs.existsSync(SSL_CERT)) {
httpsServer = https.createServer(
{ key: fs.readFileSync(SSL_KEY), cert: fs.readFileSync(SSL_CERT) }, app
);
}
const io = new Server(httpServer);
if (httpsServer) io.attach(httpsServer);
const JWT_SECRET = process.env.JWT_SECRET || 'hearts-secret-change-me';
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || '';
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET || '';
const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '';
const RESEND_API_KEY = process.env.RESEND_API_KEY || '';
const RESEND_FROM = process.env.RESEND_FROM || 'noreply@example.com';
const HTTP_PORT = parseInt(process.env.PORT || '4000');
const HTTPS_PORT = parseInt(process.env.HTTPS_PORT || '4443');
// Path to Hokm's users.json — allows Hokm accounts to log into Hearts
const SHARED_USERS_FILE = process.env.SHARED_USERS_FILE || '';
// ─── JSON file database ────────────────────────────────────────
const DATA_DIR = path.join(__dirname, 'data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const USERS_FILE = path.join(DATA_DIR, 'users.json');
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
function readJSON(file, def) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; }
}
function writeJSON(file, data) {
const tmp = file + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
fs.renameSync(tmp, file);
}
let users = readJSON(USERS_FILE, []);
let stats = readJSON(STATS_FILE, []);
let config = readJSON(CONFIG_FILE, { signupsOpen: true });
function saveUsers() { writeJSON(USERS_FILE, users); }
function saveStats() { writeJSON(STATS_FILE, stats); }
function saveConfig() { writeJSON(CONFIG_FILE, config); }
function isAdmin(user) {
return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME;
}
// ─── Pending email verifications ──────────────────────────────
const pendingRegistrations = new Map();
const PENDING_TTL_MS = 15 * 60 * 1000;
function sendVerificationEmail(toEmail, code) {
return new Promise((resolve) => {
if (!RESEND_API_KEY) return resolve(true);
const body = JSON.stringify({
from: RESEND_FROM,
to: [toEmail],
subject: 'Your Hearts verification code',
text: `Your Hearts verification code is: ${code}\n\nThis code expires in 15 minutes.`,
});
const opts = {
hostname: 'api.resend.com',
path: '/emails',
method: 'POST',
headers: {
'Authorization': `Bearer ${RESEND_API_KEY}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => { resolve(res.statusCode >= 200 && res.statusCode < 300); });
});
req.on('error', () => resolve(false));
req.write(body);
req.end();
});
}
function verifyTurnstile(token) {
return new Promise((resolve) => {
if (!TURNSTILE_SECRET) return resolve(true);
const body = `secret=${encodeURIComponent(TURNSTILE_SECRET)}&response=${encodeURIComponent(token || '')}`;
const opts = {
hostname: 'challenges.cloudflare.com',
path: '/turnstile/v0/siteverify',
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body) },
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d).success === true); } catch { resolve(false); } });
});
req.on('error', () => resolve(false));
req.write(body);
req.end();
});
}
let _nextId = users.length > 0 ? Math.max(...users.map(u => u.id)) + 1 : 1;
function nextId() { return _nextId++; }
function findUser(username) {
const local = users.find(u => u.username.toLowerCase() === username.toLowerCase());
if (local) return local;
// Fall back to Hokm's users so shared accounts work
if (SHARED_USERS_FILE) {
try {
const shared = readJSON(SHARED_USERS_FILE, []);
const s = shared.find(u => u.username.toLowerCase() === username.toLowerCase());
if (s) return { ...s, _fromShared: true };
} catch { /* ignore */ }
}
return null;
}
function getStats(userId) {
let s = stats.find(s => s.userId === userId);
if (!s) {
s = { userId, games_played: 0, games_won: 0, moon_shots: 0, total_score: 0 };
stats.push(s);
saveStats();
}
return s;
}
function addStats(userId, delta) {
const s = getStats(userId);
s.games_played = (s.games_played || 0) + (delta.games_played || 0);
s.games_won = (s.games_won || 0) + (delta.games_won || 0);
s.moon_shots = (s.moon_shots || 0) + (delta.moon_shots || 0);
s.total_score = (s.total_score || 0) + (delta.total_score || 0);
saveStats();
}
// ─── Auth middleware ───────────────────────────────────────────
function requireAuth(req, res, next) {
const h = req.headers.authorization;
if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Not authenticated' });
try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); }
catch { res.status(401).json({ error: 'Invalid or expired token' }); }
}
// ─── Middleware ────────────────────────────────────────────────
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => {
if (/\.svg$/.test(filePath)) {
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable');
} else if (/\.(js|css|html)$/.test(filePath)) {
res.setHeader('Cache-Control', 'no-cache');
}
}
}));
// ─── Auth API ─────────────────────────────────────────────────
app.get('/api/config', (_req, res) => {
res.json({ turnstileSiteKey: TURNSTILE_SITE_KEY || null, signupsOpen: config.signupsOpen });
});
app.post('/api/register/initiate', async (req, res) => {
if (!config.signupsOpen)
return res.status(403).json({ error: 'New registrations are currently closed.' });
const { username, password, email, cfToken } = req.body || {};
if (!username || !password || !email)
return res.status(400).json({ error: 'Username, email and password are required' });
if (username.trim().length < 2 || username.trim().length > 16)
return res.status(400).json({ error: 'Username must be 216 characters' });
if (password.length < 4)
return res.status(400).json({ error: 'Password must be at least 4 characters' });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()))
return res.status(400).json({ error: 'Please enter a valid email address' });
if (findUser(username))
return res.status(409).json({ error: 'Username already taken' });
if (users.some(u => u.email && u.email.toLowerCase() === email.trim().toLowerCase()))
return res.status(409).json({ error: 'Email already registered' });
if (TURNSTILE_SECRET) {
const ok = await verifyTurnstile(cfToken);
if (!ok) return res.status(400).json({ error: 'CAPTCHA verification failed. Please try again.' });
}
const code = String(Math.floor(100000 + Math.random() * 900000));
const hashedPassword = bcrypt.hashSync(password, 10);
const emailKey = email.trim().toLowerCase();
pendingRegistrations.set(emailKey, {
username: username.trim(),
hashedPassword,
code,
expires: Date.now() + PENDING_TTL_MS,
});
if (!RESEND_API_KEY) {
const pending = pendingRegistrations.get(emailKey);
pendingRegistrations.delete(emailKey);
const id = nextId();
users.push({ id, username: pending.username, password: pending.hashedPassword, email: emailKey });
saveUsers();
getStats(id);
const token = jwt.sign({ id, username: pending.username }, JWT_SECRET, { expiresIn: '30d' });
return res.json({ done: true, token, username: pending.username });
}
const sent = await sendVerificationEmail(emailKey, code);
if (!sent) return res.status(500).json({ error: 'Failed to send verification email. Please try again.' });
res.json({ pending: true, email: emailKey });
});
app.post('/api/register/confirm', (req, res) => {
const { email, code } = req.body || {};
if (!email || !code)
return res.status(400).json({ error: 'Email and code are required' });
const emailKey = email.trim().toLowerCase();
const pending = pendingRegistrations.get(emailKey);
if (!pending)
return res.status(400).json({ error: 'No pending registration for this email. Please start over.' });
if (Date.now() > pending.expires) {
pendingRegistrations.delete(emailKey);
return res.status(400).json({ error: 'Verification code expired. Please register again.' });
}
if (pending.code !== code.trim())
return res.status(400).json({ error: 'Incorrect code. Please try again.' });
pendingRegistrations.delete(emailKey);
if (findUser(pending.username))
return res.status(409).json({ error: 'Username was just taken. Please choose another.' });
if (users.some(u => u.email && u.email.toLowerCase() === emailKey))
return res.status(409).json({ error: 'Email already registered.' });
const id = nextId();
users.push({ id, username: pending.username, password: pending.hashedPassword, email: emailKey });
saveUsers();
getStats(id);
const token = jwt.sign({ id, username: pending.username }, JWT_SECRET, { expiresIn: '30d' });
res.json({ token, username: pending.username });
});
app.post('/api/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password)
return res.status(400).json({ error: 'Username and password required' });
const user = findUser(username);
if (!user || !bcrypt.compareSync(password, user.password))
return res.status(401).json({ error: 'Invalid username or password' });
// For shared (Hokm) users, their userId may clash with local IDs;
// prefix shared IDs to avoid collisions in stats
const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id;
const effectiveUsername = user.username;
const token = jwt.sign({ id: effectiveId, username: effectiveUsername }, JWT_SECRET, { expiresIn: '30d' });
res.json({ token, username: effectiveUsername });
});
app.post('/api/change-password', requireAuth, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword)
return res.status(400).json({ error: 'Both passwords required' });
if (newPassword.length < 4)
return res.status(400).json({ error: 'New password must be at least 4 characters' });
const user = users.find(u => u.id === req.user.id);
if (!user) return res.status(404).json({ error: 'User not found (shared accounts cannot change password here)' });
if (!bcrypt.compareSync(currentPassword, user.password))
return res.status(401).json({ error: 'Current password is incorrect' });
user.password = bcrypt.hashSync(newPassword, 10);
saveUsers();
res.json({ ok: true });
});
app.get('/api/profile/:username', (req, res) => {
const user = findUser(req.params.username);
if (!user) return res.status(404).json({ error: 'User not found' });
const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id;
const s = getStats(effectiveId);
res.json({
username: user.username,
games_played: s.games_played || 0,
games_won: s.games_won || 0,
moon_shots: s.moon_shots || 0,
total_score: s.total_score || 0,
});
});
app.get('/api/leaderboard', (_req, res) => {
const allUsers = [...users];
if (SHARED_USERS_FILE) {
try {
const shared = readJSON(SHARED_USERS_FILE, []);
for (const su of shared) {
if (!allUsers.find(u => u.username.toLowerCase() === su.username.toLowerCase())) {
allUsers.push({ ...su, _fromShared: true });
}
}
} catch { /* ignore */ }
}
const rows = allUsers.map(u => {
const eid = u._fromShared ? `hokm_${u.id}` : u.id;
const s = getStats(eid);
const played = s.games_played || 0;
return {
username: u.username,
games_played: played,
games_won: s.games_won || 0,
moon_shots: s.moon_shots || 0,
total_score: s.total_score || 0,
score_per_game: played > 0 ? +(( s.total_score || 0) / played).toFixed(2) : null,
};
})
.filter(r => r.games_played > 0)
// Lower score per game = better player — sort ascending
.sort((a, b) => {
if (a.score_per_game === null) return 1;
if (b.score_per_game === null) return -1;
return a.score_per_game - b.score_per_game || b.games_played - a.games_played;
})
.slice(0, 30);
res.json(rows);
});
app.post('/api/admin/toggle-signups', requireAuth, (req, res) => {
if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' });
config.signupsOpen = !config.signupsOpen;
saveConfig();
res.json({ signupsOpen: config.signupsOpen });
});
// ═══════════════════════════════════════════════════════════════
// HEARTS GAME ENGINE
// ═══════════════════════════════════════════════════════════════
const SUITS = ['C', 'D', 'H', 'S'];
const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
const RANK_VAL = Object.fromEntries(RANKS.map((r, i) => [r, i + 2])); // 2→2 … A→14
function makeDeck() {
const d = [];
for (const s of SUITS) for (const r of RANKS) d.push(`${s}-${r}`);
return d;
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function suit(c) { return c.split('-')[0]; }
function rank(c) { return c.split('-')[1]; }
function rankVal(c) { return RANK_VAL[rank(c)]; }
function points(c) { return suit(c) === 'H' ? 1 : c === 'S-Q' ? 13 : 0; }
function isHeart(c) { return suit(c) === 'H'; }
// Sort: Clubs, Diamonds, Hearts, Spades; within suit low→high
const SUIT_ORDER = { C: 0, D: 1, S: 2, H: 3 }; // C, D, S, H
function sortCards(a, b) {
const sd = SUIT_ORDER[suit(a)] - SUIT_ORDER[suit(b)];
return sd !== 0 ? sd : rankVal(a) - rankVal(b);
}
const PASS_DIRS = ['left', 'right', 'across', 'hold'];
function newRoom(id) {
return {
id,
state: 'WAITING',
names: ['', '', '', ''],
seats: [null, null, null, null],
tokens: [null, null, null, null],
userIds: [null, null, null, null],
bots: [false, false, false, false],
hands: [[], [], [], []],
passCards: [null, null, null, null],
passDirection: 'left',
handNumber: 0,
trick: [],
trickLead: 0,
currentTurn: 0,
tricksPlayed: 0,
heartsBroken: false,
scores: [0, 0, 0, 0],
handPoints: [0, 0, 0, 0],
lastTrick: null,
lastTrickWinner: -1,
moonShooter: -1,
handDeltas: null,
winScore: 100,
spectators: new Set(),
trickTimer: null,
};
}
const rooms = new Map(); // roomId → room
const userSockets = new Map(); // userId → socket
function makeToken() {
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
}
function publicInfo(room, seat = -1) {
return {
id: room.id,
state: room.state,
names: room.names,
bots: room.bots,
hands: room.hands.map((h, i) => (i === seat ? h : h.length)),
passDirection: room.passDirection,
passReady: room.passCards.map(p => p !== null),
passSelected: seat >= 0 ? (room.passCards[seat] || []) : [],
trick: room.trick,
trickLead: room.trickLead,
currentTurn: room.currentTurn,
tricksPlayed: room.tricksPlayed,
heartsBroken: room.heartsBroken,
scores: room.scores,
// Each player only sees their own in-hand points (secret during play)
handPoints: room.handPoints.map((hp, i) => (seat < 0 || i === seat) ? hp : null),
handNumber: room.handNumber,
winScore: room.winScore,
lastTrick: room.lastTrick,
lastTrickWinner: room.lastTrickWinner,
moonShooter: room.moonShooter,
handDeltas: room.handDeltas,
gameWinner: room.gameWinner,
spectatorCount: room.spectators.size,
};
}
// Emit full state to each seated player (showing their own hand)
function broadcastState(room, event = 'roomInfo') {
for (let i = 0; i < 4; i++) {
const sid = room.seats[i];
if (sid) io.to(sid).emit(event, publicInfo(room, i));
}
// Spectators see no hand
for (const sid of room.spectators) {
io.to(sid).emit(event, publicInfo(room, -1));
}
}
// ─── Dealing ──────────────────────────────────────────────────
function dealHand(room) {
const deck = shuffle(makeDeck());
for (let i = 0; i < 4; i++) {
room.hands[i] = deck.slice(i * 13, (i + 1) * 13).sort(sortCards);
}
room.trick = [];
room.lastTrick = null;
room.lastTrickWinner = -1;
room.passCards = [null, null, null, null];
room.heartsBroken = false;
room.handPoints = [0, 0, 0, 0];
room.tricksPlayed = 0;
room.moonShooter = -1;
room.handDeltas = null;
room.passDirection = PASS_DIRS[room.handNumber % 4];
}
function find2Clubs(room) {
for (let i = 0; i < 4; i++) {
if (room.hands[i].includes('C-2')) return i;
}
return 0;
}
// ─── Card validation ──────────────────────────────────────────
function legalCards(room, player) {
const hand = room.hands[player];
const trick = room.trick;
// First card of the very first trick must be 2♣
if (trick.length === 0 && room.tricksPlayed === 0) {
return hand.includes('C-2') ? ['C-2'] : hand;
}
// Leading a trick
if (trick.length === 0) {
const nonHearts = hand.filter(c => !isHeart(c));
if (!room.heartsBroken && nonHearts.length > 0) return nonHearts;
return hand;
}
// Following: must follow suit if possible
const leadSuit = suit(trick[0].card);
const suitCards = hand.filter(c => suit(c) === leadSuit);
if (suitCards.length > 0) return suitCards;
// Can't follow suit — on trick 0 avoid point cards if possible
if (room.tricksPlayed === 0) {
const safe = hand.filter(c => points(c) === 0);
if (safe.length > 0) return safe;
}
return hand;
}
function isLegal(room, player, card) {
return legalCards(room, player).includes(card);
}
function trickWinner(trick) {
const ls = suit(trick[0].card);
let best = trick[0];
for (const t of trick.slice(1)) {
if (suit(t.card) === ls && rankVal(t.card) > rankVal(best.card)) best = t;
}
return best.player;
}
// ─── Pass exchange ────────────────────────────────────────────
function exchangePassCards(room) {
const dir = room.passDirection;
if (dir === 'hold') return;
const sending = room.passCards.map(p => [...p]);
// Remove from senders
for (let p = 0; p < 4; p++) {
for (const c of sending[p]) {
const idx = room.hands[p].indexOf(c);
if (idx !== -1) room.hands[p].splice(idx, 1);
}
}
// Add to receivers
for (let from = 0; from < 4; from++) {
// "left" = visual left (area-left = seat+3); "right" = visual right (area-right = seat+1)
const to = dir === 'left' ? (from + 3) % 4
: dir === 'across' ? (from + 2) % 4
: (from + 1) % 4; // right
for (const c of sending[from]) room.hands[to].push(c);
room.hands[to].sort(sortCards);
}
room.passCards = [null, null, null, null];
}
// ─── Game flow ────────────────────────────────────────────────
function startPassing(room) {
room.state = 'PASSING';
broadcastState(room, 'roomInfo');
// Schedule bots to pass
for (let i = 0; i < 4; i++) {
if (room.bots[i]) setTimeout(() => botPass(room, i), 600 + Math.random() * 400);
}
}
function startPlaying(room) {
room.state = 'PLAYING';
room.trickLead = find2Clubs(room);
room.currentTurn = room.trickLead;
room.trick = [];
broadcastState(room, 'roomInfo');
scheduleBotPlay(room);
}
function onCardPlayed(room, player, card) {
// Remove from hand
room.hands[player] = room.hands[player].filter(c => c !== card);
// Add to trick
room.trick.push({ card, player });
// Break hearts
if (isHeart(card) || card === 'S-Q') room.heartsBroken = true;
if (room.trick.length < 4) {
room.currentTurn = (player + 3) % 4; // anti-clockwise
broadcastState(room, 'cardPlayed');
scheduleBotPlay(room);
return;
}
// Trick complete
const winner = trickWinner(room.trick);
const trickPts = room.trick.reduce((s, t) => s + points(t.card), 0);
room.handPoints[winner] += trickPts;
room.tricksPlayed++;
room.lastTrick = room.trick.slice();
room.lastTrickWinner = winner;
if (room.tricksPlayed === 13) {
broadcastState(room, 'trickWon');
if (room.trickTimer) clearTimeout(room.trickTimer);
room.trickTimer = setTimeout(() => finishHand(room), 1400);
} else {
broadcastState(room, 'trickWon');
if (room.trickTimer) clearTimeout(room.trickTimer);
room.trickTimer = setTimeout(() => {
room.trickLead = winner;
room.currentTurn = winner;
room.trick = [];
broadcastState(room, 'roomInfo');
scheduleBotPlay(room);
}, 1400);
}
}
function finishHand(room) {
// Check shoot the moon (one player has all 26 pts)
const shooter = room.handPoints.findIndex(p => p === 26);
let deltas;
if (shooter !== -1) {
deltas = [26, 26, 26, 26];
deltas[shooter] = 0;
room.moonShooter = shooter;
} else {
deltas = room.handPoints.slice();
room.moonShooter = -1;
}
room.handDeltas = deltas;
for (let i = 0; i < 4; i++) room.scores[i] += deltas[i];
// Check game over: someone hit winScore
if (room.scores.some(s => s >= room.winScore)) {
finishGame(room);
return;
}
room.state = 'HAND_OVER';
broadcastState(room, 'handOver');
// Auto-start next hand
if (room.trickTimer) clearTimeout(room.trickTimer);
room.trickTimer = setTimeout(() => {
room.handNumber++;
dealHand(room);
if (room.passDirection === 'hold') {
startPlaying(room);
} else {
startPassing(room);
}
}, 4000);
}
function finishGame(room) {
const minScore = Math.min(...room.scores);
// Multiple players can tie for lowest
room.gameWinner = room.scores
.map((s, i) => ({ s, i }))
.filter(x => x.s === minScore)
.map(x => x.i);
room.state = 'GAME_OVER';
broadcastState(room, 'gameOver');
// Skip stats entirely if any bot participated — games vs bots don't count
if (room.bots.some(Boolean)) return;
const winners = new Set(room.gameWinner);
for (let i = 0; i < 4; i++) {
const uid = room.userIds[i];
if (!uid) continue;
addStats(uid, {
games_played: 1,
games_won: winners.has(i) ? 1 : 0,
moon_shots: 0,
total_score: room.scores[i],
});
}
}
// ─── Start the game ───────────────────────────────────────────
function tryStartGame(room) {
if (room.seats.filter(Boolean).length + room.bots.filter(Boolean).length < 4) return;
if (room.seats.some((s, i) => !s && !room.bots[i])) return;
dealHand(room); // handNumber is 0 from newRoom; passDirection set inside
if (room.passDirection === 'hold') {
startPlaying(room);
} else {
startPassing(room);
}
}
// ─── Bot logic ────────────────────────────────────────────────
function botPass(room, bot) {
if (room.state !== 'PASSING') return;
if (room.passCards[bot] !== null) return;
const hand = [...room.hands[bot]];
const selected = [];
// Priority: S-Q, high hearts, high of short suits
const danger = (c) => {
if (c === 'S-Q') return 100;
if (c === 'S-K') return 60;
if (c === 'S-A') return 55;
if (suit(c) === 'H') return 40 + rankVal(c);
return rankVal(c);
};
hand.sort((a, b) => danger(b) - danger(a));
// Don't pass S-Q if we have plenty of spades protection (5+ spades)
const spades = hand.filter(c => suit(c) === 'S');
const filtered = (spades.length >= 5 && hand[0] === 'S-Q')
? hand.slice(1)
: hand;
for (let i = 0; i < 3 && i < filtered.length; i++) {
selected.push(filtered[i]);
}
// Fill up to 3 if we skipped S-Q
while (selected.length < 3) {
const c = hand.find(c => !selected.includes(c));
if (c) selected.push(c);
else break;
}
room.passCards[bot] = selected;
checkAllPassed(room);
}
function checkAllPassed(room) {
if (room.passDirection === 'hold') {
startPlaying(room);
return;
}
if (room.passCards.every(p => p !== null)) {
exchangePassCards(room);
startPlaying(room);
} else {
broadcastState(room, 'roomInfo');
}
}
function scheduleBotPlay(room) {
if (room.state !== 'PLAYING') return;
const bot = room.currentTurn;
if (!room.bots[bot]) return;
const delay = 500 + Math.random() * 600;
setTimeout(() => {
if (room.state !== 'PLAYING' || room.currentTurn !== bot) return;
const card = botChooseCard(room, bot);
if (card) onCardPlayed(room, bot, card);
}, delay);
}
function botChooseCard(room, bot) {
const legal = legalCards(room, bot);
if (legal.length === 1) return legal[0];
const trick = room.trick;
// Leading a trick
if (trick.length === 0) {
// Prefer to lead low clubs, then diamonds, avoid hearts/spades unless forced
const nonPoint = legal.filter(c => points(c) === 0 && suit(c) !== 'S');
if (nonPoint.length > 0) return nonPoint.sort(sortCards)[0]; // lowest safe
const nonHeart = legal.filter(c => !isHeart(c));
if (nonHeart.length > 0) return nonHeart.sort(sortCards)[0];
return legal.sort(sortCards)[0]; // lowest heart
}
// Following suit
const leadSuit = suit(trick[0].card);
const followingCards = legal.filter(c => suit(c) === leadSuit);
if (followingCards.length > 0) {
// Find current winning card
const curWinner = trickWinner(trick);
const winCard = trick.find(t => t.player === curWinner).card;
// Try to duck (play below winner)
const duck = followingCards.filter(c => rankVal(c) < rankVal(winCard));
if (duck.length > 0) {
// Play highest duck to preserve low cards
return duck.sort(sortCards)[duck.length - 1];
}
// Must win — play lowest winning card
return followingCards.sort(sortCards)[0];
}
// Discarding (can't follow suit) — dump high-danger cards
// S-Q first
if (legal.includes('S-Q')) return 'S-Q';
// High hearts
const hearts = legal.filter(c => isHeart(c)).sort(sortCards);
if (hearts.length > 0) return hearts[hearts.length - 1]; // highest heart
// Highest remaining card
return legal.sort(sortCards)[legal.length - 1];
}
// ─── Socket.IO ────────────────────────────────────────────────
io.use((socket, next) => {
const token = socket.handshake.auth?.token;
if (token) {
try { socket.data.user = jwt.verify(token, JWT_SECRET); }
catch { /* guest */ }
}
next();
});
io.on('connection', (socket) => {
const user = socket.data.user;
if (user) userSockets.set(user.id, socket);
// ── Create room ────────────────────────────────────────────
socket.on('create', ({ name, winScore } = {}) => {
if (!name?.trim()) return socket.emit('error', 'Name is required');
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
const room = newRoom(id);
room.winScore = Number.isFinite(+winScore) && winScore >= 50 ? Math.min(+winScore, 500) : 100;
room.names[0] = name.trim().slice(0, 16);
room.userIds[0] = user?.id || null;
room.seats[0] = socket.id;
room.tokens[0] = makeToken();
rooms.set(id, room);
socket.join(id);
socket.emit('created', { roomId: id, seat: 0, token: room.tokens[0] });
socket.emit('roomInfo', publicInfo(room, 0));
});
// ── Join room ──────────────────────────────────────────────
socket.on('join', ({ name, roomId } = {}) => {
if (!name?.trim()) return socket.emit('joinError', 'Name is required');
const room = rooms.get((roomId || '').toUpperCase());
if (!room) return socket.emit('joinError', 'Room not found');
if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress');
// Find first open seat (not filled by a human or bot)
let openSeat = -1;
for (let i = 0; i < 4; i++) {
if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; }
}
if (openSeat === -1) return socket.emit('joinError', 'Room is full');
room.names[openSeat] = name.trim().slice(0, 16);
room.userIds[openSeat] = user?.id || null;
room.seats[openSeat] = socket.id;
room.tokens[openSeat] = makeToken();
socket.join(room.id);
socket.emit('joined', { roomId: room.id, seat: openSeat, token: room.tokens[openSeat] });
broadcastState(room, 'roomInfo');
const humanCount = room.seats.filter(Boolean).length;
const botCount = room.bots.filter(Boolean).length;
if (humanCount + botCount === 4) tryStartGame(room);
});
// ── Spectate ───────────────────────────────────────────────
socket.on('spectate', ({ roomId } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room) return socket.emit('spectateError', 'Room not found');
room.spectators.add(socket.id);
socket.join(room.id);
socket.emit('spectating', { roomId: room.id });
socket.emit('roomInfo', publicInfo(room, -1));
});
// ── Rejoin ─────────────────────────────────────────────────
socket.on('rejoin', ({ roomId, seat, token } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room) return socket.emit('rejoinError', 'Room no longer exists');
if (room.tokens[seat] !== token) return socket.emit('rejoinError', 'Invalid session token');
room.seats[seat] = socket.id;
socket.join(room.id);
socket.emit('rejoined', { roomId: room.id, seat, token });
socket.emit('roomInfo', publicInfo(room, seat));
broadcastState(room, 'roomInfo');
});
// ── Fill with bots ─────────────────────────────────────────
socket.on('fillBots', ({ roomId } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room || room.state !== 'WAITING') return;
// Only the first seated player can fill bots
if (room.seats[0] !== socket.id && !room.seats.includes(socket.id)) return;
const botNames = ['Alice', 'Bob', 'Charlie', 'Diana'];
for (let i = 0; i < 4; i++) {
if (!room.seats[i] && !room.bots[i]) {
room.bots[i] = true;
room.names[i] = botNames[i];
}
}
broadcastState(room, 'roomInfo');
tryStartGame(room);
});
// ── Pass cards ─────────────────────────────────────────────
socket.on('passCards', ({ roomId, seat, token, cards } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room) return;
if (room.state !== 'PASSING') return;
if (room.tokens[seat] !== token) return;
if (!Array.isArray(cards) || cards.length !== 3) return socket.emit('passError', 'Must pass exactly 3 cards');
if (room.passCards[seat] !== null) return; // already passed
// Validate cards are in hand and distinct
const hand = room.hands[seat];
const unique = [...new Set(cards)];
if (unique.length !== 3) return socket.emit('passError', 'Cards must be distinct');
if (!unique.every(c => hand.includes(c))) return socket.emit('passError', 'Invalid card');
room.passCards[seat] = unique;
broadcastState(room, 'roomInfo');
checkAllPassed(room);
});
// ── Play card ──────────────────────────────────────────────
socket.on('play', ({ roomId, seat, token, card } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room) return;
if (room.state !== 'PLAYING') return;
if (room.tokens[seat] !== token) return;
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
if (!isLegal(room, seat, card)) return socket.emit('playError', 'Illegal card');
onCardPlayed(room, seat, card);
});
// ── Leave ──────────────────────────────────────────────────
socket.on('leave', ({ roomId, seat, token } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room) return;
if (room.tokens[seat] === token) {
room.seats[seat] = null;
room.tokens[seat] = null;
}
socket.leave(room.id);
});
socket.on('disconnect', () => {
if (user) userSockets.delete(user.id);
// Don't remove from room — allow rejoin
});
});
// ─── Server startup ────────────────────────────────────────────
httpServer.listen(HTTP_PORT, () =>
console.log(`Hearts HTTP → http://localhost:${HTTP_PORT}`)
);
if (httpsServer) {
httpsServer.listen(HTTPS_PORT, () =>
console.log(`Hearts HTTPS → https://localhost:${HTTPS_PORT}`)
);
}