diff --git a/docs/guides/leaderboard.md b/docs/guides/leaderboard.md index 208f779..5c58c30 100644 --- a/docs/guides/leaderboard.md +++ b/docs/guides/leaderboard.md @@ -30,7 +30,9 @@ Click a player's name to open their shareable profile at links redirect to the combined profile. Profiles show all ten lifetime statistics, headshots as a percentage of kills, kills per down, average kills and observed rounds per game, and completion rate. Each map has its own complete set of -records; map records and match history identify their game. Match history shows +records; each personal best shows its rank among players on that map in that game, +across all squad sizes. Equal bests share a rank, with the next rank skipping tied +positions (for example, 1, 1, 3). Map records and match history identify their game. Match history shows individual counters, server, date and completion credit in pages of 25 matches across both games. Map names link back to that map's leaderboard in its game. Profiles update every 30 seconds while visible; the freshness indicator offers Retry if an update fails. Ratios with a zero denominator diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts index f5f75d2..b0145b6 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -39,6 +39,7 @@ export interface PlayerMapRecord extends PlayerStats { name: string; lastSeen: string; records: PersonalBestRecords; + ranks: Record; } export interface PlayerMatch { id: number; diff --git a/src/lib/server/fleet/player-profile.ts b/src/lib/server/fleet/player-profile.ts index de66ef2..4ee3392 100644 --- a/src/lib/server/fleet/player-profile.ts +++ b/src/lib/server/fleet/player-profile.ts @@ -36,10 +36,31 @@ export function getPlayerProfile(guid: string, requestedOffset = 0, const mapStats = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name, MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope} GROUP BY m.game, m.map_key ORDER BY highestRound DESC, kills DESC, name COLLATE NOCASE, m.game, key`) - .all(guid) as Array>; + .all(guid) as Array>; const [records, ...mapRecords] = getPersonalBestRecords(handle, [{ guid, ...stats }, ...mapStats.map(map => ({ guid, ...map, map: map.key }))]); - const maps = mapStats.map((map, index) => ({ ...map, records: mapRecords[index] })); + // Rank all three bests in one pass over this player's maps. Filter the GUID + // only after ranking competitors so ties and skipped ranks match the leaderboard. + const mapRanks = handle.prepare(`WITH totals AS ( + SELECT requested.key AS requestId, mp.guid, + MAX(mp.highest_round) AS highestRound, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore + FROM json_each(?) requested + JOIN leaderboard_matches m ON m.game = json_extract(requested.value, '$.game') + AND m.map_key = json_extract(requested.value, '$.key') + JOIN leaderboard_match_players mp ON mp.match_id = m.id + WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.has_stats = 1 + AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND} + AND EXISTS (SELECT 1 FROM leaderboard_players p WHERE p.guid = mp.guid AND p.game IN ('t5', 't6')) + GROUP BY requested.key, mp.guid + ), ranked AS ( + SELECT requestId, guid, + RANK() OVER (PARTITION BY requestId ORDER BY highestRound DESC) AS highestRound, + RANK() OVER (PARTITION BY requestId ORDER BY bestKills DESC) AS bestKills, + RANK() OVER (PARTITION BY requestId ORDER BY bestScore DESC) AS bestScore + FROM totals + ) SELECT highestRound, bestKills, bestScore FROM ranked WHERE guid = ? ORDER BY requestId`) + .all(JSON.stringify(mapStats.map(({ game, key }) => ({ game, key }))), guid) as PlayerMapRecord['ranks'][]; + const maps = mapStats.map((map, index) => ({ ...map, records: mapRecords[index], ranks: mapRanks[index] })); const matches = handle.prepare(`SELECT m.id, m.game, m.server, m.server_deleted_at, m.map_name AS mapName, m.started_at AS startedAt, mp.last_seen_at AS lastSeen, m.status, mp.completed, mp.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived, diff --git a/src/lib/ui/components/PlayerMapCards.svelte b/src/lib/ui/components/PlayerMapCards.svelte index 8245c81..eb342c4 100644 --- a/src/lib/ui/components/PlayerMapCards.svelte +++ b/src/lib/ui/components/PlayerMapCards.svelte @@ -10,7 +10,7 @@ {#each maps as map (`${map.game}:${map.key}`)}

{map.name}

{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(map.gamesPlayed)} {map.gamesPlayed === 1 ? 'game' : 'games'}
-
{#each records as record}{/each}
+
{#each records as record}
{record.label}
{number.format(map[record.key])}Rank #{number.format(map.ranks[record.key])}
{/each}

{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed rounds

{/each} @@ -23,5 +23,6 @@ dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin: var(--space-3) 0; } dd { font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; margin-top: var(--space-1); } dd a { display: inline-flex; align-items: center; min-height: 44px; } + dd small { display: block; color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-xs); } a { color: var(--accent-primary-hover); } diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index 3b416e5..3ae64f6 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -86,12 +86,12 @@
{#each overviewMetrics as metric}
Lifetime {metric.label.toLowerCase()}{number.format(stats[metric.key])}
{/each}
-

Per-map records

Lifetime personal bests across all squad sizes. Each record links to the match that established it.

+

Per-map records

Lifetime personal bests across all squad sizes, ranked against other players on each map. Tied records share a rank. Each record links to the match that established it.

{#each records as record}{/each} - {#each profile.maps as map (`${map.game}:${map.key}`)}{#each records as record}{/each}{/each} + {#each profile.maps as map (`${map.game}:${map.key}`)}{#each records as record}{/each}{/each}
Map{record.label}GamesTotal killsTotal rounds
{map.name}{gameName(map.game)}{number.format(map[record.key])}{number.format(map.gamesPlayed)}{number.format(map.kills)}{number.format(map.roundsSurvived)}
{map.name}{gameName(map.game)}{number.format(map[record.key])}Rank #{number.format(map.ranks[record.key])}{number.format(map.gamesPlayed)}{number.format(map.kills)}{number.format(map.roundsSurvived)}
diff --git a/tests/unit/leaderboard-records.test.ts b/tests/unit/leaderboard-records.test.ts index d14d9f3..e391dd4 100644 --- a/tests/unit/leaderboard-records.test.ts +++ b/tests/unit/leaderboard-records.test.ts @@ -75,6 +75,38 @@ it('resolves tied bests by earliest completion, receipt time for unknown endings } }); +it('ranks each map best against distinct players using leaderboard ties, qualification, and game boundaries', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const handle = db(); + seed(handle, 1, { round: 20, kills: 200, score: 2000 }); + seed(handle, 2, { guid: '202', round: 30, kills: 100, score: 3000 }); + seed(handle, 3, { guid: '202', round: 30, kills: 150, score: 2000 }); + seed(handle, 4, { guid: '303', round: 30, kills: 300, score: 2000 }); + seed(handle, 5, { guid: '404', round: 10, kills: 200, score: 1000 }); + seed(handle, 6, { map: 'moon', round: 5, kills: 10, score: 10 }); + seed(handle, 7, { game: 't6', round: 6, kills: 6, score: 6 }); + seed(handle, 8, { game: 't6', guid: '202', round: 7, kills: 7, score: 7 }); + seed(handle, 9, { guid: 'active', status: 'active', round: 99, kills: 9999, score: 9999 }); + seed(handle, 10, { guid: 'interrupted', status: 'interrupted', round: 99, kills: 9999, score: 9999 }); + seed(handle, 11, { guid: 'unrecorded', hasStats: 0, round: 99, kills: 9999, score: 9999 }); + seed(handle, 12, { guid: 'unqualified', round: 1, kills: 9999, score: 9999 }); + handle.prepare('UPDATE leaderboard_matches SET start_players=2 WHERE id IN (1,6,7)').run(); + handle.prepare('UPDATE leaderboard_matches SET start_players=4 WHERE id IN (2,3,4,5,8)').run(); + const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js'); + const { getPlayerLeaderboardEntry } = await import('../../src/lib/server/fleet/leaderboard.js'); + const profile = getPlayerProfile('101')!; + expect(profile.maps.find(map => map.key === 't5:theater')?.ranks).toEqual({ highestRound: 3, bestKills: 2, bestScore: 2 }); + expect(profile.maps.find(map => map.key === 't5:moon')?.ranks).toEqual({ highestRound: 1, bestKills: 1, bestScore: 1 }); + expect(profile.maps.find(map => map.key === 't6:theater')?.ranks).toEqual({ highestRound: 2, bestKills: 2, bestScore: 2 }); + for (const map of profile.maps) for (const metric of ['highestRound', 'bestKills', 'bestScore'] as const) { + expect(map.ranks[metric]).toBe(getPlayerLeaderboardEntry('101', { game: map.game, map: map.key, metric })?.rank); + } + expect(getPlayerProfile('101', 0, { game: 't6', map: 't6:theater', players: 2 })?.maps).toEqual(profile.maps); + seed(handle, 13, { guid: 'new-leader', round: 40, kills: 400, score: 4000 }); + expect(getPlayerProfile('101')?.maps.find(map => map.key === 't5:theater')?.ranks) + .toEqual({ highestRound: 4, bestKills: 3, bestScore: 3 }); +}); + it('never uses unfinished, unsupported, nonqualifying or unrecorded stats as evidence', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const handle = db(); diff --git a/tests/unit/ui-player-map-records.test.ts b/tests/unit/ui-player-map-records.test.ts new file mode 100644 index 0000000..133ae6d --- /dev/null +++ b/tests/unit/ui-player-map-records.test.ts @@ -0,0 +1,45 @@ +import { expect, it, vi } from 'vitest'; +import { render } from 'svelte/server'; +import { writable } from 'svelte/store'; +import PlayerProfilePage from '../../src/routes/leaderboard/players/[guid]/+page.svelte'; +import PlayerMapCards from '../../src/lib/ui/components/PlayerMapCards.svelte'; +import type { PlayerMapRecord, PlayerProfile, RecordEvidence } from '../../src/lib/leaderboard.js'; + +vi.mock('$app/stores', () => ({ page: writable({ + url: new URL('http://localhost/leaderboard/players/101?players=2'), data: { user: null } +}) })); +vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })); + +const evidence: RecordEvidence = { matchId: 71, game: 't5', mapKey: 't5:theater', mapName: 'Kino Der Toten', + server: 't5_zombie_theater_1', startedAt: '2026-10-06T12:00:00Z', endedAt: '2026-10-06T13:00:00Z', + startObserved: true, presentAtStart: true, firstObservedRound: 0, value: 22 }; +const stats = { highestRound: 22, bestKills: 507, bestScore: 5290, kills: 722, roundsSurvived: 41, + headshots: 10, revives: 2, downs: 3, gamesPlayed: 9, gamesCompleted: 8 }; +const map: PlayerMapRecord = { ...stats, game: 't5', key: 't5:theater', name: 'Kino Der Toten', + lastSeen: evidence.endedAt!, ranks: { highestRound: 7, bestKills: 12, bestScore: 3 }, + records: { highestRound: evidence, bestKills: { ...evidence, matchId: 72, value: 507 }, + bestScore: { ...evidence, matchId: 73, value: 5290 } } }; + +function expectRankedRecords(body: string) { + for (const [label, rank] of [['Best round', 7], ['Best kills', 12], ['Best score', 3]]) { + expect(body).toContain(`aria-label="Kino Der Toten ${label} rank ${rank}"`); + expect(body).toContain(`Rank #${rank}`); + } + for (const id of [71, 72, 73]) expect(body).toContain(`/matches/${id}?returnTo=`); + for (const value of ['22', '507', '5,290']) expect(body).toContain(`>${value}`); +} + +it('shows each record rank and preserves match links in mobile map cards', () => { + expectRankedRecords(render(PlayerMapCards, { props: { maps: [map], returnTo: '/leaderboard/players/101?players=2' } }).body); +}); + +it('shows the same ranks in the desktop profile with lifetime map scope explained', () => { + const profile: PlayerProfile = { guid: '101', name: 'Cherry', firstSeen: evidence.startedAt, lastSeen: evidence.endedAt!, + stats, records: map.records, maps: [map], matches: [], matchesTotal: 0, limit: 25, offset: 0 }; + const body = render(PlayerProfilePage, { props: { data: { profile, returnTo: '/leaderboard', + scope: { game: '', map: '', players: '2', metric: 'kills', entry: null } } } }).body; + const table = body.slice(body.indexOf('')); + expectRankedRecords(table); + expect(body).toContain('Lifetime personal bests across all squad sizes, ranked against other players on each map.'); + expect(body).toContain('Tied records share a rank.'); +});