Show map rankings for player personal records
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface PlayerMapRecord extends PlayerStats {
|
||||
name: string;
|
||||
lastSeen: string;
|
||||
records: PersonalBestRecords;
|
||||
ranks: Record<PersonalBestMetric, number>;
|
||||
}
|
||||
export interface PlayerMatch {
|
||||
id: number;
|
||||
|
||||
@@ -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<Omit<PlayerMapRecord, 'records'>>;
|
||||
.all(guid) as Array<Omit<PlayerMapRecord, 'records' | 'ranks'>>;
|
||||
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,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{#each maps as map (`${map.game}:${map.key}`)}
|
||||
<article>
|
||||
<header><h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3><span>{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(map.gamesPlayed)} {map.gamesPlayed === 1 ? 'game' : 'games'}</span></header>
|
||||
<dl>{#each records as record}<div><dt>{record.label}</dt><dd><a href={contextHref(`/matches/${map.records[record.key].matchId}`, returnTo ?? "/leaderboard")} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></dd></div>{/each}</dl>
|
||||
<dl>{#each records as record}<div><dt>{record.label}</dt><dd><a href={contextHref(`/matches/${map.records[record.key].matchId}`, returnTo ?? "/leaderboard")} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a><small aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}>Rank #{number.format(map.ranks[record.key])}</small></dd></div>{/each}</dl>
|
||||
<p>{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed rounds</p>
|
||||
</article>
|
||||
{/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); }
|
||||
</style>
|
||||
|
||||
@@ -86,12 +86,12 @@
|
||||
<div class="stats">{#each overviewMetrics as metric}<div class="stat compact"><span>Lifetime {metric.label.toLowerCase()}</span><strong>{number.format(stats[metric.key])}</strong></div>{/each}</div>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="maps-title">
|
||||
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Lifetime personal bests across all squad sizes. Each record links to the match that established it.</p></div>
|
||||
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>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.</p></div>
|
||||
<div class="mobile-records"><PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} /></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
|
||||
<div class="table-scroll desktop-records" role="region" aria-label="Per-map player records" tabindex="0">
|
||||
<table><thead><tr><th scope="col">Map</th>{#each records as record}<th scope="col">{record.label}</th>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th scope="col">Total rounds</th></tr></thead>
|
||||
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small></th>{#each records as record}<td><a href={contextHref(`/matches/${map.records[record.key].matchId}`, $page.url)} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></td>{/each}<td>{number.format(map.gamesPlayed)}</td><td>{number.format(map.kills)}</td><td>{number.format(map.roundsSurvived)}</td></tr>{/each}</tbody>
|
||||
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small></th>{#each records as record}<td><a href={contextHref(`/matches/${map.records[record.key].matchId}`, $page.url)} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a><small aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}>Rank #{number.format(map.ranks[record.key])}</small></td>{/each}<td>{number.format(map.gamesPlayed)}</td><td>{number.format(map.kills)}</td><td>{number.format(map.roundsSurvived)}</td></tr>{/each}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}</a>`);
|
||||
}
|
||||
|
||||
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('<table'), body.indexOf('</table>'));
|
||||
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.');
|
||||
});
|
||||
Reference in New Issue
Block a user