Compare commits

6 Commits

Author SHA1 Message Date
goyban 66a9e59db6 Add tap-to-update button on version footer to force-refresh cached assets
Phones stuck on stale service-worker caches (esp. iOS home-screen PWAs) could
show the new version string while still running old app.js/css. Tapping the
footer now unregisters the service worker, clears all caches, and reloads to
pull the latest build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:07:16 +00:00
goyban ab069f2ec3 Show all 4 bids on mobile during bidding (remove inner scroll cap)
The mobile bid-history was capped at 110px, hiding the 4th player's bid behind
a scroll even though the panel had room. There are always exactly 4 players, so
show all four rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:54:50 +00:00
goyban cb664e4505 Show app version in lobby footer; expose version via /api/config
- Server reads version from package.json and returns it from /api/config
- Client fetches it at boot and renders it in a lobby footer
- Bump service-worker cache to shelem-v3 so precache refreshes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:46:13 +00:00
goyban 3c26eb4ba5 Fix auth token being clobbered by game seat token (userId always null)
The login JWT and the per-seat rejoin token shared the localStorage key
'shelem_token', so joining a game overwrote the auth token with the random
seat token. The socket then reconnected as an unauthenticated guest, so every
seat was recorded with userId=null and no stats or history were attributed.

- Seat token now uses its own key 'shelem_seat_token'; 'shelem_token' is auth-only
- Add GET /api/me + boot-time validateAuth() to drop stale/corrupted tokens
  instead of running as a "logged in" ghost
