Fix widow/play bugs, exit-anywhere; rework leaderboard + add game history
- Widow: preserve card selection across re-renders (no more deselecting) - Play: reject a second card during trick resolution (fixes hand-count desync) - Leave: exit available during bidding/widow/trump; a mid-game leave hands the seat to AI so the game continues; rooms with no humans are torn down - Leaderboard: one-time flush + schema migration, win rate as the primary ranking metric, per-team shelem and per-seat bid tracking, avg score + bids won - History: per-game round-by-round records persisted to history.json, a history screen, and a vertical round breakdown (with totals) shown to all players at hand/game end; result held for 15s Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+157
-11
@@ -19,6 +19,7 @@ let swapSelectedSeat = -1; // seat highlighted for swap in waiting room
|
||||
|
||||
// Widow discard state
|
||||
let widowSelected = [];
|
||||
let widowActive = false; // true while the widow overlay is open for this WIDOW phase
|
||||
|
||||
// Bid UI state
|
||||
let currentBidAmount = 85;
|
||||
@@ -907,11 +908,20 @@ function computeLegalCards(st, seat) {
|
||||
|
||||
// ─── Overlays ─────────────────────────────────────────────────
|
||||
function renderOverlays(st) {
|
||||
hideAllOverlays();
|
||||
const widowDeclarer = st.state === 'WIDOW' && mySeat === st.declarer;
|
||||
|
||||
// Hide the bid/hand overlays every render, but manage the widow overlay
|
||||
// separately so an unrelated re-render (a spectator joining, a rejoin, etc.)
|
||||
// does not wipe the declarer's in-progress card selection.
|
||||
hide('overlay-bid');
|
||||
hide('overlay-hand');
|
||||
if (!widowDeclarer && widowActive) { widowActive = false; hide('overlay-widow'); }
|
||||
// Restore actual hand mode if we temporarily forced fan mode during bidding
|
||||
if (isTouchDevice()) applyHandMode();
|
||||
|
||||
if (st.state === 'BIDDING') {
|
||||
renderBiddingOverlay(st);
|
||||
} else if (st.state === 'WIDOW' && mySeat === st.declarer) {
|
||||
} else if (widowDeclarer) {
|
||||
renderWidowOverlay(st);
|
||||
} else if (st.state === 'WIDOW' && mySeat !== st.declarer) {
|
||||
$('phase-msg').textContent = `${st.names[st.declarer]} is picking up the widow…`;
|
||||
@@ -922,6 +932,7 @@ function hideAllOverlays() {
|
||||
hide('overlay-bid');
|
||||
hide('overlay-widow');
|
||||
hide('overlay-hand');
|
||||
widowActive = false;
|
||||
// Restore actual hand mode if we temporarily forced fan mode during bidding
|
||||
if (isTouchDevice()) applyHandMode();
|
||||
}
|
||||
@@ -992,12 +1003,21 @@ function renderBiddingOverlay(st) {
|
||||
|
||||
// ─── Widow overlay ────────────────────────────────────────────
|
||||
function renderWidowOverlay(st) {
|
||||
widowSelected = [];
|
||||
show('overlay-widow');
|
||||
|
||||
const needed = st.widowSize;
|
||||
$('widow-title').textContent = `Pick up widow — discard ${needed} cards`;
|
||||
$('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`;
|
||||
|
||||
if (!widowActive) {
|
||||
// First render of this WIDOW phase — initialize a fresh selection
|
||||
widowActive = true;
|
||||
widowSelected = [];
|
||||
show('overlay-widow');
|
||||
$('widow-title').textContent = `Pick up widow — discard ${needed} cards`;
|
||||
$('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`;
|
||||
} else {
|
||||
// Re-render while already open — preserve the current selection, but drop
|
||||
// any card that is somehow no longer in hand as a safety measure.
|
||||
const hand = Array.isArray(st.hands[mySeat]) ? st.hands[mySeat] : [];
|
||||
widowSelected = widowSelected.filter(c => hand.includes(c));
|
||||
}
|
||||
|
||||
renderWidowHand(st);
|
||||
updateWidowPreview(st);
|
||||
@@ -1152,6 +1172,9 @@ function onHandOver(st) {
|
||||
scoresEl.appendChild(row);
|
||||
}
|
||||
|
||||
// Round-by-round running totals (all rounds so far, current totals after the divider)
|
||||
renderRoundBreakdown($('hand-round-breakdown'), st.roundHistory, st.teamNames, st.scores);
|
||||
|
||||
show('overlay-hand');
|
||||
// overlay-hand is hidden by hideAllOverlays() when the next render() fires (BIDDING state)
|
||||
}
|
||||
@@ -1178,6 +1201,9 @@ function onGameOver(st) {
|
||||
scoresEl.appendChild(row);
|
||||
}
|
||||
|
||||
// The round-by-round breakdown every player sees at the end of the game
|
||||
renderRoundBreakdown($('gameover-breakdown'), st.roundHistory, st.teamNames, st.scores);
|
||||
|
||||
show('overlay-gameover');
|
||||
}
|
||||
|
||||
@@ -1250,10 +1276,15 @@ async function showProfile(username) {
|
||||
const r = await fetch(`/api/profile/${encodeURIComponent(username)}`);
|
||||
const d = await r.json();
|
||||
if (!r.ok) return;
|
||||
$('stat-games-played').textContent = d.games_played;
|
||||
$('stat-win-rate').textContent = (d.win_rate != null ? d.win_rate : 0) + '%';
|
||||
$('stat-games-won').textContent = d.games_won;
|
||||
$('stat-games-played').textContent = d.games_played;
|
||||
$('stat-shelem').textContent = d.shelemCount || 0;
|
||||
$('stat-total-score').textContent = d.total_score;
|
||||
$('stat-bids-won').textContent = d.bids_won || 0;
|
||||
$('stat-avg-score').textContent = d.avg_score != null ? d.avg_score : 0;
|
||||
|
||||
// History button only makes sense for a user who has an identity
|
||||
$('btn-show-history').dataset.username = username;
|
||||
|
||||
// Change password button (only own profile)
|
||||
if (username === authUser) show('btn-show-change-pass');
|
||||
@@ -1282,10 +1313,12 @@ async function showLeaderboard() {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${i + 1}</td>
|
||||
<td>${row.username}</td>
|
||||
<td>${row.score_per_game ?? '—'}</td>
|
||||
<td>${escHtml(row.username)}</td>
|
||||
<td>${row.win_rate != null ? row.win_rate + '%' : '—'}</td>
|
||||
<td>${row.games_won}</td>
|
||||
<td>${row.games_played}</td>
|
||||
<td>${row.avg_score ?? '—'}</td>
|
||||
<td>${row.bids_won || 0}</td>
|
||||
<td>${row.shelemCount || 0}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
@@ -1293,6 +1326,97 @@ async function showLeaderboard() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Round-by-round breakdown (shared) ────────────────────────
|
||||
// Renders each round's point swing per team, then a divider column with the
|
||||
// running/current total. Used at hand-over, game-over, and in game history.
|
||||
function renderRoundBreakdown(container, rounds, teamNames, finalScores) {
|
||||
if (!container) return;
|
||||
if (!Array.isArray(rounds) || rounds.length === 0) {
|
||||
container.innerHTML = '<p class="hint">No rounds played yet.</p>';
|
||||
return;
|
||||
}
|
||||
const names = teamNames || ['Team 1', 'Team 2'];
|
||||
const totals = finalScores || rounds[rounds.length - 1].scores;
|
||||
|
||||
// Rounds run down the page (one row each), teams are the two columns, and a
|
||||
// horizontal line separates the final totals row at the bottom.
|
||||
let html = '<div class="rb-scroll"><table class="round-breakdown"><thead><tr>' +
|
||||
'<th class="rb-round"></th>' +
|
||||
`<th>${escHtml(names[0] || 'Team 1')}</th>` +
|
||||
`<th>${escHtml(names[1] || 'Team 2')}</th>` +
|
||||
'</tr></thead><tbody>';
|
||||
rounds.forEach(r => {
|
||||
html += `<tr><td class="rb-round">R${r.hand}</td>`;
|
||||
for (let t = 0; t < 2; t++) {
|
||||
const d = (r.deltas && r.deltas[t]) || 0;
|
||||
html += `<td class="${d > 0 ? 'pos' : d < 0 ? 'neg' : ''}">${d > 0 ? '+' : ''}${d}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody><tfoot><tr class="rb-final">' +
|
||||
'<td class="rb-round">Total</td>' +
|
||||
`<td>${totals[0]}</td><td>${totals[1]}</td>` +
|
||||
'</tr></tfoot></table></div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Game History ─────────────────────────────────────────────
|
||||
async function showHistory(username) {
|
||||
if (!username) return;
|
||||
showScreen('screen-history');
|
||||
$('history-title').textContent = username === authUser ? 'Your Game History' : `${username}'s History`;
|
||||
hide('history-detail');
|
||||
show('history-list');
|
||||
const listEl = $('history-list');
|
||||
listEl.innerHTML = '<p class="hint">Loading…</p>';
|
||||
try {
|
||||
const r = await fetch(`/api/history/${encodeURIComponent(username)}`);
|
||||
const games = await r.json();
|
||||
if (!Array.isArray(games) || games.length === 0) {
|
||||
listEl.innerHTML = '<p class="hint">No games played yet.</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
games.forEach(g => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'history-item' + (g.won ? ' won' : ' lost');
|
||||
const date = new Date(g.endedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
row.innerHTML = `
|
||||
<div class="history-item-main">
|
||||
<span class="history-item-result">${g.won ? '🏆 Won' : '✖ Lost'}</span>
|
||||
<span class="history-item-teams">${escHtml(g.teamNames[0])} vs ${escHtml(g.teamNames[1])}</span>
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
<span>${g.finalScores[0]} – ${g.finalScores[1]}</span>
|
||||
<span class="history-item-date">${date}</span>
|
||||
</div>`;
|
||||
row.addEventListener('click', () => showHistoryDetail(g));
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
} catch {
|
||||
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load history.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function showHistoryDetail(g) {
|
||||
hide('history-list');
|
||||
show('history-detail');
|
||||
const date = new Date(g.endedAt).toLocaleString();
|
||||
$('history-detail-title').textContent =
|
||||
`${g.won ? '🏆 ' : ''}${g.teamNames[0]} vs ${g.teamNames[1]} — ${date}`;
|
||||
|
||||
const scoresEl = $('history-detail-scores');
|
||||
scoresEl.innerHTML = '';
|
||||
for (let t = 0; t < 2; t++) {
|
||||
const won = (g.winnerTeams || []).includes(t);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'gameover-row' + (won ? ' winner' : '');
|
||||
row.innerHTML = `<span>${escHtml(g.teamNames[t])}</span><span style="font-weight:700">${g.finalScores[t]}</span>`;
|
||||
scoresEl.appendChild(row);
|
||||
}
|
||||
renderRoundBreakdown($('history-detail-breakdown'), g.rounds, g.teamNames, g.finalScores);
|
||||
}
|
||||
|
||||
function updateAiControlBanner() {
|
||||
if (aiControlledSeats.size === 0) {
|
||||
hide('ai-control-banner');
|
||||
@@ -1315,6 +1439,13 @@ function clearGameState() {
|
||||
aiControlledSeats.clear();
|
||||
hide('ai-control-banner');
|
||||
hide('afk-banner');
|
||||
// Dismiss any phase overlay so it can't linger over the lobby after leaving
|
||||
hide('overlay-bid');
|
||||
hide('overlay-widow');
|
||||
hide('overlay-trump');
|
||||
hide('overlay-hand');
|
||||
hide('overlay-gameover');
|
||||
widowActive = false;
|
||||
}
|
||||
|
||||
// ─── Event wiring ──────────────────────────────────────────────
|
||||
@@ -1396,6 +1527,11 @@ function wireGameEvents() {
|
||||
show('overlay-exit-confirm');
|
||||
});
|
||||
$('btn-leave-game').addEventListener('click', () => show('overlay-exit-confirm'));
|
||||
// Leave buttons inside the bidding / widow / trump overlays (these overlays
|
||||
// cover the info bar, so players need their own exit path during those phases)
|
||||
document.querySelectorAll('[data-overlay-leave]').forEach(btn => {
|
||||
btn.addEventListener('click', () => show('overlay-exit-confirm'));
|
||||
});
|
||||
$('btn-exit-confirm-yes').addEventListener('click', () => {
|
||||
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||
clearSession();
|
||||
@@ -1472,6 +1608,16 @@ function wireGameEvents() {
|
||||
$('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby'));
|
||||
$('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby'));
|
||||
|
||||
// Game history
|
||||
$('btn-show-history').addEventListener('click', (e) => {
|
||||
showHistory(e.currentTarget.dataset.username || authUser);
|
||||
});
|
||||
$('btn-history-back').addEventListener('click', () => showScreen('screen-profile'));
|
||||
$('btn-history-detail-back').addEventListener('click', () => {
|
||||
hide('history-detail');
|
||||
show('history-list');
|
||||
});
|
||||
|
||||
// Change password
|
||||
$('btn-show-change-pass').addEventListener('click', () => {
|
||||
$('change-pass-form').classList.toggle('hidden');
|
||||
|
||||
+42
-5
@@ -261,6 +261,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p id="bid-waiting-msg" class="hint" style="min-height:20px"></p>
|
||||
<button class="overlay-leave-btn" data-overlay-leave>🚪 Leave game</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,6 +277,7 @@
|
||||
</div>
|
||||
<button id="btn-confirm-discard" class="btn-primary" disabled>Confirm Discard</button>
|
||||
<p id="widow-waiting" class="hint" style="min-height:18px"></p>
|
||||
<button class="overlay-leave-btn" data-overlay-leave>🚪 Leave game</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,6 +298,7 @@
|
||||
<div class="result-icon" style="font-size:2rem">⏳</div>
|
||||
<p id="trump-waiting-msg" class="hint"></p>
|
||||
</div>
|
||||
<button class="overlay-leave-btn" data-overlay-leave>🚪 Leave game</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -306,6 +309,7 @@
|
||||
<h3 id="hand-result-title"></h3>
|
||||
<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>
|
||||
@@ -316,6 +320,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>
|
||||
@@ -367,21 +372,33 @@
|
||||
<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-shelem">—</span>
|
||||
<span class="stat-label">Shelems 🃏</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-num" id="stat-total-score">—</span>
|
||||
<span class="stat-label">Total Score</span>
|
||||
<span class="stat-num" id="stat-bids-won">—</span>
|
||||
<span class="stat-label">Bids Won</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-num" id="stat-avg-score">—</span>
|
||||
<span class="stat-label">Avg 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">
|
||||
@@ -434,7 +451,7 @@
|
||||
<table class="lb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Player</th><th>Avg Score</th><th>W</th><th>Played</th><th>🃏</th>
|
||||
<th>#</th><th>Player</th><th>Win %</th><th>W</th><th>P</th><th>Avg</th><th title="Bids won">Bid</th><th title="Shelems">🃏</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lb-body"></tbody>
|
||||
@@ -443,6 +460,26 @@
|
||||
</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>
|
||||
<!-- List of past games -->
|
||||
<div id="history-list" class="history-list"><p class="hint">Loading…</p></div>
|
||||
<!-- Detail of a selected game -->
|
||||
<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>
|
||||
|
||||
<!-- ══════════════ EXIT CONFIRM ══════════════ -->
|
||||
<div id="overlay-exit-confirm" class="overlay hidden">
|
||||
<div class="overlay-box small" style="text-align:center">
|
||||
|
||||
@@ -760,6 +760,17 @@ input:focus { border-color: var(--gold); }
|
||||
}
|
||||
.overlay-box.small { max-width: 320px; }
|
||||
.overlay-box h3 { font-size: 1.1rem; color: var(--gold); text-align: center; }
|
||||
.overlay-leave-btn {
|
||||
align-self: center;
|
||||
margin-top: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,128,128,.75);
|
||||
font-size: .82rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.overlay-leave-btn:hover { color: #ff8080; text-decoration: underline; }
|
||||
|
||||
/* ── Bidding overlay ──────────────────────────── */
|
||||
.bid-box {}
|
||||
@@ -1022,6 +1033,64 @@ input:focus { border-color: var(--gold); }
|
||||
.lb-table td { border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||
.lb-table tr:hover td { background: rgba(255,255,255,.04); }
|
||||
|
||||
/* ── Round-by-round breakdown ───── */
|
||||
.round-breakdown-wrap { margin: 6px 0 2px; }
|
||||
.rb-scroll { max-height: 42vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||||
.round-breakdown {
|
||||
border-collapse: collapse;
|
||||
font-size: .82rem;
|
||||
margin: 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.round-breakdown th, .round-breakdown td {
|
||||
padding: 4px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Sticky team-name header so it stays visible as rounds scroll */
|
||||
.round-breakdown thead th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #1a2e1a;
|
||||
border-bottom: 1px solid rgba(255,255,255,.12);
|
||||
}
|
||||
.round-breakdown .rb-round {
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.round-breakdown td.pos { color: #69f0ae; }
|
||||
.round-breakdown td.neg { color: #ff6b6b; }
|
||||
/* Horizontal line before the final totals row */
|
||||
.round-breakdown .rb-final td {
|
||||
border-top: 2px solid var(--gold);
|
||||
font-weight: 700;
|
||||
color: var(--gold);
|
||||
}
|
||||
.round-breakdown .rb-final .rb-round { color: var(--gold); }
|
||||
|
||||
/* ── Game history list ───── */
|
||||
.history-list { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
|
||||
.history-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-left: 4px solid rgba(255,255,255,.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-item:hover { background: rgba(255,255,255,.07); }
|
||||
.history-item.won { border-left-color: #69f0ae; }
|
||||
.history-item.lost { border-left-color: #ff6b6b; }
|
||||
.history-item-main { display: flex; gap: 10px; align-items: center; justify-content: space-between; }
|
||||
.history-item-result { font-weight: 700; }
|
||||
.history-item-teams { font-size: .82rem; color: rgba(255,255,255,.75); text-align: right; }
|
||||
.history-item-meta { display: flex; justify-content: space-between; font-size: .78rem; color: var(--muted); }
|
||||
|
||||
/* ── Waiting room top row (Leave + ☰ menu) ───── */
|
||||
.waiting-top-row {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user