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
push.sh
CLAUDE.md
data-dev/
compose.dev.yml
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "shelem",
"version": "1.1.7",
"version": "1.1.8",
"description": "Shelem card game — multiplayer",
"main": "server.js",
"scripts": {
+18 -3
View File
@@ -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 = `<td colspan="8">Not yet ranked — needs ${
row.gamesToRank + row.games_played} games</td>`;
tbody.appendChild(sep);
}
const tr = document.createElement('tr');
if (row.ranked) rank++;
else tr.className = 'lb-provisional';
tr.innerHTML = `
<td>${i + 1}</td>
<td>${row.ranked ? rank : '—'}</td>
<td>${escHtml(row.username)}</td>
<td>${row.win_rate != null ? row.win_rate + '%' : '—'}</td>
<td>${row.games_won}</td>
<td>${row.games_played}</td>
<td>${row.games_played}${row.ranked ? '' : ` <span class="lb-togo">+${row.gamesToRank}</span>`}</td>
<td>${row.avg_score ?? '—'}</td>
<td>${row.bids_won || 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; }
.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;
}
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) => {