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:
goyban
2026-08-30 14:13:46 +00:00
parent e499e89027
commit dc051cbe45
9 changed files with 2935 additions and 2205 deletions
+105 -61
View File
@@ -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 &amp; 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>