Preserve leaderboard context in player profiles

This commit is contained in:
2026-10-01 12:23:30 -04:00
parent 47ebe83106
commit eac9ad4fbc
10 changed files with 326 additions and 24 deletions
+11 -1
View File
@@ -23,7 +23,17 @@ records; 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.
Use **Refresh stats** to load the latest reports. Ratios with a zero denominator
display a dash. Profiles always cover all recorded maps across both games.
display a dash. Lifetime statistics and per-map records always cover all recorded
maps across both games.
Opening a player from the leaderboard preserves the originating game, map,
metric, search and page in the **Leaderboard** return link. The profile's
**Selected leaderboard** controls initialize from that game/map/metric and show
the player's rank within that competition, independent of the original search.
These controls also filter match history; lifetime totals stay unchanged.
History pagination retains the selection and return link. Map links preserve
the selected ranking metric. A player without qualifying matches in a selection
can still view their lifetime profile and choose another scope.
Highest-round, best-game-kill and highest-score records link to the completed
match that earned them on the leaderboard and in lifetime/per-map profile stats.
+44
View File
@@ -0,0 +1,44 @@
import { leaderboardMetrics } from './leaderboard.js';
const origin = 'https://electric-cherry.invalid';
const contextKeys = ['game', 'map', 'metric', 'q', 'offset'] as const;
/** Only retain a local leaderboard destination and its supported view state. */
export function leaderboardReturnHref(value: string | null | undefined): string {
if (!value?.startsWith('/leaderboard')) return '/leaderboard';
try {
const url = new URL(value, origin);
if (url.origin !== origin || url.pathname !== '/leaderboard') return '/leaderboard';
const params = new URLSearchParams();
for (const key of contextKeys) {
const entry = url.searchParams.get(key);
if (entry) params.set(key, entry);
}
return `/leaderboard${params.size ? `?${params}` : ''}`;
} catch { return '/leaderboard'; }
}
export function playerContextHref(guid: string, source: string): string {
const returnTo = leaderboardReturnHref(source);
const original = new URL(returnTo, origin).searchParams;
const params = new URLSearchParams({ returnTo });
// History pagination is independent of the originating leaderboard page.
const game = original.get('game');
const map = original.get('map');
const metric = original.get('metric');
// Match the leaderboard's displayed defaults for older or hand-edited URLs.
if (game === 't5' || game === 't6') params.set('game', game);
if (map && map.length <= 1024) params.set('map', map);
if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric);
return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`;
}
export function profileViewHref(current: URL, changes: Record<string, string>): string {
const params = new URLSearchParams(current.search);
params.delete('offset');
for (const [key, value] of Object.entries(changes)) {
if (value) params.set(key, value);
else params.delete(key);
}
return `${current.pathname}${params.size ? `?${params}` : ''}`;
}
+1
View File
@@ -67,6 +67,7 @@ export interface PlayerProfile {
records: PersonalBestRecords;
maps: PlayerMapRecord[];
matches: PlayerMatch[];
matchesTotal: number;
offset: number;
limit: number;
}
+14 -2
View File
@@ -15,6 +15,16 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
/** Rank each GUID once within the selected matches; player search keeps their actual rank. */
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
return readLeaderboard(options);
}
/** Select the exact player after ranking everyone eligible in the requested scope. */
export function getPlayerLeaderboardEntry(guid: string,
options: Pick<LeaderboardOptions, 'game' | 'map' | 'metric'> = {}): LeaderboardEntry | null {
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid).entries[0] ?? null;
}
function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): LeaderboardResponse {
const handle = db();
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50;
@@ -53,8 +63,10 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
)`;
const query = options.q?.trim() ?? '';
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
const searchedParameters = query ? [...parameters, query, query] : parameters;
const search = exactGuid !== undefined ? 'WHERE guid = ?'
: query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
const searchedParameters = exactGuid !== undefined ? [...parameters, exactGuid]
: query ? [...parameters, query, query] : parameters;
const rankedEntries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search}
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`)
.all(...searchedParameters, limit, offset) as Array<Omit<LeaderboardEntry, 'records'>>;
+14 -6
View File
@@ -1,7 +1,7 @@
import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { getPersonalBestRecords } from './leaderboard-records.js';
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
import type { LeaderboardOptions, PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
@@ -11,7 +11,8 @@ const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.guid = ? AND mp.has_stats = 1
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`;
export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfile | null {
export function getPlayerProfile(guid: string, requestedOffset = 0,
options: Pick<LeaderboardOptions, 'game' | 'map'> = {}): PlayerProfile | null {
const handle = db();
// Keep game-specific ingestion identities, but show one identity with the latest reported name.
const player = handle.prepare(`SELECT guid, name, MIN(first_seen_at) OVER () AS firstSeen,
@@ -21,8 +22,15 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi
if (!player) return null;
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
if (!stats.gamesPlayed) return null;
const historyParameters = [guid];
let historyScope = scope;
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
if (options.map) { historyScope += ' AND m.map_key = ?'; historyParameters.push(options.map); }
const matchesTotal = options.game || options.map
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
: stats.gamesPlayed;
const limit = 25;
const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit;
const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit);
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, 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}
@@ -35,7 +43,7 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi
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,
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore
${scope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
.all(guid, limit, offset) as PlayerMatch[];
return { ...player, stats, records, maps, matches, offset, limit };
${historyScope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
.all(...historyParameters, limit, offset) as PlayerMatch[];
return { ...player, stats, records, maps, matches, matchesTotal, offset, limit };
}
+3 -2
View File
@@ -3,7 +3,8 @@
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { leaderboardMetrics, playerProfileHref, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import { leaderboardMetrics, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import { playerContextHref } from '$lib/leaderboard-navigation.js';
import { efficiencyMetrics, isEfficiencyMetric, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
@@ -215,7 +216,7 @@
<tr>
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
<th scope="row" class="player">
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
<a class="player-name" href={playerContextHref(entry.guid, `${$page.url.pathname}${$page.url.search}`)}>{entry.name}</a>
<div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
</th>
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{formatValue(entry, key)}{#if isEfficiencyMetric(key)}<small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small>{/if}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}<RecordSource evidence={entry.records[key]} />{/if}</td>{/each}
@@ -1,8 +1,20 @@
import { error } from '@sveltejs/kit';
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js';
import { getPlayerLeaderboardEntry, isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
import { leaderboardReturnHref } from '$lib/leaderboard-navigation.js';
export function load({ params, url }: { params: { guid: string }; url: URL }) {
const profile = getPlayerProfile(params.guid, Number(url.searchParams.get('offset') ?? 0));
const game = url.searchParams.get('game');
const map = url.searchParams.get('map');
const requestedMetric = url.searchParams.get('metric');
if ((game !== null && game !== 't5' && game !== 't6') || (map !== null && map.length > 1024)
|| (requestedMetric !== null && !isLeaderboardMetric(requestedMetric))) {
error(400, 'Use game t5 or t6, a supported metric, and a map key up to 1024 characters.');
}
const metric = requestedMetric !== null && isLeaderboardMetric(requestedMetric) ? requestedMetric : 'highestRound';
const options = { game: game || undefined, map: map || undefined, metric };
const profile = getPlayerProfile(params.guid, Number(url.searchParams.get('offset') ?? 0), options);
if (!profile) error(404, 'No recorded stats found for this player');
return { profile };
return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
scope: { game: game ?? '', map: map ?? '', metric, entry: getPlayerLeaderboardEntry(params.guid, options) } };
}
@@ -1,23 +1,51 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { invalidateAll } from '$app/navigation';
import { playerStatMetrics, type PlayerProfile, type PlayerStatMetric } from '$lib/leaderboard.js';
import { efficiencyMetrics, efficiencyValue, efficiencyEligible, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { leaderboardMetrics, playerStatMetrics, type PlayerProfile, type PlayerStatMetric, type LeaderboardMetric, type LeaderboardEntry } from '$lib/leaderboard.js';
import { efficiencyMetrics, efficiencyValue, efficiencyEligible, efficiencyRequirement, efficiencySample, formatEfficiencyValue, isEfficiencyMetric } from '$lib/leaderboard-efficiency.js';
import { profileViewHref } from '$lib/leaderboard-navigation.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import RecordSource from '$ui/components/RecordSource.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
let { data }: { data: { profile: PlayerProfile } } = $props();
let { data }: { data: { profile: PlayerProfile; returnTo: string;
scope: { game: string; map: string; metric: LeaderboardMetric; entry: LeaderboardEntry | null } } } = $props();
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
const profile = $derived(data.profile);
const stats = $derived(profile.stats);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
const scope = $derived(data.scope);
const filteredMaps = $derived(profile.maps.filter(map => !scope.game || map.game === scope.game));
const selectedMetric = $derived(leaderboardMetrics.find(metric => metric.key === scope.metric)!);
const selectedValue = $derived(scope.entry ? isEfficiencyMetric(scope.metric)
? formatEfficiencyValue(scope.entry[scope.metric], scope.metric) : number.format(scope.entry[scope.metric]) : '—');
const scopedLeaderboard = $derived.by(() => {
const params = new URLSearchParams({ metric: scope.metric });
if (scope.game) params.set('game', scope.game);
if (scope.map) params.set('map', scope.map);
return `/leaderboard?${params}`;
});
const date = (value: string) => $timeFormat.dateTime(value);
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly PlayerStatMetric[];
let refreshing = $state(false);
let refreshError = $state('');
let filtering = $state(false);
let pendingUrl: URL | undefined;
let navigationId = 0;
async function updateScope(changes: Record<string, string>) {
const url = new URL(profileViewHref(pendingUrl ?? $page.url, changes), $page.url);
pendingUrl = url;
const id = ++navigationId;
filtering = true;
refreshError = '';
try { await goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true }); }
catch { refreshError = 'Unable to update the player filters. Please try again.'; }
finally { if (id === navigationId) { pendingUrl = undefined; filtering = false; } }
}
async function refresh() {
refreshing = true;
refreshError = '';
@@ -31,12 +59,27 @@
<section class="profile">
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats">
{#snippet crumbs()}<a href="/leaderboard">← Leaderboard</a>{/snippet}
{#snippet crumbs()}<a href={data.returnTo}>← Leaderboard</a>{/snippet}
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
</SectionHeader>
<div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div>
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
<section class="panel selection" aria-labelledby="selection-title" aria-busy={filtering}>
<div class="panel-heading"><h2 id="selection-title">Selected leaderboard</h2><p>Choose the competition and match history to explore. Lifetime stats below always cover all games and maps.</p></div>
<div class="scope-controls">
<label>Game<select value={scope.game} onchange={event => void updateScope({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Map<select value={scope.map} onchange={event => void updateScope({ map: event.currentTarget.value })}><option value="">All maps</option>{#if scope.map && !filteredMaps.some(map => map.key === scope.map)}<option value={scope.map}>{scope.map}</option>{/if}{#each filteredMaps as map (`${map.game}:${map.key}`)}<option value={map.key}>{map.name}{!scope.game ? ` · ${gameName(map.game)}` : ''}</option>{/each}</select></label>
<label>Rank by<select value={scope.metric} onchange={event => void updateScope({ metric: event.currentTarget.value })}>{#each leaderboardMetrics as metric (metric.key)}<option value={metric.key}>{metric.label}</option>{/each}</select></label>
</div>
<div class="scope-rank">
{#if scope.entry}<p><strong>Rank #{number.format(scope.entry.rank)}</strong> · {selectedMetric.label}: <strong>{selectedValue}</strong>{#if isEfficiencyMetric(scope.metric)}<small>{efficiencySample(scope.entry, scope.metric)} · {number.format(scope.entry.gamesPlayed)} games</small>{/if}</p>
{:else if profile.matchesTotal === 0}<p>No completed matches in this selection.</p>
{:else if isEfficiencyMetric(scope.metric)}<p>Not yet qualified for this ranking.<small>{efficiencyRequirement(scope.metric)} in this selection.</small></p>{/if}
<a href={scopedLeaderboard}>Open selected leaderboard</a>
</div>
</section>
<section aria-labelledby="totals-title">
<h2 id="totals-title">Lifetime stats</h2>
<div class="stats">
@@ -63,7 +106,7 @@
<table>
<thead><tr><th scope="col">Map</th>{#each playerStatMetrics as metric}<th scope="col">{metric.label}</th>{/each}</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 })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
<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><small>Last played {date(map.lastSeen)}</small></th>
{#each playerStatMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
{/each}</tbody>
</table>
@@ -71,7 +114,8 @@
</section>
<section class="panel" aria-labelledby="history-title">
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Completed matches from both games, most recently observed first. Stats reflect this player's participation.</p></div>
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Completed matches in the selected game and map, most recently observed first. Stats reflect this player's participation.</p></div>
{#if profile.matches.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll" role="region" aria-label="Player match history" tabindex="0">
<table>
@@ -84,9 +128,10 @@
{/each}</tbody>
</table>
</div>
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(stats.gamesPlayed)} games</span>
<div><Button size="sm" href={`?offset=${Math.max(0, profile.offset - profile.limit)}`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`?offset=${profile.offset + profile.limit}`} disabled={profile.offset + profile.limit >= stats.gamesPlayed}>Next</Button></div>
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(profile.matchesTotal)} games</span>
<div><Button size="sm" href={profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })} disabled={profile.offset + profile.limit >= profile.matchesTotal}>Next</Button></div>
</div>
{:else}<EmptyState title="No completed matches in this selection" description="Choose another game or map to explore this player's history.">{#snippet actions()}<Button size="sm" onclick={() => void updateScope({ game: '', map: '' })}>Show all completed matches</Button>{/snippet}</EmptyState>{/if}
</section>
<details class="note"><summary>How these stats work</summary>
<p>Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in progress and interrupted games do not contribute. Highest round includes mid-game joins; total rounds only counts observed round progression. Kills, headshots, revives and downs use each match's highest reported counters.</p>
@@ -109,6 +154,11 @@
.panel { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.panel-heading { padding: var(--space-5); }
.panel-heading p, .note { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
.scope-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); padding: 0 var(--space-5) var(--space-4); }
.scope-controls label { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; color: var(--text-secondary); font-size: var(--text-xs); }
.scope-controls select { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); font: inherit; }
.scope-rank { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 0 var(--space-5) var(--space-5); font-size: var(--text-sm); }
.scope-rank p { margin: 0; }
section > .note { margin-top: var(--space-3); }
.table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
@@ -122,5 +172,5 @@
.pagination { padding: var(--space-4); justify-content: space-between; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); }
summary { cursor: pointer; color: var(--text-secondary); }
@media (max-width: 1000px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 640px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } }
@media (max-width: 640px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } .scope-controls { grid-template-columns: minmax(0, 1fr); } .scope-rank { align-items: flex-start; flex-direction: column; } }
</style>
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { leaderboardReturnHref, playerContextHref, profileViewHref } from '../../src/lib/leaderboard-navigation.js';
describe('leaderboard profile navigation', () => {
it('preserves leaderboard scope, search and page while starting profile history on its first page', () => {
const source = '/leaderboard?game=t6&map=t6%3Atown&metric=kills&q=Player+One&offset=50';
const profile = new URL(playerContextHref('001/2?#', source), 'http://localhost');
expect(profile.pathname).toBe('/leaderboard/players/001%2F2%3F%23');
expect(profile.searchParams.get('returnTo')).toBe(source);
expect(profile.searchParams.get('game')).toBe('t6');
expect(profile.searchParams.get('map')).toBe('t6:town');
expect(profile.searchParams.get('metric')).toBe('kills');
expect(profile.searchParams.has('q')).toBe(false);
expect(profile.searchParams.has('offset')).toBe(false);
});
it('keeps profile filters and return context through pagination and resets history pages on filter changes', () => {
const current = new URL('http://localhost/leaderboard/players/101?returnTo=%2Fleaderboard%3Fq%3DAce&game=t5&map=t5%3Atheater&metric=highestRound&offset=25');
const next = new URL(profileViewHref(current, { offset: '50' }), current);
expect(next.searchParams.get('offset')).toBe('50');
expect(next.searchParams.get('returnTo')).toBe('/leaderboard?q=Ace');
expect(next.searchParams.get('map')).toBe('t5:theater');
const filtered = new URL(profileViewHref(current, { game: 't6', map: '' }), current);
expect(filtered.searchParams.has('offset')).toBe(false);
expect(filtered.searchParams.has('map')).toBe(false);
expect(filtered.searchParams.get('game')).toBe('t6');
expect(filtered.searchParams.get('returnTo')).toBe('/leaderboard?q=Ace');
});
it('restricts supplied return destinations to the local leaderboard', () => {
for (const value of [null, '', 'https://example.com/leaderboard', '//example.com/leaderboard', '/leaderboard/players/101', '/leaderboard/../settings', '/leaderboard\\@example.com']) {
expect(leaderboardReturnHref(value)).toBe('/leaderboard');
}
expect(leaderboardReturnHref('/leaderboard?q=A%26B&unrelated=drop#fragment')).toBe('/leaderboard?q=A%26B');
});
it('uses the displayed fallback scope when a shared leaderboard URL contains unknown filters', () => {
const source = '/leaderboard?game=t7&metric=unknown&q=101';
const profile = new URL(playerContextHref('101', source), 'http://localhost');
expect(profile.searchParams.get('returnTo')).toBe(source);
expect(profile.searchParams.has('game')).toBe(false);
expect(profile.searchParams.has('metric')).toBe(false);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => fixture.cleanup());
function addMatches(handle: Database.Database, guid: string, count: number,
{ game = 't5', map = 'theater', kills = 10, name = guid } = {}) {
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, '2026-09-01', '2026-09-02')`).run(game, guid, name);
for (let i = 0; i < count; i++) {
const inserted = handle.prepare(`INSERT INTO leaderboard_matches
(server, game, map, map_key, map_name, started_at, ended_at, last_received_at, status,
highest_round, last_event, last_fingerprint)
VALUES ('removed-server', ?, ?, ?, ?, '2026-09-01', '2026-09-02', '2026-09-02', 'completed', 20, 'game_end', 'test')`)
.run(game, map, `${game}:${map}`, map);
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, kills, headshots,
downs, rounds_survived, completed, has_stats, first_seen_at, last_seen_at)
VALUES (?, ?, 20, ?, 5, 1, 2, 1, 1, '2026-09-01', '2026-09-02')`).run(inserted.lastInsertRowid, guid, kills);
}
}
it('filters and paginates match history independently of lifetime stats, map records and best evidence', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
addMatches(db(), '101', 30);
addMatches(db(), '101', 3, { game: 't6', map: 'town', kills: 100 });
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const lifetime = getPlayerProfile('101')!;
expect(lifetime.matchesTotal).toBe(33);
const first = getPlayerProfile('101', 0, { game: 't5', map: 't5:theater' })!;
const second = getPlayerProfile('101', 25, { game: 't5', map: 't5:theater' })!;
expect(first.matchesTotal).toBe(30);
expect(first.matches).toHaveLength(25);
expect(second.matches).toHaveLength(5);
expect(new Set([...first.matches, ...second.matches].map(match => match.id)).size).toBe(30);
expect(first.matches.every(match => match.game === 't5' && match.mapName === 'theater')).toBe(true);
for (const profile of [first, second, getPlayerProfile('101', 0, { game: 't6' })!]) {
expect(profile.stats).toEqual(lifetime.stats);
expect(profile.maps).toEqual(lifetime.maps);
expect(profile.records).toEqual(lifetime.records);
}
expect(getPlayerProfile('101', 999, { game: 't5' })!.offset).toBe(25);
expect(getPlayerProfile('101', 999, { game: 't6' })).toMatchObject({ offset: 0, matchesTotal: 3 });
expect(getPlayerProfile('101', 999, { game: 't6', map: 't5:theater' })).toMatchObject({
stats: lifetime.stats, matches: [], matchesTotal: 0, offset: 0
});
for (const offset of [-1, NaN, 1.5, Infinity]) expect(getPlayerProfile('101', offset, { game: 't5' })!.offset).toBe(0);
});
it('finds the exact GUID after combined or scoped ranking even beyond the first hundred name and GUID collisions', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
addMatches(handle, '101', 1, { kills: 10 });
addMatches(handle, '101', 1, { game: 't6', map: 'town', kills: 20 });
for (let index = 0; index < 105; index++) {
addMatches(handle, `101-${index}`, 1, { kills: 100, name: '101 namesake' });
}
const { getPlayerLeaderboardEntry, getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
expect(getLeaderboard({ q: '101', metric: 'kills', limit: 100 }).entries.some(entry => entry.guid === '101')).toBe(false);
expect(getPlayerLeaderboardEntry('101', { metric: 'kills' })).toMatchObject({ guid: '101', rank: 106,
gamesPlayed: 2, kills: 30, records: { bestKills: { game: 't6', value: 20 } } });
expect(getPlayerLeaderboardEntry('101', { metric: 'kills', game: 't5' })).toMatchObject({ guid: '101', rank: 106,
gamesPlayed: 1, kills: 10, records: { bestKills: { game: 't5', value: 10 } } });
expect(getPlayerLeaderboardEntry('101', { metric: 'kills', game: 't6', map: 't6:town' })).toMatchObject({
guid: '101', rank: 1, gamesPlayed: 1, kills: 20
});
expect(getPlayerLeaderboardEntry('10', { metric: 'kills' })).toBeNull();
expect(getPlayerLeaderboardEntry("' OR 1=1 --", { metric: 'kills' })).toBeNull();
});
it('keeps lifetime profiles available when selected history is empty or an efficiency rank is unqualified', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
addMatches(db(), '101', 4);
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const filtered = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?game=t6&map=t6%3Atown&offset=25') });
expect(filtered).toMatchObject({ returnTo: '/leaderboard',
profile: { stats: { gamesPlayed: 4, kills: 40 }, matches: [], matchesTotal: 0, offset: 0 },
scope: { game: 't6', map: 't6:town', metric: 'highestRound', entry: null } });
const unqualified = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') });
expect(unqualified).toMatchObject({ profile: { matchesTotal: 4, stats: { gamesPlayed: 4 } },
scope: { game: '', map: '', metric: 'averageKills', entry: null } });
addMatches(db(), '101', 1);
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') }))
.toMatchObject({ scope: { entry: { guid: '101', rank: 1, averageKills: 10, gamesPlayed: 5 } } });
});
it('loads canonical navigation context while keeping source search and pagination out of profile rank', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
addMatches(db(), '101', 1, { kills: 100 });
addMatches(db(), '202', 1, { kills: 200 });
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const url = new URL('http://localhost/leaderboard/players/101?game=t5&map=t5%3Atheater&metric=kills');
url.searchParams.set('returnTo', '/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50&unknown=drop');
const loaded = load({ params: { guid: '101' }, url });
expect(loaded.returnTo).toBe('/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50');
expect(loaded.scope).toMatchObject({ game: 't5', map: 't5:theater', metric: 'kills', entry: { guid: '101', rank: 2 } });
expect(loaded.profile.offset).toBe(0);
url.searchParams.set('returnTo', 'https://external.invalid/leaderboard');
expect(load({ params: { guid: '101' }, url }).returnTo).toBe('/leaderboard');
const defaults = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101') });
expect(defaults).toMatchObject({ returnTo: '/leaderboard', scope: { game: '', map: '', metric: 'highestRound' } });
});
it('rejects invalid filter parameters and retains existing offset normalization and missing-player behavior', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
addMatches(db(), '101', 30);
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
for (const query of ['game=t7', 'game=', 'metric=', 'metric=unknown', `map=${'x'.repeat(1025)}`]) {
expect(() => load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) }))
.toThrow(expect.objectContaining({ status: 400 }));
}
for (const query of ['offset=garbage', 'offset=-1', 'offset=1.5', 'offset=Infinity']) {
expect(load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) }).profile.offset).toBe(0);
}
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=999') }).profile.offset).toBe(25);
expect(() => load({ params: { guid: 'missing' }, url: new URL('http://localhost/leaderboard/players/missing?game=t5') }))
.toThrow(expect.objectContaining({ status: 404 }));
});