diff --git a/docs/leaderboard.md b/docs/leaderboard.md index d55854e..e67fe8c 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -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. diff --git a/src/lib/leaderboard-navigation.ts b/src/lib/leaderboard-navigation.ts new file mode 100644 index 0000000..67c4759 --- /dev/null +++ b/src/lib/leaderboard-navigation.ts @@ -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 { + 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}` : ''}`; +} diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts index 5492f68..8d3c3a2 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -67,6 +67,7 @@ export interface PlayerProfile { records: PersonalBestRecords; maps: PlayerMapRecord[]; matches: PlayerMatch[]; + matchesTotal: number; offset: number; limit: number; } diff --git a/src/lib/server/fleet/leaderboard.ts b/src/lib/server/fleet/leaderboard.ts index 5c2dccd..be5dd9d 100644 --- a/src/lib/server/fleet/leaderboard.ts +++ b/src/lib/server/fleet/leaderboard.ts @@ -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 = {}): 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>; diff --git a/src/lib/server/fleet/player-profile.ts b/src/lib/server/fleet/player-profile.ts index a84f7c0..ecda526 100644 --- a/src/lib/server/fleet/player-profile.ts +++ b/src/lib/server/fleet/player-profile.ts @@ -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 = {}): 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 }; } diff --git a/src/routes/leaderboard/+page.svelte b/src/routes/leaderboard/+page.svelte index cf1d38a..44ad8cb 100644 --- a/src/routes/leaderboard/+page.svelte +++ b/src/routes/leaderboard/+page.svelte @@ -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 @@ {number.format(entry.rank)} - {entry.name} + {entry.name}
{#each columns as key (key)}{formatValue(entry, key)}{#if isEfficiencyMetric(key)}{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games{/if}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}{/if}{/each} diff --git a/src/routes/leaderboard/players/[guid]/+page.server.ts b/src/routes/leaderboard/players/[guid]/+page.server.ts index b977efa..0662449 100644 --- a/src/routes/leaderboard/players/[guid]/+page.server.ts +++ b/src/routes/leaderboard/players/[guid]/+page.server.ts @@ -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) } }; } diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index e015d72..1f0c3c6 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -1,23 +1,51 @@