Leader board needs 5 finish games to be qualified

This commit is contained in:
goyban
2026-08-30 14:45:39 +00:00
parent 1149687e3c
commit ebfd90c5a5
5 changed files with 74 additions and 15 deletions
+2
View File
@@ -11,3 +11,5 @@ npm-debug.log*
TODO TODO
push.sh push.sh
CLAUDE.md CLAUDE.md
data-dev/
compose.dev.yml
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shelem", "name": "shelem",
"version": "1.1.7", "version": "1.1.8",
"description": "Shelem card game — multiplayer", "description": "Shelem card game — multiplayer",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+18 -3
View File
@@ -1324,14 +1324,29 @@ async function showLeaderboard() {
const rows = await r.json(); const rows = await r.json();
const tbody = $('lb-body'); const tbody = $('lb-body');
tbody.innerHTML = ''; 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 = `<td colspan="8">Not yet ranked — needs ${
row.gamesToRank + row.games_played} games</td>`;
tbody.appendChild(sep);
}
const tr = document.createElement('tr'); const tr = document.createElement('tr');
if (row.ranked) rank++;
else tr.className = 'lb-provisional';
tr.innerHTML = ` tr.innerHTML = `
<td>${i + 1}</td> <td>${row.ranked ? rank : '—'}</td>
<td>${escHtml(row.username)}</td> <td>${escHtml(row.username)}</td>
<td>${row.win_rate != null ? row.win_rate + '%' : '—'}</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}${row.ranked ? '' : ` <span class="lb-togo">+${row.gamesToRank}</span>`}</td>
<td>${row.avg_score ?? '—'}</td> <td>${row.avg_score ?? '—'}</td>
<td>${row.bids_won || 0}</td> <td>${row.bids_won || 0}</td>
<td>${row.shelemCount || 0}</td> <td>${row.shelemCount || 0}</td>
+11
View File
@@ -1304,3 +1304,14 @@ input:focus { border-color: var(--gold); }
.overlay-box { padding: 18px 16px; } .overlay-box { padding: 18px 16px; }
.bid-amount-display { font-size: 1.4rem; } .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; }
+42 -11
View File
@@ -103,10 +103,20 @@ function findUser(username) {
return null; 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) { 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, bids_won: 0, total_score: 0 }; s = { userId, ...EMPTY_STATS };
stats.push(s); stats.push(s);
saveStats(); saveStats();
} }
@@ -146,7 +156,11 @@ app.use(express.static(path.join(__dirname, 'public'), {
const APP_VERSION = require('./package.json').version; const APP_VERSION = require('./package.json').version;
app.get('/api/config', (_req, res) => { 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 // 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); const user = findUser(req.params.username);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id; const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id;
const s = getStats(effectiveId); const s = readStats(effectiveId);
res.json({ res.json({
username: user.username, username: user.username,
games_played: s.games_played || 0, games_played: s.games_played || 0,
@@ -246,6 +260,9 @@ app.get('/api/history/:username', (req, res) => {
res.json(games); 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) => { app.get('/api/leaderboard', (_req, res) => {
const allUsers = [...users]; const allUsers = [...users];
if (SHARED_USERS_FILE) { if (SHARED_USERS_FILE) {
@@ -259,7 +276,7 @@ app.get('/api/leaderboard', (_req, res) => {
} }
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 = readStats(eid);
const played = s.games_played || 0; const played = s.games_played || 0;
const won = s.games_won || 0; const won = s.games_won || 0;
return { return {
@@ -274,14 +291,28 @@ app.get('/api/leaderboard', (_req, res) => {
avg_score: played > 0 ? +((s.total_score || 0) / played).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) =>
const byWinRate = (a, b) =>
(b.win_rate - a.win_rate) || // primary: win rate (b.win_rate - a.win_rate) || // primary: win rate
(b.avg_score - a.avg_score) || // tie-break: average score (b.avg_score - a.avg_score) || // tie-break: average score (higher is better)
(b.games_played - a.games_played) (b.games_played - a.games_played);
)
.slice(0, 30); // A single lucky game shouldn't top the board, so a player needs
res.json(rows); // 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) => { app.get('/api/rooms', (_req, res) => {