Preserve leaderboard context in player profiles
This commit is contained in:
+11
-1
@@ -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.
|
||||
|
||||
@@ -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}` : ''}`;
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export interface PlayerProfile {
|
||||
records: PersonalBestRecords;
|
||||
maps: PlayerMapRecord[];
|
||||
matches: PlayerMatch[];
|
||||
matchesTotal: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
@@ -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'>>;
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
Reference in New Issue
Block a user