Compare commits
8 Commits
e0b9dde93e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1149687e3c | |||
| 3cf338d014 | |||
| 23ae455ec1 | |||
| 66a9e59db6 | |||
| ab069f2ec3 | |||
| cb664e4505 | |||
| 3c26eb4ba5 | |||
| 7c602b8e95 |
@@ -9,3 +9,5 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
TODO
|
TODO
|
||||||
|
push.sh
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shelem",
|
"name": "shelem",
|
||||||
"version": "1.0.0",
|
"version": "1.1.7",
|
||||||
"description": "Shelem card game — multiplayer",
|
"description": "Shelem card game — multiplayer",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+234
-22
@@ -19,6 +19,7 @@ let swapSelectedSeat = -1; // seat highlighted for swap in waiting room
|
|||||||
|
|
||||||
// Widow discard state
|
// Widow discard state
|
||||||
let widowSelected = [];
|
let widowSelected = [];
|
||||||
|
let widowActive = false; // true while the widow overlay is open for this WIDOW phase
|
||||||
|
|
||||||
// Bid UI state
|
// Bid UI state
|
||||||
let currentBidAmount = 85;
|
let currentBidAmount = 85;
|
||||||
@@ -234,7 +235,7 @@ function show(id) { const el = $(id); if (el) el.classList.remove('hidden'); }
|
|||||||
function hide(id) { const el = $(id); if (el) el.classList.add('hidden'); }
|
function hide(id) { const el = $(id); if (el) el.classList.add('hidden'); }
|
||||||
|
|
||||||
function cardSvg(code) {
|
function cardSvg(code) {
|
||||||
if (code === 'JOKER-COLOR') return '/cards/JOKER-1.svg';
|
if (code === 'JOKER-COLOR') return '/cards/JOKER-3.svg';
|
||||||
if (code === 'JOKER-BLACK') return '/cards/JOKER-2.svg';
|
if (code === 'JOKER-BLACK') return '/cards/JOKER-2.svg';
|
||||||
const [suit, rank] = code.split('-');
|
const [suit, rank] = code.split('-');
|
||||||
const suitMap = { C: 'CLUB', D: 'DIAMOND', H: 'HEART', S: 'SPADE' };
|
const suitMap = { C: 'CLUB', D: 'DIAMOND', H: 'HEART', S: 'SPADE' };
|
||||||
@@ -470,10 +471,21 @@ function connectSocket(onReady) {
|
|||||||
if (!socket) {
|
if (!socket) {
|
||||||
socket = io({ auth: { token: authToken } });
|
socket = io({ auth: { token: authToken } });
|
||||||
initSocketHandlers();
|
initSocketHandlers();
|
||||||
|
} else {
|
||||||
|
// Reuse the socket but make sure it carries the current auth token
|
||||||
|
socket.auth = { token: authToken };
|
||||||
}
|
}
|
||||||
socket.once('connect', onReady);
|
socket.once('connect', onReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-authenticate an existing socket after login/register so the server sees
|
||||||
|
// the user on this same connection (auth is only read at (re)connect time).
|
||||||
|
function reauthSocket() {
|
||||||
|
if (!socket) return;
|
||||||
|
socket.auth = { token: authToken };
|
||||||
|
if (socket.connected) socket.disconnect().connect();
|
||||||
|
}
|
||||||
|
|
||||||
function initSocketHandlers() {
|
function initSocketHandlers() {
|
||||||
// Persistent reconnect handler — auto-rejoin after any network drop mid-game
|
// Persistent reconnect handler — auto-rejoin after any network drop mid-game
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
@@ -554,20 +566,23 @@ function initSocketHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Session persistence (localStorage so it survives tab/app close) ──────────
|
// ─── Session persistence (localStorage so it survives tab/app close) ──────────
|
||||||
|
// NOTE: the game-session (seat) token uses its OWN key. It must never share a
|
||||||
|
// key with the auth JWT ('shelem_token'), or joining a game would overwrite the
|
||||||
|
// login token and drop the player to an unauthenticated guest.
|
||||||
function saveSession() {
|
function saveSession() {
|
||||||
localStorage.setItem('shelem_room', myRoomId);
|
localStorage.setItem('shelem_room', myRoomId);
|
||||||
localStorage.setItem('shelem_seat', mySeat);
|
localStorage.setItem('shelem_seat', mySeat);
|
||||||
localStorage.setItem('shelem_token', myToken);
|
localStorage.setItem('shelem_seat_token', myToken);
|
||||||
}
|
}
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
localStorage.removeItem('shelem_room');
|
localStorage.removeItem('shelem_room');
|
||||||
localStorage.removeItem('shelem_seat');
|
localStorage.removeItem('shelem_seat');
|
||||||
localStorage.removeItem('shelem_token');
|
localStorage.removeItem('shelem_seat_token');
|
||||||
}
|
}
|
||||||
function tryRejoin() {
|
function tryRejoin() {
|
||||||
const room = localStorage.getItem('shelem_room');
|
const room = localStorage.getItem('shelem_room');
|
||||||
const seat = localStorage.getItem('shelem_seat');
|
const seat = localStorage.getItem('shelem_seat');
|
||||||
const token = localStorage.getItem('shelem_token');
|
const token = localStorage.getItem('shelem_seat_token');
|
||||||
if (!room || seat === null || !token) return;
|
if (!room || seat === null || !token) return;
|
||||||
// Set inside the callback so the persistent 'connect' handler doesn't
|
// Set inside the callback so the persistent 'connect' handler doesn't
|
||||||
// also emit rejoin on the very first connection (would be a duplicate)
|
// also emit rejoin on the very first connection (would be a duplicate)
|
||||||
@@ -907,11 +922,20 @@ function computeLegalCards(st, seat) {
|
|||||||
|
|
||||||
// ─── Overlays ─────────────────────────────────────────────────
|
// ─── Overlays ─────────────────────────────────────────────────
|
||||||
function renderOverlays(st) {
|
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') {
|
if (st.state === 'BIDDING') {
|
||||||
renderBiddingOverlay(st);
|
renderBiddingOverlay(st);
|
||||||
} else if (st.state === 'WIDOW' && mySeat === st.declarer) {
|
} else if (widowDeclarer) {
|
||||||
renderWidowOverlay(st);
|
renderWidowOverlay(st);
|
||||||
} else if (st.state === 'WIDOW' && mySeat !== st.declarer) {
|
} else if (st.state === 'WIDOW' && mySeat !== st.declarer) {
|
||||||
$('phase-msg').textContent = `${st.names[st.declarer]} is picking up the widow…`;
|
$('phase-msg').textContent = `${st.names[st.declarer]} is picking up the widow…`;
|
||||||
@@ -922,6 +946,7 @@ function hideAllOverlays() {
|
|||||||
hide('overlay-bid');
|
hide('overlay-bid');
|
||||||
hide('overlay-widow');
|
hide('overlay-widow');
|
||||||
hide('overlay-hand');
|
hide('overlay-hand');
|
||||||
|
widowActive = false;
|
||||||
// Restore actual hand mode if we temporarily forced fan mode during bidding
|
// Restore actual hand mode if we temporarily forced fan mode during bidding
|
||||||
if (isTouchDevice()) applyHandMode();
|
if (isTouchDevice()) applyHandMode();
|
||||||
}
|
}
|
||||||
@@ -992,18 +1017,27 @@ function renderBiddingOverlay(st) {
|
|||||||
|
|
||||||
// ─── Widow overlay ────────────────────────────────────────────
|
// ─── Widow overlay ────────────────────────────────────────────
|
||||||
function renderWidowOverlay(st) {
|
function renderWidowOverlay(st) {
|
||||||
widowSelected = [];
|
|
||||||
show('overlay-widow');
|
|
||||||
|
|
||||||
const needed = st.widowSize;
|
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);
|
renderWidowHand(st);
|
||||||
updateWidowPreview(st);
|
updateWidowPreview(st);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WIDOW_SUIT_ORDER = ['C', 'D', 'H', 'S'];
|
const WIDOW_SUIT_ORDER = ['C', 'D', 'S', 'H'];
|
||||||
const WIDOW_SUIT_LABEL = { C: '♣', D: '♦', H: '♥', S: '♠' };
|
const WIDOW_SUIT_LABEL = { C: '♣', D: '♦', H: '♥', S: '♠' };
|
||||||
const WIDOW_JOKER_LABEL = { 'JOKER-COLOR': '🌈', 'JOKER-BLACK': '⚫' };
|
const WIDOW_JOKER_LABEL = { 'JOKER-COLOR': '🌈', 'JOKER-BLACK': '⚫' };
|
||||||
|
|
||||||
@@ -1145,13 +1179,16 @@ function onHandOver(st) {
|
|||||||
const isWinner = mySeat >= 0 && teamOf(mySeat) === team && delta > 0;
|
const isWinner = mySeat >= 0 && teamOf(mySeat) === team && delta > 0;
|
||||||
if (isWinner) row.classList.add('winner');
|
if (isWinner) row.classList.add('winner');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<span class="team-label">${names}</span>
|
<span class="team-label">${escHtml(names)}</span>
|
||||||
<span class="delta ${delta >= 0 ? 'pos' : 'neg'}">${delta >= 0 ? '+' : ''}${delta}</span>
|
<span class="delta ${delta >= 0 ? 'pos' : 'neg'}">${delta >= 0 ? '+' : ''}${delta}</span>
|
||||||
<span style="color:rgba(255,255,255,.6);font-size:.8rem">→ ${st.scores[team]}</span>
|
<span style="color:rgba(255,255,255,.6);font-size:.8rem">→ ${st.scores[team]}</span>
|
||||||
`;
|
`;
|
||||||
scoresEl.appendChild(row);
|
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');
|
show('overlay-hand');
|
||||||
// overlay-hand is hidden by hideAllOverlays() when the next render() fires (BIDDING state)
|
// overlay-hand is hidden by hideAllOverlays() when the next render() fires (BIDDING state)
|
||||||
}
|
}
|
||||||
@@ -1174,10 +1211,13 @@ function onGameOver(st) {
|
|||||||
.filter(Boolean).join(' & ') || `Team ${team + 1}`;
|
.filter(Boolean).join(' & ') || `Team ${team + 1}`;
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'gameover-row' + (winTeams.has(team) ? ' winner' : '');
|
row.className = 'gameover-row' + (winTeams.has(team) ? ' winner' : '');
|
||||||
row.innerHTML = `<span>${names}</span><span style="font-weight:700;color:${team===0?'var(--team-a)':'var(--team-b)'}">${st.scores[team]}</span>`;
|
row.innerHTML = `<span>${escHtml(names)}</span><span style="font-weight:700;color:${team===0?'var(--team-a)':'var(--team-b)'}">${st.scores[team]}</span>`;
|
||||||
scoresEl.appendChild(row);
|
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');
|
show('overlay-gameover');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1209,7 +1249,7 @@ async function doLogin() {
|
|||||||
localStorage.setItem('shelem_user', authUser);
|
localStorage.setItem('shelem_user', authUser);
|
||||||
hide('overlay-auth');
|
hide('overlay-auth');
|
||||||
updateAuthBar();
|
updateAuthBar();
|
||||||
if (socket) socket.auth = { token: authToken };
|
reauthSocket();
|
||||||
} catch { $('auth-login-error').textContent = 'Network error'; }
|
} catch { $('auth-login-error').textContent = 'Network error'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,6 +1271,7 @@ async function doRegister() {
|
|||||||
localStorage.setItem('shelem_user', authUser);
|
localStorage.setItem('shelem_user', authUser);
|
||||||
hide('overlay-auth');
|
hide('overlay-auth');
|
||||||
updateAuthBar();
|
updateAuthBar();
|
||||||
|
reauthSocket();
|
||||||
} catch { $('auth-reg-error').textContent = 'Network error'; }
|
} catch { $('auth-reg-error').textContent = 'Network error'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1250,10 +1291,15 @@ async function showProfile(username) {
|
|||||||
const r = await fetch(`/api/profile/${encodeURIComponent(username)}`);
|
const r = await fetch(`/api/profile/${encodeURIComponent(username)}`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!r.ok) return;
|
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-won').textContent = d.games_won;
|
||||||
|
$('stat-games-played').textContent = d.games_played;
|
||||||
$('stat-shelem').textContent = d.shelemCount || 0;
|
$('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)
|
// Change password button (only own profile)
|
||||||
if (username === authUser) show('btn-show-change-pass');
|
if (username === authUser) show('btn-show-change-pass');
|
||||||
@@ -1282,10 +1328,12 @@ async function showLeaderboard() {
|
|||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${i + 1}</td>
|
<td>${i + 1}</td>
|
||||||
<td>${row.username}</td>
|
<td>${escHtml(row.username)}</td>
|
||||||
<td>${row.score_per_game ?? '—'}</td>
|
<td>${row.win_rate != null ? row.win_rate + '%' : '—'}</td>
|
||||||
<td>${row.games_won}</td>
|
<td>${row.games_won}</td>
|
||||||
<td>${row.games_played}</td>
|
<td>${row.games_played}</td>
|
||||||
|
<td>${row.avg_score ?? '—'}</td>
|
||||||
|
<td>${row.bids_won || 0}</td>
|
||||||
<td>${row.shelemCount || 0}</td>
|
<td>${row.shelemCount || 0}</td>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
@@ -1293,6 +1341,97 @@ async function showLeaderboard() {
|
|||||||
} catch { /* ignore */ }
|
} 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() {
|
function updateAiControlBanner() {
|
||||||
if (aiControlledSeats.size === 0) {
|
if (aiControlledSeats.size === 0) {
|
||||||
hide('ai-control-banner');
|
hide('ai-control-banner');
|
||||||
@@ -1315,6 +1454,13 @@ function clearGameState() {
|
|||||||
aiControlledSeats.clear();
|
aiControlledSeats.clear();
|
||||||
hide('ai-control-banner');
|
hide('ai-control-banner');
|
||||||
hide('afk-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 ──────────────────────────────────────────────
|
// ─── Event wiring ──────────────────────────────────────────────
|
||||||
@@ -1396,6 +1542,11 @@ function wireGameEvents() {
|
|||||||
show('overlay-exit-confirm');
|
show('overlay-exit-confirm');
|
||||||
});
|
});
|
||||||
$('btn-leave-game').addEventListener('click', () => 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', () => {
|
$('btn-exit-confirm-yes').addEventListener('click', () => {
|
||||||
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||||
clearSession();
|
clearSession();
|
||||||
@@ -1472,6 +1623,16 @@ function wireGameEvents() {
|
|||||||
$('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby'));
|
$('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby'));
|
||||||
$('btn-lb-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
|
// Change password
|
||||||
$('btn-show-change-pass').addEventListener('click', () => {
|
$('btn-show-change-pass').addEventListener('click', () => {
|
||||||
$('change-pass-form').classList.toggle('hidden');
|
$('change-pass-form').classList.toggle('hidden');
|
||||||
@@ -1520,11 +1681,62 @@ function wireGameEvents() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the stored auth token before trusting it. Catches tokens that are
|
||||||
|
// expired, or corrupted by the old localStorage key collision, so we don't run
|
||||||
|
// around as a "logged in" ghost whose socket is actually an unauthenticated guest.
|
||||||
|
async function validateAuth() {
|
||||||
|
if (!authToken) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/me', { headers: { Authorization: `Bearer ${authToken}` } });
|
||||||
|
if (!r.ok) throw new Error('invalid');
|
||||||
|
const d = await r.json();
|
||||||
|
authUser = d.username;
|
||||||
|
localStorage.setItem('shelem_user', authUser);
|
||||||
|
} catch {
|
||||||
|
authToken = null; authUser = null;
|
||||||
|
localStorage.removeItem('shelem_token');
|
||||||
|
localStorage.removeItem('shelem_user');
|
||||||
|
}
|
||||||
|
updateAuthBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the running app version in the lobby footer (served from /api/config).
|
||||||
|
// Tapping it force-updates: clears the service-worker caches and reloads, so a
|
||||||
|
// phone stuck on stale cached assets can pull the latest build.
|
||||||
|
async function loadAppVersion() {
|
||||||
|
try {
|
||||||
|
const d = await fetch('/api/config').then(r => r.json());
|
||||||
|
if (d.version) $('app-version').textContent = `v${d.version} · tap to update`;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceUpdate() {
|
||||||
|
const el = $('app-version');
|
||||||
|
if (el) el.textContent = 'Updating…';
|
||||||
|
try {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(regs.map(r => r.unregister()));
|
||||||
|
}
|
||||||
|
if (window.caches) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map(k => caches.delete(k)));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
// SW gone and caches cleared — a plain reload now refetches everything fresh
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Boot ─────────────────────────────────────────────────────
|
// ─── Boot ─────────────────────────────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initLobby();
|
initLobby();
|
||||||
wireGameEvents();
|
wireGameEvents();
|
||||||
applyBarBottom();
|
applyBarBottom();
|
||||||
|
loadAppVersion();
|
||||||
|
$('app-version').addEventListener('click', forceUpdate);
|
||||||
|
|
||||||
|
// Drop any stale/corrupted auth token before we use it to connect
|
||||||
|
await validateAuth();
|
||||||
|
|
||||||
// Pre-fill name from auth
|
// Pre-fill name from auth
|
||||||
if (authUser) $('input-name').value = authUser;
|
if (authUser) $('input-name').value = authUser;
|
||||||
|
|||||||
+43
-5
@@ -91,6 +91,7 @@
|
|||||||
|
|
||||||
<p id="lobby-error" class="error-msg"></p>
|
<p id="lobby-error" class="error-msg"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="app-version" class="app-version"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══════════════ WAITING ROOM ══════════════ -->
|
<!-- ══════════════ WAITING ROOM ══════════════ -->
|
||||||
@@ -261,6 +262,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="bid-waiting-msg" class="hint" style="min-height:20px"></p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,6 +278,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="btn-confirm-discard" class="btn-primary" disabled>Confirm Discard</button>
|
<button id="btn-confirm-discard" class="btn-primary" disabled>Confirm Discard</button>
|
||||||
<p id="widow-waiting" class="hint" style="min-height:18px"></p>
|
<p id="widow-waiting" class="hint" style="min-height:18px"></p>
|
||||||
|
<button class="overlay-leave-btn" data-overlay-leave>🚪 Leave game</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,6 +299,7 @@
|
|||||||
<div class="result-icon" style="font-size:2rem">⏳</div>
|
<div class="result-icon" style="font-size:2rem">⏳</div>
|
||||||
<p id="trump-waiting-msg" class="hint"></p>
|
<p id="trump-waiting-msg" class="hint"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="overlay-leave-btn" data-overlay-leave>🚪 Leave game</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -306,6 +310,7 @@
|
|||||||
<h3 id="hand-result-title"></h3>
|
<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-result-scores" class="result-scores"></div>
|
||||||
|
<div id="hand-round-breakdown" class="round-breakdown-wrap"></div>
|
||||||
<p class="hint">Next hand starting…</p>
|
<p class="hint">Next hand starting…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,6 +321,7 @@
|
|||||||
<div class="result-icon big">🏆</div>
|
<div class="result-icon big">🏆</div>
|
||||||
<h2 id="gameover-title"></h2>
|
<h2 id="gameover-title"></h2>
|
||||||
<div id="gameover-scores" class="gameover-scores"></div>
|
<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>
|
<button id="btn-new-game" class="btn-primary">New Game</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -367,21 +373,33 @@
|
|||||||
<h2 id="profile-username" class="profile-name"></h2>
|
<h2 id="profile-username" class="profile-name"></h2>
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span class="stat-num" id="stat-games-played">—</span>
|
<span class="stat-num" id="stat-win-rate">—</span>
|
||||||
<span class="stat-label">Games Played</span>
|
<span class="stat-label">Win Rate</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span class="stat-num" id="stat-games-won">—</span>
|
<span class="stat-num" id="stat-games-won">—</span>
|
||||||
<span class="stat-label">Games Won</span>
|
<span class="stat-label">Games Won</span>
|
||||||
</div>
|
</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">
|
<div class="stat-card">
|
||||||
<span class="stat-num" id="stat-shelem">—</span>
|
<span class="stat-num" id="stat-shelem">—</span>
|
||||||
<span class="stat-label">Shelems 🃏</span>
|
<span class="stat-label">Shelems 🃏</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span class="stat-num" id="stat-total-score">—</span>
|
<span class="stat-num" id="stat-bids-won">—</span>
|
||||||
<span class="stat-label">Total Score</span>
|
<span class="stat-label">Bids Won</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="points-legend">
|
<div class="points-legend">
|
||||||
@@ -434,7 +452,7 @@
|
|||||||
<table class="lb-table">
|
<table class="lb-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="lb-body"></tbody>
|
<tbody id="lb-body"></tbody>
|
||||||
@@ -443,6 +461,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ══════════════ -->
|
<!-- ══════════════ EXIT CONFIRM ══════════════ -->
|
||||||
<div id="overlay-exit-confirm" class="overlay hidden">
|
<div id="overlay-exit-confirm" class="overlay hidden">
|
||||||
<div class="overlay-box small" style="text-align:center">
|
<div class="overlay-box small" style="text-align:center">
|
||||||
|
|||||||
+85
-2
@@ -760,6 +760,17 @@ input:focus { border-color: var(--gold); }
|
|||||||
}
|
}
|
||||||
.overlay-box.small { max-width: 320px; }
|
.overlay-box.small { max-width: 320px; }
|
||||||
.overlay-box h3 { font-size: 1.1rem; color: var(--gold); text-align: center; }
|
.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 ──────────────────────────── */
|
/* ── Bidding overlay ──────────────────────────── */
|
||||||
.bid-box {}
|
.bid-box {}
|
||||||
@@ -1022,6 +1033,78 @@ input:focus { border-color: var(--gold); }
|
|||||||
.lb-table td { border-bottom: 1px solid rgba(255,255,255,.05); }
|
.lb-table td { border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||||
.lb-table tr:hover td { background: rgba(255,255,255,.04); }
|
.lb-table tr:hover td { background: rgba(255,255,255,.04); }
|
||||||
|
|
||||||
|
/* ── App version footer ───── */
|
||||||
|
.app-version {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: .72rem;
|
||||||
|
color: rgba(255,255,255,.35);
|
||||||
|
letter-spacing: .5px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.app-version:hover { color: rgba(255,255,255,.6); text-decoration: underline; }
|
||||||
|
.app-version:active { color: var(--gold); }
|
||||||
|
|
||||||
|
/* ── 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 room top row (Leave + ☰ menu) ───── */
|
||||||
.waiting-top-row {
|
.waiting-top-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1200,8 +1283,8 @@ input:focus { border-color: var(--gold); }
|
|||||||
box-shadow: 0 6px 28px rgba(0,0,0,.8);
|
box-shadow: 0 6px 28px rgba(0,0,0,.8);
|
||||||
max-height: 55vh;
|
max-height: 55vh;
|
||||||
}
|
}
|
||||||
/* Shrink bid history on mobile so it doesn't swallow the screen */
|
/* Always 4 players, so show all four bids without an inner scroll */
|
||||||
.bid-history { max-height: 110px; }
|
.bid-history { max-height: none; }
|
||||||
.bid-box h3 { font-size: .95rem; margin-bottom: 0; }
|
.bid-box h3 { font-size: .95rem; margin-bottom: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const CACHE_NAME = 'shelem-v2';
|
const CACHE_NAME = 'shelem-v3';
|
||||||
|
|
||||||
// ── Generate all 55 card paths ──────────────────────────────────
|
// ── Generate all 55 card paths ──────────────────────────────────
|
||||||
const SUITS = ['CLUB', 'DIAMOND', 'HEART', 'SPADE'];
|
const SUITS = ['CLUB', 'DIAMOND', 'HEART', 'SPADE'];
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ const bcrypt = require('bcryptjs');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.disable('x-powered-by'); // don't advertise the tech stack
|
||||||
|
|
||||||
|
// Safe security headers (no CSP here — inline SW script + socket.io would need
|
||||||
|
// 'unsafe-inline'/wss exceptions; Cloudflare already terminates TLS/HSTS)
|
||||||
|
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);
|
const httpServer = http.createServer(app);
|
||||||
|
|
||||||
let httpsServer = null;
|
let httpsServer = null;
|
||||||
@@ -34,9 +45,10 @@ const HTTPS_PORT = parseInt(process.env.HTTPS_PORT || '4443');
|
|||||||
const DATA_DIR = path.join(__dirname, 'data');
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||||
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
|
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
|
||||||
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||||
|
const HISTORY_FILE = path.join(DATA_DIR, 'history.json');
|
||||||
|
|
||||||
function readJSON(file, def) {
|
function readJSON(file, def) {
|
||||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; }
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; }
|
||||||
@@ -51,9 +63,25 @@ let users = readJSON(USERS_FILE, []);
|
|||||||
let stats = readJSON(STATS_FILE, []);
|
let stats = readJSON(STATS_FILE, []);
|
||||||
let config = readJSON(CONFIG_FILE, { signupsOpen: true });
|
let config = readJSON(CONFIG_FILE, { signupsOpen: true });
|
||||||
|
|
||||||
function saveUsers() { writeJSON(USERS_FILE, users); }
|
let history = readJSON(HISTORY_FILE, []);
|
||||||
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); }
|
||||||
|
|
||||||
|
// ─── Stats schema migration ───────────────────────────────────
|
||||||
|
// Bumping STATS_SCHEMA_VERSION flushes the leaderboard exactly once when a
|
||||||
|
// container running the new code starts for the first time. This is how an
|
||||||
|
// already-deployed instance "activates the patch" after pulling a new image.
|
||||||
|
const STATS_SCHEMA_VERSION = 2;
|
||||||
|
if ((config.statsSchemaVersion || 0) < STATS_SCHEMA_VERSION) {
|
||||||
|
stats = [];
|
||||||
|
saveStats();
|
||||||
|
config.statsSchemaVersion = STATS_SCHEMA_VERSION;
|
||||||
|
saveConfig();
|
||||||
|
console.log(`Leaderboard flushed and migrated to stats schema v${STATS_SCHEMA_VERSION}`);
|
||||||
|
}
|
||||||
|
|
||||||
function isAdmin(user) {
|
function isAdmin(user) {
|
||||||
return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME;
|
return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME;
|
||||||
@@ -78,7 +106,7 @@ function findUser(username) {
|
|||||||
function getStats(userId) {
|
function getStats(userId) {
|
||||||
let s = stats.find(s => s.userId === userId);
|
let s = stats.find(s => s.userId === userId);
|
||||||
if (!s) {
|
if (!s) {
|
||||||
s = { userId, games_played: 0, games_won: 0, shelemCount: 0, total_score: 0 };
|
s = { userId, games_played: 0, games_won: 0, shelemCount: 0, bids_won: 0, total_score: 0 };
|
||||||
stats.push(s);
|
stats.push(s);
|
||||||
saveStats();
|
saveStats();
|
||||||
}
|
}
|
||||||
@@ -90,6 +118,7 @@ function addStats(userId, delta) {
|
|||||||
s.games_played = (s.games_played || 0) + (delta.games_played || 0);
|
s.games_played = (s.games_played || 0) + (delta.games_played || 0);
|
||||||
s.games_won = (s.games_won || 0) + (delta.games_won || 0);
|
s.games_won = (s.games_won || 0) + (delta.games_won || 0);
|
||||||
s.shelemCount = (s.shelemCount || 0) + (delta.shelemCount || 0);
|
s.shelemCount = (s.shelemCount || 0) + (delta.shelemCount || 0);
|
||||||
|
s.bids_won = (s.bids_won || 0) + (delta.bids_won || 0);
|
||||||
s.total_score = (s.total_score || 0) + (delta.total_score || 0);
|
s.total_score = (s.total_score || 0) + (delta.total_score || 0);
|
||||||
saveStats();
|
saveStats();
|
||||||
}
|
}
|
||||||
@@ -114,8 +143,16 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// ─── Auth API ─────────────────────────────────────────────────
|
// ─── Auth API ─────────────────────────────────────────────────
|
||||||
|
const APP_VERSION = require('./package.json').version;
|
||||||
|
|
||||||
app.get('/api/config', (_req, res) => {
|
app.get('/api/config', (_req, res) => {
|
||||||
res.json({ signupsOpen: config.signupsOpen });
|
res.json({ signupsOpen: config.signupsOpen, version: APP_VERSION });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate the stored auth token; the client uses this at startup to detect a
|
||||||
|
// stale/corrupted token and drop the fake "logged in" state.
|
||||||
|
app.get('/api/me', requireAuth, (req, res) => {
|
||||||
|
res.json({ id: req.user.id, username: req.user.username });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/register', (req, res) => {
|
app.post('/api/register', (req, res) => {
|
||||||
@@ -176,12 +213,39 @@ app.get('/api/profile/:username', (req, res) => {
|
|||||||
games_played: s.games_played || 0,
|
games_played: s.games_played || 0,
|
||||||
games_won: s.games_won || 0,
|
games_won: s.games_won || 0,
|
||||||
shelemCount: s.shelemCount || 0,
|
shelemCount: s.shelemCount || 0,
|
||||||
|
bids_won: s.bids_won || 0,
|
||||||
total_score: s.total_score || 0,
|
total_score: s.total_score || 0,
|
||||||
|
win_rate: (s.games_played || 0) > 0 ? +(((s.games_won || 0) / s.games_played) * 100).toFixed(1) : 0,
|
||||||
|
avg_score: (s.games_played || 0) > 0 ? +(((s.total_score || 0) / s.games_played)).toFixed(1) : 0,
|
||||||
isAdmin: isAdmin(user),
|
isAdmin: isAdmin(user),
|
||||||
signupsOpen: config.signupsOpen,
|
signupsOpen: config.signupsOpen,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 myTeam = me ? me.team : -1;
|
||||||
|
return {
|
||||||
|
id: g.id,
|
||||||
|
endedAt: g.endedAt,
|
||||||
|
teamNames: g.teamNames,
|
||||||
|
finalScores: g.finalScores,
|
||||||
|
winnerTeams: g.winnerTeams,
|
||||||
|
won: myTeam >= 0 && g.winnerTeams.includes(myTeam),
|
||||||
|
myTeam,
|
||||||
|
rounds: g.rounds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json(games);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/leaderboard', (_req, res) => {
|
app.get('/api/leaderboard', (_req, res) => {
|
||||||
const allUsers = [...users];
|
const allUsers = [...users];
|
||||||
if (SHARED_USERS_FILE) {
|
if (SHARED_USERS_FILE) {
|
||||||
@@ -194,24 +258,28 @@ app.get('/api/leaderboard', (_req, res) => {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
const rows = allUsers.map(u => {
|
const rows = allUsers.map(u => {
|
||||||
const eid = u._fromShared ? `hokm_${u.id}` : u.id;
|
const eid = u._fromShared ? `hokm_${u.id}` : u.id;
|
||||||
const s = getStats(eid);
|
const s = getStats(eid);
|
||||||
const played = s.games_played || 0;
|
const played = s.games_played || 0;
|
||||||
|
const won = s.games_won || 0;
|
||||||
return {
|
return {
|
||||||
username: u.username,
|
username: u.username,
|
||||||
games_played: played,
|
games_played: played,
|
||||||
games_won: s.games_won || 0,
|
games_won: won,
|
||||||
shelemCount: s.shelemCount || 0,
|
shelemCount: s.shelemCount || 0,
|
||||||
|
bids_won: s.bids_won || 0,
|
||||||
total_score: s.total_score || 0,
|
total_score: s.total_score || 0,
|
||||||
score_per_game: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : null,
|
// Win rate is the primary ranking metric (percentage of games won)
|
||||||
|
win_rate: played > 0 ? +((won / played) * 100).toFixed(1) : null,
|
||||||
|
avg_score: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(r => r.games_played > 0)
|
.filter(r => r.games_played > 0)
|
||||||
.sort((a, b) => {
|
.sort((a, b) =>
|
||||||
if (a.score_per_game === null) return 1;
|
(b.win_rate - a.win_rate) || // primary: win rate
|
||||||
if (b.score_per_game === null) return -1;
|
(b.avg_score - a.avg_score) || // tie-break: average score
|
||||||
return b.score_per_game - a.score_per_game || b.games_played - a.games_played;
|
(b.games_played - a.games_played)
|
||||||
})
|
)
|
||||||
.slice(0, 30);
|
.slice(0, 30);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
@@ -294,7 +362,8 @@ function trumpRank(card) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Suit display order: C D H S JOKER
|
// Suit display order: C D H S JOKER
|
||||||
const SUIT_ORD = { C: 0, D: 1, H: 2, S: 3, JOKER: 4 };
|
// Alternate colours (black-red-black-red) so same-colour suits aren't adjacent
|
||||||
|
const SUIT_ORD = { C: 0, D: 1, S: 2, H: 3, JOKER: 4 };
|
||||||
function sortCards(hand) {
|
function sortCards(hand) {
|
||||||
return [...hand].sort((a, b) => {
|
return [...hand].sort((a, b) => {
|
||||||
const sa = SUIT_ORD[suitOf(a)], sb = SUIT_ORD[suitOf(b)];
|
const sa = SUIT_ORD[suitOf(a)], sb = SUIT_ORD[suitOf(b)];
|
||||||
@@ -386,6 +455,9 @@ function newRoom(id) {
|
|||||||
handDeltas: null,
|
handDeltas: null,
|
||||||
isShelemHand: false,
|
isShelemHand: false,
|
||||||
gameShelemsCount: 0,
|
gameShelemsCount: 0,
|
||||||
|
shelemsByTeam: [0, 0], // shelems achieved per team this game
|
||||||
|
bidsWonBySeat: [0, 0, 0, 0], // hands where each seat was the declarer
|
||||||
|
roundHistory: [], // per-hand record for the end-of-game breakdown
|
||||||
// Accumulated game scores (per team)
|
// Accumulated game scores (per team)
|
||||||
scores: [0, 0],
|
scores: [0, 0],
|
||||||
gameWinner: null,
|
gameWinner: null,
|
||||||
@@ -440,6 +512,11 @@ function publicInfo(room, seat) {
|
|||||||
winScore: room.winScore,
|
winScore: room.winScore,
|
||||||
isPublic: room.isPublic,
|
isPublic: room.isPublic,
|
||||||
spectatorCount: room.spectators.size,
|
spectatorCount: room.spectators.size,
|
||||||
|
roundHistory: room.roundHistory,
|
||||||
|
teamNames: [
|
||||||
|
[room.names[0], room.names[2]].filter(Boolean).join(' & ') || 'Team 1',
|
||||||
|
[room.names[1], room.names[3]].filter(Boolean).join(' & ') || 'Team 2',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,7 +685,11 @@ function onCardPlayed(room, player, card) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trick complete
|
// Trick complete — no one is "on turn" during the resolution window. This
|
||||||
|
// prevents the player who completed the trick from sneaking a second card in
|
||||||
|
// before the trick-advance timer fires (which would desync hand counts).
|
||||||
|
room.currentTurn = -1;
|
||||||
|
|
||||||
const winner = trickWinner(room.trick, room.trump);
|
const winner = trickWinner(room.trick, room.trump);
|
||||||
const winnerTeam = teamOf(winner);
|
const winnerTeam = teamOf(winner);
|
||||||
const trickPts = room.trick.reduce((s, t) => s + cardPoints(t.card), 0);
|
const trickPts = room.trick.reduce((s, t) => s + cardPoints(t.card), 0);
|
||||||
@@ -656,6 +737,7 @@ function finishHand(room) {
|
|||||||
oDelta = 0;
|
oDelta = 0;
|
||||||
room.isShelemHand = true;
|
room.isShelemHand = true;
|
||||||
room.gameShelemsCount++;
|
room.gameShelemsCount++;
|
||||||
|
room.shelemsByTeam[dTeam]++;
|
||||||
} else if (dPts >= room.highBid) {
|
} else if (dPts >= room.highBid) {
|
||||||
// Made the bid — score exactly what was bid, not actual points earned
|
// Made the bid — score exactly what was bid, not actual points earned
|
||||||
dDelta = room.highBid;
|
dDelta = room.highBid;
|
||||||
@@ -666,12 +748,29 @@ function finishHand(room) {
|
|||||||
oDelta = oPts;
|
oDelta = oPts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The declarer won this hand's bidding
|
||||||
|
if (room.declarer >= 0) room.bidsWonBySeat[room.declarer]++;
|
||||||
|
|
||||||
room.handDeltas = [0, 0];
|
room.handDeltas = [0, 0];
|
||||||
room.handDeltas[dTeam] = dDelta;
|
room.handDeltas[dTeam] = dDelta;
|
||||||
room.handDeltas[oTeam] = oDelta;
|
room.handDeltas[oTeam] = oDelta;
|
||||||
room.scores[0] += room.handDeltas[0];
|
room.scores[0] += room.handDeltas[0];
|
||||||
room.scores[1] += room.handDeltas[1];
|
room.scores[1] += room.handDeltas[1];
|
||||||
|
|
||||||
|
// Record this round for the end-of-game / history breakdown
|
||||||
|
room.roundHistory.push({
|
||||||
|
hand: room.roundHistory.length + 1,
|
||||||
|
declarer: room.declarer,
|
||||||
|
declarerName: room.names[room.declarer] || '',
|
||||||
|
declarerTeam: dTeam,
|
||||||
|
bid: room.highBid,
|
||||||
|
trump: room.trump,
|
||||||
|
deltas: [room.handDeltas[0], room.handDeltas[1]],
|
||||||
|
scores: [room.scores[0], room.scores[1]],
|
||||||
|
shelem: room.isShelemHand,
|
||||||
|
made: dDelta > 0,
|
||||||
|
});
|
||||||
|
|
||||||
if (room.scores.some(s => s >= room.winScore)) {
|
if (room.scores.some(s => s >= room.winScore)) {
|
||||||
finishGame(room);
|
finishGame(room);
|
||||||
return;
|
return;
|
||||||
@@ -686,7 +785,7 @@ function finishHand(room) {
|
|||||||
room.dealer = (room.dealer + 1) % 4; // counter-clockwise rotation
|
room.dealer = (room.dealer + 1) % 4; // counter-clockwise rotation
|
||||||
dealHand(room);
|
dealHand(room);
|
||||||
startBidding(room);
|
startBidding(room);
|
||||||
}, 4500);
|
}, 15000); // show the round result + running scores for 15s before the next hand
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishGame(room) {
|
function finishGame(room) {
|
||||||
@@ -698,6 +797,8 @@ function finishGame(room) {
|
|||||||
room.state = 'GAME_OVER';
|
room.state = 'GAME_OVER';
|
||||||
broadcastState(room, 'gameOver');
|
broadcastState(room, 'gameOver');
|
||||||
|
|
||||||
|
// Games involving bots (incl. seats a human abandoned mid-game) are not
|
||||||
|
// ranked and are not written to history, to keep the leaderboard clean.
|
||||||
if (room.bots.some(Boolean)) return;
|
if (room.bots.some(Boolean)) return;
|
||||||
|
|
||||||
const winnerTeams = new Set(room.gameWinner);
|
const winnerTeams = new Set(room.gameWinner);
|
||||||
@@ -708,10 +809,35 @@ function finishGame(room) {
|
|||||||
addStats(uid, {
|
addStats(uid, {
|
||||||
games_played: 1,
|
games_played: 1,
|
||||||
games_won: winnerTeams.has(team) ? 1 : 0,
|
games_won: winnerTeams.has(team) ? 1 : 0,
|
||||||
shelemCount: room.gameShelemsCount,
|
shelemCount: room.shelemsByTeam[team], // only this player's team's shelems
|
||||||
|
bids_won: room.bidsWonBySeat[seat], // hands this player declared
|
||||||
total_score: room.scores[team],
|
total_score: room.scores[team],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveGameHistory(room, winnerTeams);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist a finished game so each participant can review it later.
|
||||||
|
function saveGameHistory(room, winnerTeams) {
|
||||||
|
const record = {
|
||||||
|
id: `${room.id}-${Date.now()}`,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
winScore: room.winScore,
|
||||||
|
jokerMode: room.jokerMode,
|
||||||
|
finalScores: [room.scores[0], room.scores[1]],
|
||||||
|
winnerTeams: [...winnerTeams],
|
||||||
|
teamNames: [
|
||||||
|
[room.names[0], room.names[2]].filter(Boolean).join(' & ') || 'Team 1',
|
||||||
|
[room.names[1], room.names[3]].filter(Boolean).join(' & ') || 'Team 2',
|
||||||
|
],
|
||||||
|
players: room.userIds
|
||||||
|
.map((uid, seat) => ({ uid, seat, name: room.names[seat], team: teamOf(seat) }))
|
||||||
|
.filter(p => p.uid != null),
|
||||||
|
rounds: room.roundHistory,
|
||||||
|
};
|
||||||
|
history.push(record);
|
||||||
|
saveHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── AFK timer ────────────────────────────────────────────────
|
// ─── AFK timer ────────────────────────────────────────────────
|
||||||
@@ -773,10 +899,38 @@ function scheduleAfkTimer(room) {
|
|||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Room teardown / leave recovery ───────────────────────────
|
||||||
|
function destroyRoom(room) {
|
||||||
|
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||||
|
if (room.afkTimer) clearTimeout(room.afkTimer);
|
||||||
|
rooms.delete(room.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a human leaves an in-progress game their seat becomes a bot; nudge the
|
||||||
|
// current phase so that bot acts if the game is now waiting on that seat.
|
||||||
|
function resumeAfterLeave(room, seat) {
|
||||||
|
if (room.state === 'BIDDING') {
|
||||||
|
scheduleBotBid(room);
|
||||||
|
} else if (room.state === 'WIDOW') {
|
||||||
|
if (room.declarer === seat && room.bots[seat]) {
|
||||||
|
setTimeout(() => botDiscard(room, seat), 900);
|
||||||
|
}
|
||||||
|
} else if (room.state === 'PLAYING') {
|
||||||
|
scheduleAfkTimer(room);
|
||||||
|
scheduleBotPlay(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Game init ────────────────────────────────────────────────
|
// ─── Game init ────────────────────────────────────────────────
|
||||||
function tryStartGame(room) {
|
function tryStartGame(room) {
|
||||||
const filled = room.seats.map((s, i) => !!s || room.bots[i]);
|
const filled = room.seats.map((s, i) => !!s || room.bots[i]);
|
||||||
if (!filled.every(Boolean)) return;
|
if (!filled.every(Boolean)) return;
|
||||||
|
// Reset game-level accumulators for a fresh game
|
||||||
|
room.scores = [0, 0];
|
||||||
|
room.gameShelemsCount = 0;
|
||||||
|
room.shelemsByTeam = [0, 0];
|
||||||
|
room.bidsWonBySeat = [0, 0, 0, 0];
|
||||||
|
room.roundHistory = [];
|
||||||
dealHand(room);
|
dealHand(room);
|
||||||
startBidding(room);
|
startBidding(room);
|
||||||
}
|
}
|
||||||
@@ -1111,6 +1265,9 @@ io.on('connection', (socket) => {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
if (room.state !== 'PLAYING') return;
|
if (room.state !== 'PLAYING') return;
|
||||||
if (room.tokens[seat] !== token) return;
|
if (room.tokens[seat] !== token) return;
|
||||||
|
// Reject if a trick is already complete and waiting to resolve, or if it is
|
||||||
|
// not this seat's live turn — guards against a double card being injected.
|
||||||
|
if (room.trick.length >= 4) return socket.emit('playError', 'Trick resolving');
|
||||||
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
|
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
|
||||||
if (!legalCards(room, seat).includes(card)) return socket.emit('playError', 'Illegal card');
|
if (!legalCards(room, seat).includes(card)) return socket.emit('playError', 'Illegal card');
|
||||||
// Player acted themselves — release AI control
|
// Player acted themselves — release AI control
|
||||||
@@ -1151,12 +1308,32 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('leave', ({ roomId, seat, token } = {}) => {
|
socket.on('leave', ({ roomId, seat, token } = {}) => {
|
||||||
const room = rooms.get((roomId || '').toUpperCase());
|
const room = rooms.get((roomId || '').toUpperCase());
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
if (room.tokens[seat] === token) {
|
if (room.tokens[seat] !== token) { socket.leave(room.id); return; }
|
||||||
room.seats[seat] = null;
|
|
||||||
room.tokens[seat] = null;
|
|
||||||
room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in
|
|
||||||
}
|
|
||||||
socket.leave(room.id);
|
socket.leave(room.id);
|
||||||
|
|
||||||
|
// Vacate the seat
|
||||||
|
room.seats[seat] = null;
|
||||||
|
room.tokens[seat] = null;
|
||||||
|
room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in
|
||||||
|
room.aiControlledSeats.delete(seat);
|
||||||
|
|
||||||
|
const inProgress = room.state !== 'WAITING' && room.state !== 'GAME_OVER';
|
||||||
|
|
||||||
|
// If no humans remain, tear the room down entirely
|
||||||
|
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 {
|
||||||
|
broadcastState(room, 'roomInfo');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user