Port Shelem layout/engine, add history, fix card flashing and count desync
Layout & engine borrowed from Shelem: waiting-room menu with seat swap and force-start, game menu with bar-to-bottom, escalating turn reminder, three hand modes defaulting to fan on touch, card-count badges instead of card backs, leave buttons inside phase overlays, version footer with tap-to-update. Seats now run clockwise from the viewer (left = +1, across = +2, right = +3) so play flows anti-clockwise on screen as the server advances it; the pass exchange was flipped to match, otherwise "pass left" delivered to the right. Game history: finished games are saved with a per-hand breakdown, readable from the profile and shown on the end-of-hand and end-of-game screens. Card flashing: the service worker precaches all 52 faces, each face is decoded once into a template image, and the renderers reconcile against existing DOM instead of rebuilding it, so card nodes and their images survive a re-render. Card-count integrity: a seat can no longer put two cards into one trick, and the server snapshots each trick start and rewinds to the last sound state if the four hands ever drift apart. Leaderboard: ranked by win rate with a 5-game minimum; below-threshold players are listed unranked. Moon shots are now recorded instead of always zero, and stat reads no longer create and persist a row per user on every request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
.env
|
||||
data/
|
||||
data-dev/
|
||||
node_modules/
|
||||
push.sh
|
||||
rerun.sh
|
||||
compose.dev.yml
|
||||
|
||||
@@ -82,4 +82,53 @@ git pull
|
||||
|
||||
## Data
|
||||
|
||||
Game data (users, stats, config) is stored in `./data/` which is bind-mounted into the container. It persists across restarts and rebuilds.
|
||||
Game data is stored in `./data/`, bind-mounted into the container, and persists
|
||||
across restarts and rebuilds:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `users.json` | Local accounts (Hokm/Shelem accounts come from `SHARED_USERS_FILE`) |
|
||||
| `stats.json` | Per-player totals behind the profile and leaderboard |
|
||||
| `config.json` | Runtime config (e.g. whether signups are open) |
|
||||
| `history.json` | Finished games with their round-by-round breakdown |
|
||||
|
||||
`history.json` keeps the most recent 2000 games. Games with a bot at the table
|
||||
are not recorded, so neither history nor the leaderboard counts them.
|
||||
|
||||
## Game history
|
||||
|
||||
Every finished all-human game is saved with the score each player picked up in
|
||||
each hand. Players reach it from **Profile → 📜 Game History**; tapping a game
|
||||
shows the final standings and a hand-by-hand table with a ruled-off totals row.
|
||||
The same breakdown appears on the end-of-hand and end-of-game screens, so the
|
||||
running scores are visible from the first hand onwards rather than only at the
|
||||
end.
|
||||
|
||||
## Leaderboard
|
||||
|
||||
Ranked by **win rate** — the percentage of finished games in which the player
|
||||
had the lowest score. A game that ends in a tie for lowest counts as a win for
|
||||
each tied player. Average score per game breaks ties in the ranking, and there
|
||||
*lower* is better, since Hearts scores are penalty points.
|
||||
|
||||
A player needs **5 finished games** before they take a rank, so one lucky game
|
||||
can't top the board. Players below that are still listed underneath the ranking,
|
||||
unranked and dimmed, showing how many more games they need. Change
|
||||
`MIN_RANKED_GAMES` in `server.js` to adjust; the client reads the value back from
|
||||
`/api/config`, so the label follows automatically.
|
||||
|
||||
Games with a bot at the table are not counted at all.
|
||||
|
||||
## Card-count integrity
|
||||
|
||||
Hearts games could previously end early if one seat somehow got two cards into a
|
||||
single trick: that player ran out while everyone else still held cards. The
|
||||
server now rejects a second card from a seat that has already played in the
|
||||
current trick, refuses cards that aren't in the player's hand, and puts nobody
|
||||
on turn while a trick is resolving.
|
||||
|
||||
As a backstop, it snapshots the table at the start of every trick (only when all
|
||||
four hands are level) and re-checks the counts after each trick and at the end of
|
||||
each hand. If they have drifted apart it rewinds to that last sound snapshot,
|
||||
tells every client (they show a brief notice), and play resumes from the start of
|
||||
that trick.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hearts",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "Hearts card game - multiplayer",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
+1399
-1212
File diff suppressed because it is too large
Load Diff
+105
-61
@@ -5,7 +5,7 @@
|
||||
<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="theme-color" content="#133025">
|
||||
<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">
|
||||
@@ -44,16 +44,16 @@
|
||||
|
||||
<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>
|
||||
<label>Score Limit <small>(game ends when a player reaches this — lowest wins)</small></label>
|
||||
<div class="score-row">
|
||||
<button class="score-btn" data-score="50">50</button>
|
||||
<button class="score-btn active" data-score="100">100</button>
|
||||
<button class="score-btn" data-score="200">200</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<div class="visibility-toggle">
|
||||
<div class="visibility-row">
|
||||
<button class="visibility-btn active" data-public="false">🔒 Private</button>
|
||||
<button class="visibility-btn" data-public="true">🌐 Public</button>
|
||||
</div>
|
||||
@@ -62,19 +62,20 @@
|
||||
</div>
|
||||
|
||||
<div id="tab-join" class="tab-panel">
|
||||
<div class="public-rooms-section">
|
||||
<div class="rooms-header">
|
||||
<span>Public Rooms</span>
|
||||
<button id="btn-refresh-rooms" class="btn-refresh">↺ Refresh</button>
|
||||
</div>
|
||||
<div id="public-rooms-list"><p class="hint">Switch to this tab to load rooms.</p></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Or enter a Room Code</label>
|
||||
<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 by Code</button>
|
||||
<hr style="border-color:rgba(255,255,255,.1);margin:4px 0">
|
||||
<button id="btn-join" class="btn-primary">Join Game</button>
|
||||
<hr class="divider">
|
||||
<div class="public-rooms-section">
|
||||
<div class="rooms-header">
|
||||
<span class="rooms-label">Public Rooms</span>
|
||||
<button id="btn-refresh-rooms" class="btn-text">↺</button>
|
||||
</div>
|
||||
<div id="public-rooms-list"><p class="hint">Loading…</p></div>
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<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">
|
||||
@@ -84,28 +85,46 @@
|
||||
|
||||
<p id="lobby-error" class="error-msg"></p>
|
||||
</div>
|
||||
<div id="app-version" class="app-version"></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>
|
||||
<div class="waiting-top-row">
|
||||
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
|
||||
<div class="game-menu-wrap" style="margin-left:auto">
|
||||
<button id="btn-waiting-menu" class="btn-leave-screen" title="Options">☰</button>
|
||||
<div id="waiting-menu-dropdown" class="game-menu-dropdown hidden">
|
||||
<button id="btn-waiting-reload" class="game-menu-item">↺ Reload</button>
|
||||
<button id="btn-waiting-exit" class="game-menu-item game-menu-exit">🚪 Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Waiting for Players</h2>
|
||||
<div id="waiting-public-badge" class="waiting-public-badge">🔒 Private</div>
|
||||
<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 class="hint" style="font-size:.78rem;color:rgba(255,255,255,.4)">Every player for themselves — lowest score wins</p>
|
||||
<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 id="waiting-options" class="waiting-options hidden">
|
||||
<span class="waiting-opt-label" id="waiting-score-label"></span>
|
||||
<span class="waiting-opt-label" id="waiting-public-badge"></span>
|
||||
</div>
|
||||
<div id="waiting-creator-controls" class="waiting-creator-controls hidden">
|
||||
<button id="btn-fill-bots" class="btn-fill-bots">🤖 Fill with bots</button>
|
||||
<button id="btn-start-game" class="btn-start-game">▶ Start Game</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,23 +135,22 @@
|
||||
<div id="info-bar">
|
||||
<button id="btn-leave-game" class="btn-leave-screen">← Menu</button>
|
||||
|
||||
<!-- Individual scores -->
|
||||
<!-- Per-player 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>
|
||||
<!-- Hearts broken + pass direction -->
|
||||
<div id="game-meta">
|
||||
<span id="hearts-broken-display" class="hearts-broken hidden">♥ Broken</span>
|
||||
<span id="pass-dir-display" class="pass-dir-display hidden"></span>
|
||||
</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-toggle-bar" class="game-menu-item">⬇ Bar to bottom</button>
|
||||
<button id="btn-exit-game" class="game-menu-item game-menu-exit">🚪 Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,11 +159,11 @@
|
||||
<!-- Spectator banner -->
|
||||
<div id="spectator-banner" class="spectator-banner hidden">👁 Spectating — watching only</div>
|
||||
|
||||
<!-- AFK warning banner -->
|
||||
<!-- AFK banner -->
|
||||
<div id="afk-banner" class="afk-banner hidden">
|
||||
<span id="afk-banner-msg"></span>
|
||||
<button id="afk-vote-btn">Let AI Play</button>
|
||||
<button id="afk-dismiss-btn">Dismiss</button>
|
||||
<button id="afk-vote-btn" class="btn-text" style="color:var(--gold);font-weight:700">Let AI Play</button>
|
||||
<button id="afk-dismiss-btn" class="btn-text">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<!-- AI control banner -->
|
||||
@@ -153,30 +171,33 @@
|
||||
<span id="ai-control-msg"></span>
|
||||
</div>
|
||||
|
||||
<!-- Revert notice (card-count desync recovery) -->
|
||||
<div id="revert-banner" class="revert-banner hidden">
|
||||
<span id="revert-banner-msg"></span>
|
||||
</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>
|
||||
<span id="top-count" class="card-count-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>
|
||||
<span id="left-count" class="card-count-badge"></span>
|
||||
</div>
|
||||
<div id="left-cards" class="opp-cards vertical"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center / trick area -->
|
||||
<!-- Center trick area -->
|
||||
<div id="trick-area">
|
||||
<div id="trick-top" class="trick-slot"></div>
|
||||
<div id="trick-middle" class="trick-middle-row">
|
||||
@@ -189,14 +210,13 @@
|
||||
<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>
|
||||
<span id="right-count" class="card-count-badge"></span>
|
||||
</div>
|
||||
<div id="right-cards" class="opp-cards vertical"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- /table-grid -->
|
||||
@@ -207,7 +227,7 @@
|
||||
<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>
|
||||
<span id="my-hand-pts" class="tricks-badge hidden"></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>
|
||||
@@ -223,11 +243,12 @@
|
||||
<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>
|
||||
<span class="pass-selected-label">Passing:</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>
|
||||
<button class="overlay-leave-btn" data-overlay-leave>🚪 Leave game</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -236,8 +257,9 @@
|
||||
<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>
|
||||
<p id="hand-result-detail"></p>
|
||||
<div id="hand-result-scores" class="result-scores"></div>
|
||||
<div id="hand-round-breakdown" class="round-breakdown-wrap"></div>
|
||||
<p class="hint">Next hand starting…</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,6 +270,7 @@
|
||||
<div class="result-icon big">🏆</div>
|
||||
<h2 id="gameover-title"></h2>
|
||||
<div id="gameover-scores" class="gameover-scores"></div>
|
||||
<div id="gameover-breakdown" class="round-breakdown-wrap"></div>
|
||||
<button id="btn-new-game" class="btn-primary">New Game</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,7 +293,7 @@
|
||||
<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>
|
||||
<p class="hint" style="font-size:.8rem;color:rgba(255,255,255,.45)">Hokm and Shelem 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>
|
||||
@@ -307,39 +330,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════ PROFILE SCREEN ══════════════ -->
|
||||
<!-- ══════════════ PROFILE ══════════════ -->
|
||||
<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>
|
||||
<span class="stat-num" id="stat-win-rate">—</span>
|
||||
<span class="stat-label">Win Rate</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-games-played">—</span>
|
||||
<span class="stat-label">Games Played</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-avg-score">—</span>
|
||||
<span class="stat-label">Avg Score</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="profile-section">
|
||||
<button id="btn-show-history" class="btn-secondary" style="width:100%">📜 Game History</button>
|
||||
</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><strong>1 pt</strong> — each ♥ heart taken</li>
|
||||
<li><strong>13 pts</strong> — taking the ♠Q</li>
|
||||
<li><strong>Shoot the Moon</strong> — take all 13 hearts + ♠Q: you score 0, everyone else 26</li>
|
||||
<li>Game ends when any player reaches the score limit. <strong>Lowest score wins.</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -374,20 +408,14 @@
|
||||
<!-- ══════════════ LEADERBOARD ══════════════ -->
|
||||
<div id="screen-leaderboard" class="screen">
|
||||
<div class="profile-wrap">
|
||||
<div class="profile-box" style="max-width:520px">
|
||||
<div class="profile-box" style="max-width:540px">
|
||||
<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>
|
||||
<th>#</th><th>Player</th><th>Win %</th><th>W</th><th>P</th><th title="Average score per game — lower is better">Avg</th><th title="Moon shots">🌙</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lb-body"></tbody>
|
||||
@@ -396,10 +424,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════ GAME HISTORY ══════════════ -->
|
||||
<div id="screen-history" class="screen">
|
||||
<div class="profile-wrap">
|
||||
<div class="profile-box" style="max-width:560px">
|
||||
<button id="btn-history-back" class="btn-back">← Back</button>
|
||||
<div class="profile-avatar">📜</div>
|
||||
<h2 id="history-title" class="profile-name">Game History</h2>
|
||||
<div id="history-list" class="history-list"><p class="hint">Loading…</p></div>
|
||||
<div id="history-detail" class="hidden">
|
||||
<button id="btn-history-detail-back" class="btn-back" style="position:static;margin-bottom:8px">← All games</button>
|
||||
<h3 id="history-detail-title"></h3>
|
||||
<div id="history-detail-scores" class="gameover-scores"></div>
|
||||
<div id="history-detail-breakdown" class="round-breakdown-wrap"></div>
|
||||
</div>
|
||||
</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="overlay-box">
|
||||
<button id="btn-details-close" class="btn-back" style="position:static;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%">
|
||||
@@ -414,7 +460,7 @@
|
||||
<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-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>
|
||||
@@ -434,9 +480,7 @@
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+969
-861
File diff suppressed because it is too large
Load Diff
+37
-13
@@ -1,7 +1,26 @@
|
||||
'use strict';
|
||||
const CACHE_NAME = 'hearts-v1';
|
||||
// v2: card faces are now precached so they never flash or refetch mid-game
|
||||
const CACHE_NAME = 'hearts-v2';
|
||||
|
||||
const PRECACHE = [
|
||||
// ── All 52 card faces ───────────────────────────────────────────
|
||||
const SUITS = ['CLUB', 'DIAMOND', 'HEART', 'SPADE'];
|
||||
const CARD_PATHS = SUITS.flatMap(s => [
|
||||
`/cards/${s}-1.svg`,
|
||||
`/cards/${s}-2.svg`,
|
||||
`/cards/${s}-3.svg`,
|
||||
`/cards/${s}-4.svg`,
|
||||
`/cards/${s}-5.svg`,
|
||||
`/cards/${s}-6.svg`,
|
||||
`/cards/${s}-7.svg`,
|
||||
`/cards/${s}-8.svg`,
|
||||
`/cards/${s}-9.svg`,
|
||||
`/cards/${s}-10.svg`,
|
||||
`/cards/${s}-11-JACK.svg`,
|
||||
`/cards/${s}-12-QUEEN.svg`,
|
||||
`/cards/${s}-13-KING.svg`,
|
||||
]);
|
||||
|
||||
const SHELL = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/style.css',
|
||||
@@ -13,9 +32,12 @@ const PRECACHE = [
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE))
|
||||
);
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
// The shell must land; a missing card shouldn't abort the whole install
|
||||
await cache.addAll(SHELL);
|
||||
await Promise.all(CARD_PATHS.map(p => cache.add(p).catch(() => {})));
|
||||
})());
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
@@ -33,23 +55,25 @@ self.addEventListener('fetch', event => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
if (url.includes('/socket.io/')) return;
|
||||
|
||||
// Cards never change — always cache-first, and never go to the network again
|
||||
// once we hold a copy.
|
||||
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;
|
||||
});
|
||||
})
|
||||
caches.match(event.request).then(cached => cached || fetch(event.request).then(res => {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE_NAME).then(c => c.put(event.request, copy));
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else: network-first, cache as the offline fallback
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(res => {
|
||||
caches.open(CACHE_NAME).then(c => c.put(event.request, res.clone()));
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE_NAME).then(c => c.put(event.request, copy));
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
|
||||
@@ -3,10 +3,10 @@ set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "==> Stopping..."
|
||||
docker compose down
|
||||
docker compose -f compose.build.yml down
|
||||
|
||||
echo "==> Building and starting..."
|
||||
docker compose up -d --build
|
||||
docker compose -f compose.build.yml up -d --build
|
||||
|
||||
echo "==> Logs (Ctrl+C to exit)..."
|
||||
docker compose logs -f
|
||||
docker compose -f compose.build.yml logs -f
|
||||
|
||||
@@ -10,6 +10,17 @@ const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by'); // don't advertise the tech stack
|
||||
|
||||
// Safe security headers (no CSP here — the inline SW registration script and
|
||||
// socket.io would both need 'unsafe-inline'/wss exceptions)
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
next();
|
||||
});
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
// HTTPS with self-signed cert for local network / PWA mic access
|
||||
@@ -40,9 +51,10 @@ const SHARED_USERS_FILE = process.env.SHARED_USERS_FILE || '';
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
|
||||
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
|
||||
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||
const HISTORY_FILE = path.join(DATA_DIR, 'history.json');
|
||||
|
||||
function readJSON(file, def) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; }
|
||||
@@ -56,10 +68,22 @@ function writeJSON(file, data) {
|
||||
let users = readJSON(USERS_FILE, []);
|
||||
let stats = readJSON(STATS_FILE, []);
|
||||
let config = readJSON(CONFIG_FILE, { signupsOpen: true });
|
||||
// Finished games, newest last. Each participant can replay the round-by-round
|
||||
// breakdown from their profile.
|
||||
let history = readJSON(HISTORY_FILE, []);
|
||||
|
||||
function saveUsers() { writeJSON(USERS_FILE, users); }
|
||||
function saveStats() { writeJSON(STATS_FILE, stats); }
|
||||
function saveConfig() { writeJSON(CONFIG_FILE, config); }
|
||||
function saveUsers() { writeJSON(USERS_FILE, users); }
|
||||
function saveStats() { writeJSON(STATS_FILE, stats); }
|
||||
function saveConfig() { writeJSON(CONFIG_FILE, config); }
|
||||
function saveHistory() { writeJSON(HISTORY_FILE, history); }
|
||||
|
||||
// Keep the history file from growing without bound.
|
||||
const MAX_HISTORY = 2000;
|
||||
function pushHistory(record) {
|
||||
history.push(record);
|
||||
if (history.length > MAX_HISTORY) history = history.slice(-MAX_HISTORY);
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function isAdmin(user) {
|
||||
return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME;
|
||||
@@ -138,10 +162,20 @@ function findUser(username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const EMPTY_STATS = { games_played: 0, games_won: 0, moon_shots: 0, total_score: 0 };
|
||||
|
||||
// Read-only lookup. Never creates or persists a row — the leaderboard walks
|
||||
// every known user, and creating-on-read there meant one full stats.json write
|
||||
// per user on every page load, plus a file steadily filling with empty rows.
|
||||
function readStats(userId) {
|
||||
return stats.find(s => s.userId === userId) || { userId, ...EMPTY_STATS };
|
||||
}
|
||||
|
||||
// Write path: materialises the row so addStats has something to accumulate into.
|
||||
function getStats(userId) {
|
||||
let s = stats.find(s => s.userId === userId);
|
||||
if (!s) {
|
||||
s = { userId, games_played: 0, games_won: 0, moon_shots: 0, total_score: 0 };
|
||||
s = { userId, ...EMPTY_STATS };
|
||||
stats.push(s);
|
||||
saveStats();
|
||||
}
|
||||
@@ -178,8 +212,21 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
||||
}));
|
||||
|
||||
// ─── Auth API ─────────────────────────────────────────────────
|
||||
const APP_VERSION = require('./package.json').version;
|
||||
|
||||
app.get('/api/config', (_req, res) => {
|
||||
res.json({ turnstileSiteKey: TURNSTILE_SITE_KEY || null, signupsOpen: config.signupsOpen });
|
||||
res.json({
|
||||
turnstileSiteKey: TURNSTILE_SITE_KEY || null,
|
||||
signupsOpen: config.signupsOpen,
|
||||
version: APP_VERSION,
|
||||
minRankedGames: MIN_RANKED_GAMES,
|
||||
});
|
||||
});
|
||||
|
||||
// Validate the stored auth token; the client calls this at startup so a stale
|
||||
// or corrupted token doesn't leave it running as a "logged in" ghost.
|
||||
app.get('/api/me', requireAuth, (req, res) => {
|
||||
res.json({ id: req.user.id, username: req.user.username });
|
||||
});
|
||||
|
||||
app.post('/api/register/initiate', async (req, res) => {
|
||||
@@ -302,16 +349,52 @@ 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);
|
||||
const s = readStats(effectiveId);
|
||||
const played = s.games_played || 0;
|
||||
res.json({
|
||||
username: user.username,
|
||||
games_played: s.games_played || 0,
|
||||
games_played: played,
|
||||
games_won: s.games_won || 0,
|
||||
moon_shots: s.moon_shots || 0,
|
||||
total_score: s.total_score || 0,
|
||||
win_rate: played > 0 ? +(((s.games_won || 0) / played) * 100).toFixed(1) : 0,
|
||||
avg_score: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : 0,
|
||||
isAdmin: ADMIN_USERNAME !== '' && user.username === ADMIN_USERNAME,
|
||||
signupsOpen: config.signupsOpen,
|
||||
});
|
||||
});
|
||||
|
||||
// Every finished game this player took part in, newest first, with the full
|
||||
// round-by-round breakdown so the client can render it without another call.
|
||||
app.get('/api/history/:username', (req, res) => {
|
||||
const user = findUser(req.params.username);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id;
|
||||
const games = history
|
||||
.filter(g => g.players?.some(p => p.uid === effectiveId))
|
||||
.sort((a, b) => b.endedAt - a.endedAt)
|
||||
.map(g => {
|
||||
const me = g.players.find(p => p.uid === effectiveId);
|
||||
const mySeat = me ? me.seat : -1;
|
||||
return {
|
||||
id: g.id,
|
||||
endedAt: g.endedAt,
|
||||
playerNames: g.playerNames,
|
||||
finalScores: g.finalScores,
|
||||
winners: g.winners,
|
||||
winScore: g.winScore,
|
||||
mySeat,
|
||||
myScore: mySeat >= 0 ? g.finalScores[mySeat] : null,
|
||||
won: mySeat >= 0 && g.winners.includes(mySeat),
|
||||
rounds: g.rounds,
|
||||
};
|
||||
});
|
||||
res.json(games);
|
||||
});
|
||||
|
||||
// Finished games a player needs before they appear in the ranking proper.
|
||||
const MIN_RANKED_GAMES = 5;
|
||||
|
||||
app.get('/api/leaderboard', (_req, res) => {
|
||||
const allUsers = [...users];
|
||||
if (SHARED_USERS_FILE) {
|
||||
@@ -326,26 +409,43 @@ app.get('/api/leaderboard', (_req, res) => {
|
||||
}
|
||||
const rows = allUsers.map(u => {
|
||||
const eid = u._fromShared ? `hokm_${u.id}` : u.id;
|
||||
const s = getStats(eid);
|
||||
const s = readStats(eid);
|
||||
const played = s.games_played || 0;
|
||||
const won = s.games_won || 0;
|
||||
return {
|
||||
username: u.username,
|
||||
games_played: played,
|
||||
games_won: s.games_won || 0,
|
||||
games_won: won,
|
||||
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,
|
||||
win_rate: played > 0 ? +((won / played) * 100).toFixed(1) : null,
|
||||
score_per_game: played > 0 ? +(( s.total_score || 0) / played).toFixed(1) : null,
|
||||
};
|
||||
})
|
||||
.filter(r => r.games_played > 0)
|
||||
// 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);
|
||||
.filter(r => r.games_played > 0);
|
||||
|
||||
// Win rate is the primary ranking; average score per game breaks ties, and
|
||||
// there lower is better because Hearts scores are penalties.
|
||||
const byWinRate = (a, b) =>
|
||||
(b.win_rate - a.win_rate) ||
|
||||
(a.score_per_game - b.score_per_game) ||
|
||||
(b.games_played - a.games_played);
|
||||
|
||||
// A single lucky game shouldn't top the board, so a player needs
|
||||
// MIN_RANKED_GAMES finished games before they get a rank. Everyone below that
|
||||
// is still listed, unranked, so a new player can see their progress.
|
||||
const ranked = rows.filter(r => r.games_played >= MIN_RANKED_GAMES)
|
||||
.sort(byWinRate)
|
||||
.slice(0, 30)
|
||||
.map(r => ({ ...r, ranked: true, gamesToRank: 0 }));
|
||||
|
||||
const provisional = rows.filter(r => r.games_played < MIN_RANKED_GAMES)
|
||||
.sort((a, b) => b.games_played - a.games_played || byWinRate(a, b))
|
||||
.slice(0, 10)
|
||||
.map(r => ({ ...r, ranked: false,
|
||||
gamesToRank: MIN_RANKED_GAMES - r.games_played }));
|
||||
|
||||
res.json([...ranked, ...provisional]);
|
||||
});
|
||||
|
||||
app.get('/api/rooms', (_req, res) => {
|
||||
@@ -429,6 +529,9 @@ function newRoom(id) {
|
||||
moonShooter: -1,
|
||||
handDeltas: null,
|
||||
winScore: 100,
|
||||
roundHistory: [], // per-hand record for the breakdown + history
|
||||
moonsBySeat: [0, 0, 0, 0], // moon shots this game, for the stats table
|
||||
trickSnapshot: null, // last known-good state (see checkHandIntegrity)
|
||||
spectators: new Set(),
|
||||
isPublic: false,
|
||||
createdAt: Date.now(),
|
||||
@@ -472,6 +575,7 @@ function publicInfo(room, seat = -1) {
|
||||
moonShooter: room.moonShooter,
|
||||
handDeltas: room.handDeltas,
|
||||
gameWinner: room.gameWinner,
|
||||
roundHistory: room.roundHistory,
|
||||
spectatorCount: room.spectators.size,
|
||||
isPublic: room.isPublic,
|
||||
aiControlledSeats: [...room.aiControlledSeats],
|
||||
@@ -513,21 +617,29 @@ function broadcastAiControl(room, seat, active) {
|
||||
|
||||
function scheduleAfkTimer(room) {
|
||||
clearAfkTimer(room);
|
||||
if (room.state !== 'PLAYING') return;
|
||||
const seat = room.currentTurn;
|
||||
if (room.bots[seat]) return;
|
||||
if (seat < 0 || room.bots[seat] || !room.seats[seat]) return;
|
||||
|
||||
if (room.aiControlledSeats.has(seat)) {
|
||||
// Already AI-controlled — keep the game moving on a short timer
|
||||
room.afkTimer = setTimeout(() => {
|
||||
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
|
||||
if (!room.aiControlledSeats.has(seat)) return;
|
||||
const card = botChooseCard(room, seat);
|
||||
if (card) onCardPlayed(room, seat, card);
|
||||
}, 10000);
|
||||
} else {
|
||||
room.afkSeat = seat;
|
||||
room.afkTimer = setTimeout(() => {
|
||||
if (room.state !== 'PLAYING') return;
|
||||
broadcast(room, 'afkWarning', { seat, name: room.names[seat] });
|
||||
}, 60000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal seat: only flag it as AFK once the minute is actually up, so a
|
||||
// takeover vote can't be cast against a player who is still thinking.
|
||||
room.afkTimer = setTimeout(() => {
|
||||
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
|
||||
room.afkSeat = seat;
|
||||
room.afkVotes.clear();
|
||||
broadcast(room, 'afkWarning', { seat, name: room.names[seat] });
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
// ─── Dealing ──────────────────────────────────────────────────
|
||||
@@ -611,18 +723,85 @@ function exchangePassCards(room) {
|
||||
if (idx !== -1) room.hands[p].splice(idx, 1);
|
||||
}
|
||||
}
|
||||
// Add to receivers
|
||||
// Add to receivers. Seats are laid out clockwise from the viewer, matching
|
||||
// the table: your left neighbour is seat+1, across is seat+2, right is seat+3.
|
||||
// (Play runs anti-clockwise, seat → seat+3, i.e. bottom → right → top → left.)
|
||||
for (let from = 0; from < 4; from++) {
|
||||
// "left" = visual left (area-left = seat+3); "right" = visual right (area-right = seat+1)
|
||||
const to = dir === 'left' ? (from + 3) % 4
|
||||
const to = dir === 'left' ? (from + 1) % 4
|
||||
: dir === 'across' ? (from + 2) % 4
|
||||
: (from + 1) % 4; // right
|
||||
: (from + 3) % 4; // right
|
||||
for (const c of sending[from]) room.hands[to].push(c);
|
||||
room.hands[to].sort(sortCards);
|
||||
}
|
||||
room.passCards = [null, null, null, null];
|
||||
}
|
||||
|
||||
// ─── Hand integrity: every player must hold the same card count ───
|
||||
// A player who somehow gets two cards out in one trick ends the hand early for
|
||||
// everyone (they run dry while the others still hold cards). Playing is guarded
|
||||
// against that at several points; this is the safety net that catches whatever
|
||||
// slips through, by rewinding to the last state that was known to be sound.
|
||||
|
||||
function handsBalanced(room) {
|
||||
const n = room.hands[0].length;
|
||||
return room.hands.every(h => h.length === n);
|
||||
}
|
||||
|
||||
// Save the state at the start of a trick, but only when it is actually sound —
|
||||
// so the snapshot is always the last *correct* state we can fall back to.
|
||||
function snapshotTrickStart(room) {
|
||||
if (!handsBalanced(room)) return;
|
||||
room.trickSnapshot = {
|
||||
tricksPlayed: room.tricksPlayed,
|
||||
hands: room.hands.map(h => h.slice()),
|
||||
handPoints: room.handPoints.slice(),
|
||||
heartsBroken: room.heartsBroken,
|
||||
trickLead: room.trickLead,
|
||||
currentTurn: room.trickLead,
|
||||
lastTrick: room.lastTrick ? room.lastTrick.slice() : null,
|
||||
lastTrickWinner: room.lastTrickWinner,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreTrickSnapshot(room) {
|
||||
const snap = room.trickSnapshot;
|
||||
if (!snap) return false;
|
||||
room.tricksPlayed = snap.tricksPlayed;
|
||||
room.hands = snap.hands.map(h => h.slice());
|
||||
room.handPoints = snap.handPoints.slice();
|
||||
room.heartsBroken = snap.heartsBroken;
|
||||
room.trickLead = snap.trickLead;
|
||||
room.currentTurn = snap.currentTurn;
|
||||
room.lastTrick = snap.lastTrick ? snap.lastTrick.slice() : null;
|
||||
room.lastTrickWinner = snap.lastTrickWinner;
|
||||
room.trick = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true when the hand is sound. When it isn't, rewinds the room to the
|
||||
// start of the last good trick, tells everyone, and returns false — the caller
|
||||
// must abandon whatever it was about to do next.
|
||||
function checkHandIntegrity(room, where) {
|
||||
const expected = 13 - room.tricksPlayed;
|
||||
if (handsBalanced(room) && room.hands[0].length === expected) return true;
|
||||
|
||||
console.warn(`[${room.id}] card-count desync at ${where}: ` +
|
||||
`hands ${room.hands.map(h => h.length).join('/')}, expected ${expected} each`);
|
||||
|
||||
if (!restoreTrickSnapshot(room)) {
|
||||
console.error(`[${room.id}] no sound snapshot to restore — hand left as-is`);
|
||||
return true; // nothing better to do than let the hand run out
|
||||
}
|
||||
|
||||
clearAfkTimer(room);
|
||||
if (room.trickTimer) { clearTimeout(room.trickTimer); room.trickTimer = null; }
|
||||
broadcast(room, 'stateReverted', { trick: room.tricksPlayed + 1 });
|
||||
broadcastState(room, 'roomInfo');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Game flow ────────────────────────────────────────────────
|
||||
function startPassing(room) {
|
||||
clearAfkTimer(room);
|
||||
@@ -639,17 +818,26 @@ function startPlaying(room) {
|
||||
room.trickLead = find2Clubs(room);
|
||||
room.currentTurn = room.trickLead;
|
||||
room.trick = [];
|
||||
snapshotTrickStart(room);
|
||||
broadcastState(room, 'roomInfo');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
}
|
||||
|
||||
function onCardPlayed(room, player, card) {
|
||||
// Remove from hand
|
||||
room.hands[player] = room.hands[player].filter(c => c !== card);
|
||||
// Add to trick
|
||||
// ── Guards against a seat getting two cards into one trick ──
|
||||
// Bots, the AFK auto-player and the socket handler all land here, so the
|
||||
// checks live in one place rather than at each call site.
|
||||
if (room.state !== 'PLAYING') return;
|
||||
if (room.trick.length >= 4) return; // trick already resolving
|
||||
if (room.currentTurn !== player) return; // not this seat's turn
|
||||
if (room.trick.some(t => t.player === player)) return; // already played this trick
|
||||
|
||||
const idx = room.hands[player].indexOf(card);
|
||||
if (idx === -1) return; // card isn't in hand — stale or duplicated click
|
||||
room.hands[player].splice(idx, 1); // remove exactly one copy
|
||||
|
||||
room.trick.push({ card, player });
|
||||
// Break hearts
|
||||
if (isHeart(card) || card === 'S-Q') room.heartsBroken = true;
|
||||
|
||||
if (room.trick.length < 4) {
|
||||
@@ -660,27 +848,33 @@ function onCardPlayed(room, player, card) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Trick complete
|
||||
const winner = trickWinner(room.trick);
|
||||
// Trick complete. Nobody is on turn during the resolution window, so the
|
||||
// player who closed the trick can't slip a second card in before the
|
||||
// trick-advance timer fires.
|
||||
room.currentTurn = -1;
|
||||
clearAfkTimer(room);
|
||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||
|
||||
const winner = trickWinner(room.trick);
|
||||
const trickPts = room.trick.reduce((s, t) => s + points(t.card), 0);
|
||||
room.handPoints[winner] += trickPts;
|
||||
room.tricksPlayed++;
|
||||
room.lastTrick = room.trick.slice();
|
||||
room.lastTrickWinner = winner;
|
||||
|
||||
// Every hand must now hold the same number of cards. If not, rewind.
|
||||
if (!checkHandIntegrity(room, `end of trick ${room.tricksPlayed}`)) return;
|
||||
|
||||
broadcastState(room, 'trickWon');
|
||||
|
||||
if (room.tricksPlayed === 13) {
|
||||
broadcastState(room, 'trickWon');
|
||||
clearAfkTimer(room);
|
||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||
room.trickTimer = setTimeout(() => finishHand(room), 1400);
|
||||
} else {
|
||||
broadcastState(room, 'trickWon');
|
||||
clearAfkTimer(room);
|
||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||
room.trickTimer = setTimeout(() => {
|
||||
room.trickLead = winner;
|
||||
room.currentTurn = winner;
|
||||
room.trick = [];
|
||||
snapshotTrickStart(room);
|
||||
broadcastState(room, 'roomInfo');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
@@ -690,6 +884,11 @@ function onCardPlayed(room, player, card) {
|
||||
|
||||
function finishHand(room) {
|
||||
clearAfkTimer(room);
|
||||
// A hand can only end with every hand empty. If cards are left over the
|
||||
// deal desynced somewhere; rewind rather than score a broken hand.
|
||||
if (room.hands.some(h => h.length > 0)) {
|
||||
if (!checkHandIntegrity(room, 'end of hand')) return;
|
||||
}
|
||||
for (const s of room.aiControlledSeats) broadcastAiControl(room, s, false);
|
||||
room.aiControlledSeats.clear();
|
||||
// Check shoot the moon (one player has all 26 pts)
|
||||
@@ -706,6 +905,17 @@ function finishHand(room) {
|
||||
|
||||
room.handDeltas = deltas;
|
||||
for (let i = 0; i < 4; i++) room.scores[i] += deltas[i];
|
||||
if (shooter !== -1) room.moonsBySeat[shooter]++;
|
||||
|
||||
// Record the hand for the end-of-hand breakdown, the game-over screen and
|
||||
// the saved game history.
|
||||
room.roundHistory.push({
|
||||
hand: room.roundHistory.length + 1,
|
||||
deltas: deltas.slice(),
|
||||
scores: room.scores.slice(),
|
||||
moonShooter: shooter,
|
||||
moonShooterName: shooter !== -1 ? (room.names[shooter] || '') : '',
|
||||
});
|
||||
|
||||
// Check game over: someone hit winScore
|
||||
if (room.scores.some(s => s >= room.winScore)) {
|
||||
@@ -740,7 +950,8 @@ function finishGame(room) {
|
||||
room.state = 'GAME_OVER';
|
||||
broadcastState(room, 'gameOver');
|
||||
|
||||
// Skip stats entirely if any bot participated — games vs bots don't count
|
||||
// Skip stats entirely if any bot participated — games vs bots don't count,
|
||||
// and they aren't written to history either, to keep both lists clean.
|
||||
if (room.bots.some(Boolean)) return;
|
||||
|
||||
const winners = new Set(room.gameWinner);
|
||||
@@ -750,16 +961,58 @@ function finishGame(room) {
|
||||
addStats(uid, {
|
||||
games_played: 1,
|
||||
games_won: winners.has(i) ? 1 : 0,
|
||||
moon_shots: 0,
|
||||
moon_shots: room.moonsBySeat[i],
|
||||
total_score: room.scores[i],
|
||||
});
|
||||
}
|
||||
|
||||
saveGameHistory(room, winners);
|
||||
}
|
||||
|
||||
// Persist a finished game so each participant can review it later.
|
||||
function saveGameHistory(room, winners) {
|
||||
pushHistory({
|
||||
id: `${room.id}-${Date.now()}`,
|
||||
endedAt: Date.now(),
|
||||
winScore: room.winScore,
|
||||
playerNames: room.names.slice(),
|
||||
finalScores: room.scores.slice(),
|
||||
winners: [...winners],
|
||||
players: room.userIds
|
||||
.map((uid, seat) => ({ uid, seat, name: room.names[seat] }))
|
||||
.filter(p => p.uid != null),
|
||||
rounds: room.roundHistory,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Room teardown / leave recovery ───────────────────────────
|
||||
function destroyRoom(room) {
|
||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||
if (room.afkTimer) clearTimeout(room.afkTimer);
|
||||
rooms.delete(room.id);
|
||||
}
|
||||
|
||||
// A human who leaves mid-game has their seat handed to AI; nudge the current
|
||||
// phase so that AI acts if the game is now waiting on that seat.
|
||||
function resumeAfterLeave(room, seat) {
|
||||
if (room.state === 'PASSING') {
|
||||
if (room.bots[seat] && room.passCards[seat] === null) {
|
||||
setTimeout(() => botPass(room, seat), 700);
|
||||
}
|
||||
} else if (room.state === 'PLAYING') {
|
||||
scheduleAfkTimer(room);
|
||||
scheduleBotPlay(room);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Start the game ───────────────────────────────────────────
|
||||
function tryStartGame(room) {
|
||||
if (room.seats.filter(Boolean).length + room.bots.filter(Boolean).length < 4) return;
|
||||
if (room.seats.some((s, i) => !s && !room.bots[i])) return;
|
||||
// Fresh game — clear the accumulators that span hands
|
||||
room.scores = [0, 0, 0, 0];
|
||||
room.roundHistory = [];
|
||||
room.moonsBySeat = [0, 0, 0, 0];
|
||||
dealHand(room); // handNumber is 0 from newRoom; passDirection set inside
|
||||
if (room.passDirection === 'hold') {
|
||||
startPlaying(room);
|
||||
@@ -995,6 +1248,42 @@ io.on('connection', (socket) => {
|
||||
tryStartGame(room);
|
||||
});
|
||||
|
||||
// ── Swap seats (creator only, while still in the waiting room) ──
|
||||
socket.on('swapSeats', ({ roomId, seatA, seatB } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
if (!room || room.state !== 'WAITING') return;
|
||||
const isCreator = room.seats[0] === socket.id ||
|
||||
(user?.id && room.userIds[0] === user.id);
|
||||
if (!isCreator) return;
|
||||
if (seatA === seatB || ![0,1,2,3].includes(seatA) || ![0,1,2,3].includes(seatB)) return;
|
||||
|
||||
for (const key of ['names', 'seats', 'tokens', 'userIds', 'bots']) {
|
||||
[room[key][seatA], room[key][seatB]] = [room[key][seatB], room[key][seatA]];
|
||||
}
|
||||
// Each moved player needs their new seat number and token
|
||||
if (room.seats[seatA]) io.to(room.seats[seatA]).emit('seatChanged', { seat: seatA, token: room.tokens[seatA] });
|
||||
if (room.seats[seatB]) io.to(room.seats[seatB]).emit('seatChanged', { seat: seatB, token: room.tokens[seatB] });
|
||||
broadcastState(room, 'roomInfo');
|
||||
});
|
||||
|
||||
// ── Force start (creator only, fills the remaining seats with bots) ──
|
||||
socket.on('forceStart', ({ roomId } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
if (!room || room.state !== 'WAITING') return;
|
||||
const isCreator = room.seats[0] === socket.id ||
|
||||
(user?.id && room.userIds[0] === user.id);
|
||||
if (!isCreator) return;
|
||||
const botNames = ['Alice', 'Bob', 'Charlie', 'Diana'];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (!room.seats[i] && !room.bots[i]) {
|
||||
room.bots[i] = true;
|
||||
room.names[i] = botNames[i];
|
||||
}
|
||||
}
|
||||
broadcastState(room, 'roomInfo');
|
||||
tryStartGame(room);
|
||||
});
|
||||
|
||||
// ── Pass cards ─────────────────────────────────────────────
|
||||
socket.on('passCards', ({ roomId, seat, token, cards } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
@@ -1022,6 +1311,10 @@ io.on('connection', (socket) => {
|
||||
if (room.state !== 'PLAYING') return;
|
||||
if (room.tokens[seat] !== token) return;
|
||||
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
|
||||
// Belt and braces against a double-tap or a duplicated socket event landing
|
||||
// twice: one seat may only ever contribute one card to a trick.
|
||||
if (room.trick.some(t => t.player === seat)) return socket.emit('playError', 'Already played this trick');
|
||||
if (!room.hands[seat].includes(card)) return socket.emit('playError', 'Card not in hand');
|
||||
if (!isLegal(room, seat, card)) return socket.emit('playError', 'Illegal card');
|
||||
if (room.aiControlledSeats.has(seat)) {
|
||||
room.aiControlledSeats.delete(seat);
|
||||
@@ -1062,12 +1355,34 @@ io.on('connection', (socket) => {
|
||||
socket.on('leave', ({ roomId, seat, token } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
if (!room) return;
|
||||
if (room.tokens[seat] === token) {
|
||||
room.seats[seat] = null;
|
||||
room.tokens[seat] = null;
|
||||
room.userIds[seat] = null;
|
||||
}
|
||||
if (room.tokens[seat] !== token) { socket.leave(room.id); return; }
|
||||
|
||||
socket.leave(room.id);
|
||||
|
||||
// Vacate the seat. Nulling userIds also stops hasActiveGame pulling this
|
||||
// player straight back into the room they just left.
|
||||
room.seats[seat] = null;
|
||||
room.tokens[seat] = null;
|
||||
room.userIds[seat] = null;
|
||||
room.aiControlledSeats.delete(seat);
|
||||
|
||||
const inProgress = room.state !== 'WAITING' && room.state !== 'GAME_OVER';
|
||||
|
||||
// Nobody left to play for — tear the room down
|
||||
if (!room.seats.some(Boolean) && room.spectators.size === 0) {
|
||||
destroyRoom(room);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inProgress) {
|
||||
// Hand the seat to AI so the game keeps going for everyone else
|
||||
room.bots[seat] = true;
|
||||
broadcastState(room, 'roomInfo');
|
||||
resumeAfterLeave(room, seat);
|
||||
} else {
|
||||
room.names[seat] = '';
|
||||
broadcastState(room, 'roomInfo');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
|
||||
Reference in New Issue
Block a user