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:
goyban
2026-07-05 12:29:16 +00:00
parent e0b9dde93e
commit 7c602b8e95
5 changed files with 452 additions and 41 deletions
+157 -11
View File
@@ -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');