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) => {