diff --git a/.gitignore b/.gitignore index 2e84f3b..1cd2591 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ npm-debug.log* TODO push.sh CLAUDE.md +data-dev/ +compose.dev.yml diff --git a/package.json b/package.json index 86a9823..e0eee7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shelem", - "version": "1.1.7", + "version": "1.1.8", "description": "Shelem card game — multiplayer", "main": "server.js", "scripts": { diff --git a/public/app.js b/public/app.js index 4478ac8..1f2c0d8 100644 --- a/public/app.js +++ b/public/app.js @@ -1324,14 +1324,29 @@ async function showLeaderboard() { const rows = await r.json(); const tbody = $('lb-body'); tbody.innerHTML = ''; - rows.forEach((row, i) => { + let rank = 0, dividerDone = false; + + rows.forEach(row => { + // Players below the minimum game count are listed after the ranking, + // under a divider, so a newcomer can see how close they are. + if (!row.ranked && !dividerDone) { + dividerDone = true; + const sep = document.createElement('tr'); + sep.className = 'lb-divider'; + sep.innerHTML = `Not yet ranked — needs ${ + row.gamesToRank + row.games_played} games`; + tbody.appendChild(sep); + } + const tr = document.createElement('tr'); + if (row.ranked) rank++; + else tr.className = 'lb-provisional'; tr.innerHTML = ` - ${i + 1} + ${row.ranked ? rank : '—'} ${escHtml(row.username)} ${row.win_rate != null ? row.win_rate + '%' : '—'} ${row.games_won} - ${row.games_played} + ${row.games_played}${row.ranked ? '' : ` +${row.gamesToRank}`} ${row.avg_score ?? '—'} ${row.bids_won || 0} ${row.shelemCount || 0} diff --git a/public/style.css b/public/style.css index a8593d3..8b55b3b 100644 --- a/public/style.css +++ b/public/style.css @@ -1304,3 +1304,14 @@ input:focus { border-color: var(--gold); } .overlay-box { padding: 18px 16px; } .bid-amount-display { font-size: 1.4rem; } } + +/* ── Leaderboard: players below the minimum game count ────────── */ +.lb-table tr.lb-divider td { + padding-top: 12px; + font-size: .7rem; + color: rgba(255,255,255,.4); + text-align: center; + border-bottom: 1px solid rgba(255,255,255,.12); +} +.lb-table tr.lb-provisional { opacity: .55; } +.lb-togo { color: var(--gold); font-size: .72em; } diff --git a/server.js b/server.js index 0323431..5950157 100644 --- a/server.js +++ b/server.js @@ -103,10 +103,20 @@ function findUser(username) { return null; } +const EMPTY_STATS = { games_played: 0, games_won: 0, shelemCount: 0, bids_won: 0, total_score: 0 }; + +// Read-only lookup. Never creates or persists a row — the leaderboard walks +// every known user, and creating-on-read there meant one full stats.json write +// per user on every page load, plus a file steadily filling with empty rows. +function readStats(userId) { + return stats.find(s => s.userId === userId) || { userId, ...EMPTY_STATS }; +} + +// Write path: materialises the row so addStats has something to accumulate into. function getStats(userId) { let s = stats.find(s => s.userId === userId); if (!s) { - s = { userId, games_played: 0, games_won: 0, shelemCount: 0, bids_won: 0, total_score: 0 }; + s = { userId, ...EMPTY_STATS }; stats.push(s); saveStats(); } @@ -146,7 +156,11 @@ app.use(express.static(path.join(__dirname, 'public'), { const APP_VERSION = require('./package.json').version; app.get('/api/config', (_req, res) => { - res.json({ signupsOpen: config.signupsOpen, version: APP_VERSION }); + res.json({ + signupsOpen: config.signupsOpen, + version: APP_VERSION, + minRankedGames: MIN_RANKED_GAMES, + }); }); // Validate the stored auth token; the client uses this at startup to detect a @@ -207,7 +221,7 @@ app.get('/api/profile/: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 s = getStats(effectiveId); + const s = readStats(effectiveId); res.json({ username: user.username, games_played: s.games_played || 0, @@ -246,6 +260,9 @@ app.get('/api/history/:username', (req, res) => { res.json(games); }); +// Finished games a player needs before they appear in the ranking proper. +const MIN_RANKED_GAMES = 5; + app.get('/api/leaderboard', (_req, res) => { const allUsers = [...users]; if (SHARED_USERS_FILE) { @@ -259,7 +276,7 @@ app.get('/api/leaderboard', (_req, res) => { } const rows = allUsers.map(u => { const eid = u._fromShared ? `hokm_${u.id}` : u.id; - const s = getStats(eid); + const s = readStats(eid); const played = s.games_played || 0; const won = s.games_won || 0; return { @@ -274,14 +291,28 @@ app.get('/api/leaderboard', (_req, res) => { avg_score: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : null, }; }) - .filter(r => r.games_played > 0) - .sort((a, b) => + .filter(r => r.games_played > 0); + + const byWinRate = (a, b) => (b.win_rate - a.win_rate) || // primary: win rate - (b.avg_score - a.avg_score) || // tie-break: average score - (b.games_played - a.games_played) - ) - .slice(0, 30); - res.json(rows); + (b.avg_score - a.avg_score) || // tie-break: average score (higher is better) + (b.games_played - a.games_played); + + // A single lucky game shouldn't top the board, so a player needs + // MIN_RANKED_GAMES finished games before they get a rank. Everyone below that + // is still listed, unranked, so a new player can see their progress. + const ranked = rows.filter(r => r.games_played >= MIN_RANKED_GAMES) + .sort(byWinRate) + .slice(0, 30) + .map(r => ({ ...r, ranked: true, gamesToRank: 0 })); + + const provisional = rows.filter(r => r.games_played < MIN_RANKED_GAMES) + .sort((a, b) => b.games_played - a.games_played || byWinRate(a, b)) + .slice(0, 10) + .map(r => ({ ...r, ranked: false, + gamesToRank: MIN_RANKED_GAMES - r.games_played })); + + res.json([...ranked, ...provisional]); }); app.get('/api/rooms', (_req, res) => {