- reauthSocket() re-authenticates the live socket on login/register

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:29:56 +00:00
goyban 7c602b8e95 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>
2026-07-05 12:29:16 +00:00
goyban e0b9dde93e Fully containerized 2026-05-24 15:59:24 +00:00
10 changed files with 878 additions and 70 deletions
+3
View File
@@ -8,3 +8,6 @@ data/
node_modules/ node_modules/
npm-debug.log* npm-debug.log*
TODO
push.sh
CLAUDE.md
View File
+15
View File
@@ -0,0 +1,15 @@
services:
shelem:
container_name: shelem
image: git.goyban.com/goyban/shelem:latest
pull_policy: missing
ports:
- "4001:4000"
- "4444:4443"
restart: unless-stopped
volumes:
- ./data:/app/data
- /root/hokm/data:/hokm-data:ro
- /root/hearts/public/cards:/app/public/cards:ro
env_file:
- .env
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shelem", "name": "shelem",
"version": "1.0.0", "version": "1.1.4",
"description": "Shelem card game — multiplayer", "description": "Shelem card game — multiplayer",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+319 -35
View File
@@ -19,14 +19,22 @@ 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;
let lastBidHandNumber = -1; let lastBidHandNumber = -1;
// Turn reminder state // Turn reminder / escalation state
let turnReminderTimer = null; let turnReminderTimer = null;
let turnReminderActive = false; let turnReminderActive = false;
let turnEscalationLevel = 0;
// AFK state
let afkBannerVoted = false;
// AI control state (which seats are AI-controlled)
let aiControlledSeats = new Set();
// Unlock Web Audio on first user gesture (required by iOS) // Unlock Web Audio on first user gesture (required by iOS)
let _audioCtx = null; let _audioCtx = null;
@@ -57,23 +65,36 @@ function playTurnChime() {
} catch (e) { /* audio not available */ } } catch (e) { /* audio not available */ }
} }
function applyTurnEscalation(level) {
const pm = $('phase-msg');
if (!pm) return;
pm.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3');
if (level > 0) pm.classList.add(`your-turn-lvl${level}`);
}
function handleTurnReminder(isMyTurn) { function handleTurnReminder(isMyTurn) {
if (isMyTurn && !turnReminderActive) { if (isMyTurn && !turnReminderActive) {
turnReminderActive = true; turnReminderActive = true;
turnEscalationLevel = 0;
$('my-area')?.classList.remove('turn-urgent'); $('my-area')?.classList.remove('turn-urgent');
turnReminderTimer = setTimeout(() => { applyTurnEscalation(0);
$('my-area')?.classList.add('turn-urgent'); // Escalate every 5 seconds: level 1 → 2 → 3 (vibrate/chime at level 3)
// Vibrate on Android; chime on iOS (vibrate not supported there) turnReminderTimer = setInterval(() => {
if (navigator.vibrate) { turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3);
navigator.vibrate([300, 100, 300]); applyTurnEscalation(turnEscalationLevel);
} else { if (turnEscalationLevel >= 3) {
playTurnChime(); $('my-area')?.classList.add('turn-urgent');
if (navigator.vibrate) navigator.vibrate([300, 100, 300]);
else playTurnChime();
clearInterval(turnReminderTimer);
turnReminderTimer = null;
} }
}, 5000); }, 5000);
} else if (!isMyTurn && (turnReminderActive || turnReminderTimer)) { } else if (!isMyTurn && turnReminderActive) {
clearTimeout(turnReminderTimer); if (turnReminderTimer) { clearInterval(turnReminderTimer); turnReminderTimer = null; }
turnReminderTimer = null; turnReminderActive = false;
turnReminderActive = false; turnEscalationLevel = 0;
applyTurnEscalation(0);
$('my-area')?.classList.remove('turn-urgent'); $('my-area')?.classList.remove('turn-urgent');
} }
} }
@@ -90,7 +111,8 @@ function loadPlayMode() {
function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); } function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); }
function loadHandMode() { function loadHandMode() {
const s = localStorage.getItem('shelem_hand_mode'); const s = localStorage.getItem('shelem_hand_mode');
return ['scroll','fan','playables'].includes(s) ? s : 'scroll'; if (['scroll','fan','playables'].includes(s)) return s;
return isTouchDevice() ? 'fan' : 'scroll';
} }
function saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); } function saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); }
function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; } function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; }
@@ -449,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', () => {
@@ -512,24 +545,44 @@ function initSocketHandlers() {
socket.on('handOver', onHandOver); socket.on('handOver', onHandOver);
socket.on('gameOver', onGameOver); socket.on('gameOver', onGameOver);
socket.on('afkWarning', ({ name } = {}) => {
afkBannerVoted = false;
$('afk-banner-msg').textContent = `${name} hasn't played for 1 minute.`;
const btn = $('afk-vote-btn');
if (btn) { btn.disabled = false; btn.textContent = 'Let AI Play'; }
show('afk-banner');
});
socket.on('afkResolved', () => {
afkBannerVoted = false;
hide('afk-banner');
});
socket.on('aiControl', ({ seat, active, name } = {}) => {
if (active) aiControlledSeats.add(seat);
else aiControlledSeats.delete(seat);
updateAiControlBanner();
});
socket.on('disconnect', () => {}); socket.on('disconnect', () => {});
} }
// ─── 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)
@@ -641,7 +694,7 @@ function renderInfoBar(st) {
const td = $('trump-display'); const td = $('trump-display');
const bd = $('bid-display'); const bd = $('bid-display');
if (st.trump) { if (st.trump) {
td.textContent = `Trump: ${suitSymbol(st.trump)} ${suitName(st.trump)}`; td.textContent = `Hokm:\n${suitSymbol(st.trump)}`;
show('trump-display'); show('trump-display');
} else { } else {
hide('trump-display'); hide('trump-display');
@@ -869,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…`;
@@ -884,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();
} }
@@ -954,12 +1017,21 @@ 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);
@@ -1114,6 +1186,9 @@ function onHandOver(st) {
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)
} }
@@ -1140,6 +1215,9 @@ function onGameOver(st) {
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');
} }
@@ -1171,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'; }
} }
@@ -1193,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'; }
} }
@@ -1212,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');
@@ -1244,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);
@@ -1255,6 +1341,128 @@ 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() {
if (aiControlledSeats.size === 0) {
hide('ai-control-banner');
return;
}
const isMe = aiControlledSeats.has(mySeat);
let msg;
if (isMe) {
msg = '🤖 AI is playing for you! Play a card to take back control.';
} else {
const names = [...aiControlledSeats].map(s => lastState?.names[s] || `Seat ${s + 1}`);
msg = `🤖 AI is playing for: ${names.join(', ')}`;
}
$('ai-control-msg').textContent = msg;
$('ai-control-banner').classList.toggle('is-me', isMe);
show('ai-control-banner');
}
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 ────────────────────────────────────────────── // ─── Event wiring ──────────────────────────────────────────────
function wireGameEvents() { function wireGameEvents() {
// Waiting room // Waiting room
@@ -1334,9 +1542,15 @@ 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();
clearGameState();
hide('overlay-exit-confirm'); hide('overlay-exit-confirm');
hide('overlay-hand'); hide('overlay-hand');
hide('overlay-gameover'); hide('overlay-gameover');
@@ -1409,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');
@@ -1432,6 +1656,15 @@ function wireGameEvents() {
} catch { $('change-pass-msg').textContent = 'Network error'; } } catch { $('change-pass-msg').textContent = 'Network error'; }
}); });
$('afk-vote-btn')?.addEventListener('click', () => {
if (afkBannerVoted) return;
afkBannerVoted = true;
socket?.emit('voteAITakeover', { roomId: myRoomId, seat: mySeat, token: myToken });
const btn = $('afk-vote-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Voted ✓'; }
});
$('afk-dismiss-btn')?.addEventListener('click', () => hide('afk-banner'));
$('btn-toggle-signups').addEventListener('click', async () => { $('btn-toggle-signups').addEventListener('click', async () => {
try { try {
const r = await fetch('/api/admin/toggle-signups', { const r = await fetch('/api/admin/toggle-signups', {
@@ -1448,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;
+55 -5
View File
@@ -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 ══════════════ -->
@@ -166,6 +167,18 @@
<!-- Spectator banner --> <!-- Spectator banner -->
<div id="spectator-banner" class="spectator-banner hidden">👁 Spectating — watching only</div> <div id="spectator-banner" class="spectator-banner hidden">👁 Spectating — watching only</div>
<!-- AFK banner -->
<div id="afk-banner" class="afk-banner hidden">
<span id="afk-banner-msg"></span>
<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 -->
<div id="ai-control-banner" class="ai-control-banner hidden">
<span id="ai-control-msg"></span>
</div>
<!-- Table grid --> <!-- Table grid -->
<div id="table-grid"> <div id="table-grid">
@@ -249,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>
@@ -264,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>
@@ -284,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>
@@ -294,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>
@@ -304,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>
@@ -355,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">
@@ -422,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>
@@ -431,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">
+147 -3
View File
@@ -338,13 +338,17 @@ input:focus { border-color: var(--gold); }
gap: 8px; gap: 8px;
flex: 1; flex: 1;
justify-content: center; justify-content: center;
min-width: 0;
flex-wrap: wrap;
} }
.team-score { .team-score {
font-size: .78rem; font-size: .78rem;
font-weight: 700; font-weight: 700;
padding: 2px 10px; padding: 2px 10px;
border-radius: 20px; border-radius: 20px;
white-space: nowrap; word-break: break-word;
text-align: center;
min-width: 0;
} }
.team-score.team-a { background: rgba(79,195,247,.2); border: 1px solid rgba(79,195,247,.4); color: var(--team-a); } .team-score.team-a { background: rgba(79,195,247,.2); border: 1px solid rgba(79,195,247,.4); color: var(--team-a); }
.team-score.team-b { background: rgba(239,154,154,.2); border: 1px solid rgba(239,154,154,.4); color: var(--team-b); } .team-score.team-b { background: rgba(239,154,154,.2); border: 1px solid rgba(239,154,154,.4); color: var(--team-b); }
@@ -357,6 +361,9 @@ input:focus { border-color: var(--gold); }
background: rgba(245,197,24,.2); background: rgba(245,197,24,.2);
border: 1px solid rgba(245,197,24,.4); border: 1px solid rgba(245,197,24,.4);
color: var(--gold); color: var(--gold);
white-space: pre;
text-align: center;
line-height: 1.2;
} }
.bid-display { .bid-display {
font-size: .75rem; font-size: .75rem;
@@ -610,6 +617,26 @@ input:focus { border-color: var(--gold); }
from { opacity: .7; } from { opacity: .7; }
to { opacity: 1; } to { opacity: 1; }
} }
/* Escalating urgency — applied every 5 s while it's your turn */
.phase-msg.your-turn-lvl1 {
font-size: .96rem;
color: #ffc400;
animation: your-turn-flash .6s ease-in-out infinite alternate;
}
.phase-msg.your-turn-lvl2 {
font-size: 1.18rem;
color: #ffeb3b;
font-weight: 800;
text-shadow: 0 0 10px rgba(255, 220, 0, 0.6);
animation: your-turn-flash .4s ease-in-out infinite alternate;
}
.phase-msg.your-turn-lvl3 {
font-size: 1.4rem;
color: #fff;
font-weight: 800;
text-shadow: 0 0 22px rgba(255, 200, 0, 1), 0 0 6px rgba(255,255,255,.8);
animation: your-turn-flash .25s ease-in-out infinite alternate;
}
/* Drop-zone pulse when dragging */ /* Drop-zone pulse when dragging */
#trick-area { position: relative; } #trick-area { position: relative; }
@@ -733,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 {}
@@ -995,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;
@@ -1026,6 +1136,40 @@ input:focus { border-color: var(--gold); }
flex-shrink: 0; flex-shrink: 0;
} }
/* ── AFK banner ───────────────────────────────── */
.afk-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: rgba(200, 100, 0, 0.28);
border-bottom: 1px solid rgba(200, 120, 0, 0.45);
font-size: .82rem;
flex-shrink: 0;
flex-wrap: wrap;
}
.afk-banner.hidden { display: none !important; }
/* ── AI control banner ────────────────────────── */
.ai-control-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
background: rgba(80, 0, 180, 0.3);
border-bottom: 1px solid rgba(120, 60, 220, 0.5);
font-size: .8rem;
flex-shrink: 0;
color: #ce93d8;
}
.ai-control-banner.is-me {
background: rgba(180, 0, 100, 0.35);
border-bottom-color: rgba(220, 60, 140, 0.55);
color: #f48fb1;
font-weight: 700;
}
.ai-control-banner.hidden { display: none !important; }
/* ── Util ─────────────────────────────────────── */ /* ── Util ─────────────────────────────────────── */
.hidden { display: none !important; } .hidden { display: none !important; }
@@ -1139,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
View File
@@ -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'];
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
REGISTRY="git.goyban.com"
USER="goyban"
IMAGE="shelem"
VERSION="${1:-latest}" # pass version as argument, e.g. ./push.sh v1.0.1
BASE="${REGISTRY}/${USER}/${IMAGE}"
echo "▶ Building ${BASE}:${VERSION} ..."
docker build -t "${BASE}:${VERSION}" .
if [ "${VERSION}" != "latest" ]; then
echo "▶ Tagging as latest ..."
docker tag "${BASE}:${VERSION}" "${BASE}:latest"
fi
echo "▶ Pushing ${BASE}:${VERSION} ..."
docker push "${BASE}:${VERSION}"
if [ "${VERSION}" != "latest" ]; then
echo "▶ Pushing ${BASE}:latest ..."
docker push "${BASE}:latest"
fi
echo "✓ Done: ${BASE}:${VERSION} + ${BASE}:latest"
+309 -25
View File
@@ -34,9 +34,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 +52,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 +95,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 +107,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 +132,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 +202,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 +247,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);
}); });
@@ -386,6 +443,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,
@@ -394,6 +454,13 @@ function newRoom(id) {
winScore: 505, winScore: 505,
spectators: new Set(), spectators: new Set(),
trickTimer: null, trickTimer: null,
// AFK tracking
afkTimer: null,
afkSeat: -1,
afkVotes: new Set(),
// AI control (persistent per seat until player acts)
aiControlledSeats: new Set(),
createdAt: Date.now(),
}; };
} }
@@ -433,6 +500,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',
],
}; };
} }
@@ -468,6 +540,9 @@ function dealHand(room) {
// ─── Bidding ────────────────────────────────────────────────── // ─── Bidding ──────────────────────────────────────────────────
function startBidding(room) { function startBidding(room) {
clearAfkTimer(room);
for (const seat of room.aiControlledSeats) broadcastAiControl(room, seat, false);
room.aiControlledSeats.clear();
room.state = 'BIDDING'; room.state = 'BIDDING';
// Bidding starts at right of dealer (counter-clockwise first seat) // Bidding starts at right of dealer (counter-clockwise first seat)
room.currentBidder = (room.dealer + 3) % 4; room.currentBidder = (room.dealer + 3) % 4;
@@ -577,10 +652,13 @@ function startPlaying(room) {
room.currentTurn = room.declarer; room.currentTurn = room.declarer;
room.trick = []; room.trick = [];
broadcastState(room, 'roomInfo'); broadcastState(room, 'roomInfo');
scheduleAfkTimer(room);
scheduleBotPlay(room); scheduleBotPlay(room);
} }
function onCardPlayed(room, player, card) { function onCardPlayed(room, player, card) {
clearAfkTimer(room);
// First card played by the declarer sets trump (jokers excluded by legalCards) // First card played by the declarer sets trump (jokers excluded by legalCards)
if (room.trump === null) room.trump = suitOf(card); if (room.trump === null) room.trump = suitOf(card);
@@ -590,11 +668,16 @@ function onCardPlayed(room, player, card) {
if (room.trick.length < 4) { if (room.trick.length < 4) {
room.currentTurn = (player + 3) % 4; // anti-clockwise: next player is to the right room.currentTurn = (player + 3) % 4; // anti-clockwise: next player is to the right
broadcastState(room, 'cardPlayed'); broadcastState(room, 'cardPlayed');
scheduleAfkTimer(room);
scheduleBotPlay(room); scheduleBotPlay(room);
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);
@@ -616,6 +699,7 @@ function onCardPlayed(room, player, card) {
room.currentTurn = winner; room.currentTurn = winner;
room.trick = []; room.trick = [];
broadcastState(room, 'roomInfo'); broadcastState(room, 'roomInfo');
scheduleAfkTimer(room);
scheduleBotPlay(room); scheduleBotPlay(room);
}, 1400); }, 1400);
} }
@@ -623,6 +707,7 @@ function onCardPlayed(room, player, card) {
// ─── Hand scoring ───────────────────────────────────────────── // ─── Hand scoring ─────────────────────────────────────────────
function finishHand(room) { function finishHand(room) {
clearAfkTimer(room);
const dTeam = teamOf(room.declarer); const dTeam = teamOf(room.declarer);
const oTeam = 1 - dTeam; const oTeam = 1 - dTeam;
@@ -640,6 +725,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;
@@ -650,12 +736,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;
@@ -670,7 +773,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) {
@@ -682,6 +785,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);
@@ -692,16 +797,128 @@ 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 ────────────────────────────────────────────────
function clearAfkTimer(room) {
if (room.afkTimer) { clearTimeout(room.afkTimer); room.afkTimer = null; }
if (room.afkSeat >= 0) {
for (let i = 0; i < 4; i++) {
if (room.seats[i]) io.to(room.seats[i]).emit('afkResolved', {});
}
room.afkSeat = -1;
room.afkVotes = new Set();
}
}
function broadcastAiControl(room, seat, active) {
const data = { seat, active, name: room.names[seat] };
for (let i = 0; i < 4; i++) {
if (room.seats[i]) io.to(room.seats[i]).emit('aiControl', data);
}
for (const sid of room.spectators) io.to(sid).emit('aiControl', data);
}
function scheduleAfkTimer(room) {
clearAfkTimer(room);
if (room.state !== 'PLAYING') return;
const seat = room.currentTurn;
if (room.bots[seat] || !room.seats[seat]) return;
// Seat is AI-controlled: play automatically after 10s
if (room.aiControlledSeats.has(seat)) {
room.afkTimer = setTimeout(() => {
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
if (!room.aiControlledSeats.has(seat)) return;
const card = botChooseCard(room, seat);
if (card) onCardPlayed(room, seat, card);
}, 10000);
return;
}
// Normal 60s AFK warning timer
room.afkTimer = setTimeout(() => {
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
room.afkSeat = seat;
room.afkVotes = new Set();
const otherHumans = room.seats
.map((sid, i) => ({ sid, i }))
.filter(({ sid, i }) => sid && i !== seat);
if (otherHumans.length === 0) {
room.afkSeat = -1;
const card = botChooseCard(room, seat);
if (card) onCardPlayed(room, seat, card);
return;
}
for (const { sid } of otherHumans) {
io.to(sid).emit('afkWarning', { seat, name: room.names[seat] });
}
}, 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);
} }
@@ -1036,21 +1253,75 @@ 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
if (room.aiControlledSeats.has(seat)) {
room.aiControlledSeats.delete(seat);
broadcastAiControl(room, seat, false);
}
onCardPlayed(room, seat, card); onCardPlayed(room, seat, card);
}); });
// ── Vote AI takeover (AFK) ─────────────────────────────────
socket.on('voteAITakeover', ({ roomId, seat, token } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room || room.state !== 'PLAYING') return;
if (room.tokens[seat] !== token) return;
if (room.afkSeat < 0 || seat === room.afkSeat) return;
room.afkVotes.add(seat);
const otherHumanSeats = room.seats
.map((sid, i) => ({ sid, i }))
.filter(({ sid, i }) => sid && i !== room.afkSeat);
const needed = Math.ceil(otherHumanSeats.length / 2);
if (room.afkVotes.size >= needed) {
const afkSeat = room.afkSeat;
clearAfkTimer(room);
room.aiControlledSeats.add(afkSeat);
broadcastAiControl(room, afkSeat, true);
if (room.state === 'PLAYING' && room.currentTurn === afkSeat) {
const card = botChooseCard(room, afkSeat);
if (card) onCardPlayed(room, afkSeat, card);
}
}
});
// ── Leave ────────────────────────────────────────────────── // ── Leave ──────────────────────────────────────────────────
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', () => {
@@ -1058,6 +1329,19 @@ io.on('connection', (socket) => {
}); });
}); });
// ─── Stale public room cleanup ────────────────────────────────
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [id, room] of rooms) {
if (room.isPublic && room.state === 'WAITING' && now - room.createdAt > SIX_HOURS_MS) {
if (room.trickTimer) clearTimeout(room.trickTimer);
if (room.afkTimer) clearTimeout(room.afkTimer);
rooms.delete(id);
}
}
}, 30 * 60 * 1000); // check every 30 minutes
// ─── Start ──────────────────────────────────────────────────── // ─── Start ────────────────────────────────────────────────────
httpServer.listen(HTTP_PORT, () => httpServer.listen(HTTP_PORT, () =>
console.log(`Shelem HTTP → http://localhost:${HTTP_PORT}`) console.log(`Shelem HTTP → http://localhost:${HTTP_PORT}`)