Initial commit
This commit is contained in:
@@ -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=
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
rerun.sh
|
||||
data/
|
||||
node_modules/
|
||||
+26
@@ -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"]
|
||||
@@ -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
@@ -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.');
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Symlink
+1
@@ -0,0 +1 @@
|
||||
/root/hokm/public/cards
|
||||
@@ -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 |
@@ -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>(2–16 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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
);
|
||||
});
|
||||
@@ -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 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.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}`)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user