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