Leader board needs 5 finish games to be qualified
This commit is contained in:
@@ -11,3 +11,5 @@ npm-debug.log*
|
||||
TODO
|
||||
push.sh
|
||||
CLAUDE.md
|
||||
data-dev/
|
||||
compose.dev.yml
|
||||
|
||||
+1
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user