From bcecc42dd12356a816b235c5c335349f25651d24 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 19:14:33 -0400 Subject: [PATCH] Focus player records and add squad leaderboards --- docs/guides/leaderboard.md | 28 ++- src/lib/leaderboard-navigation.ts | 10 +- src/lib/leaderboard.ts | 3 + src/lib/server/fleet/leaderboard-records.ts | 10 +- src/lib/server/fleet/leaderboard.ts | 25 +- src/lib/server/fleet/player-profile.ts | 7 +- src/lib/ui/components/Freshness.svelte | 17 ++ src/lib/ui/components/MatchHistory.svelte | 82 ++++--- src/lib/ui/components/PlayerMapCards.svelte | 69 ++---- src/lib/ui/components/PlayerMatchCards.svelte | 67 ++---- src/lib/ui/components/RecordSource.svelte | 32 +-- src/routes/api/leaderboard/+server.ts | 6 +- src/routes/leaderboard/+page.server.ts | 21 +- src/routes/leaderboard/+page.svelte | 193 +++++++-------- .../players/[guid]/+page.server.ts | 12 +- .../leaderboard/players/[guid]/+page.svelte | 219 ++++++++---------- src/routes/matches/+page.svelte | 4 +- tests/unit/leaderboard-navigation.test.ts | 13 +- tests/unit/leaderboard-page-load.test.ts | 29 ++- tests/unit/leaderboard-players.test.ts | 99 ++++++++ tests/unit/ui-leaderboard.test.ts | 35 ++- tests/unit/ui-match-history.test.ts | 18 +- 22 files changed, 569 insertions(+), 430 deletions(-) create mode 100644 src/lib/ui/components/Freshness.svelte create mode 100644 tests/unit/leaderboard-players.test.ts diff --git a/docs/guides/leaderboard.md b/docs/guides/leaderboard.md index a32f2f8..13a40c4 100644 --- a/docs/guides/leaderboard.md +++ b/docs/guides/leaderboard.md @@ -1,10 +1,14 @@ # Leaderboard Open **Leaderboard** in the main navigation to compare players across the fleet, -with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game -and map filters limit which matches contribute to each player's statistics and +with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game, map, +and starting squad-size filters limit which matches contribute to each player's statistics and rank. Choose a ranking, search a name or GUID, and share the page URL with its -filters intact. Results refresh while the page is visible. +filters intact. Results refresh while the page is visible. The page opens on a +recorded map by default; choose **All maps** for a fleet-wide comparison. A URL +with `map=` explicitly selects all maps. **Players** selects Solo, Duo, Trio or +Four players using the recorded starting roster; unknown starting counts appear +only under All squad sizes. The JSON API still defaults to all maps. ## Qualifying matches @@ -29,13 +33,13 @@ 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 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 +Profiles update every 30 seconds while visible; the freshness indicator offers Retry if an update fails. Ratios with a zero denominator 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, +Opening a player from the leaderboard preserves the originating game, map, squad size, metric, search and page in the **Leaderboard** return link. The profile's -**Selected leaderboard** controls initialize from that game/map/metric and show +**Filters** controls initialize from that game/map/squad/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 @@ -43,9 +47,9 @@ the selected ranking metric. A player without qualifying matches in a selection can still view their lifetime profile and choose another scope. On phones, per-map records and match history use compact cards with the map, -game, highest round and kills up front. Expand **All stats and record runs** -or **More match stats** for the remaining counters and source evidence. Desktop -profiles retain the complete tables; both views use the same history filters +game, record values and match identity up front. Record values link directly to +their completed match, where the full evidence is available. Desktop profiles +use compact tables; both views use the same history filters and pagination. History dates say **First observed**, since a missing start report can make the first recorded observation later than the actual game start. @@ -53,9 +57,8 @@ report can make the first recorded observation later than the actual game start. 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. -Each source shows its map, game, completion time, server, and whether the match -start was recorded. **Run details** shows when the player was first observed: -at the recorded start, at a reported round, or unknown for older stored records. +Each source names its match and map. Open the match to see completion time, +server, whether its start was recorded, and player participation evidence. Presence is not proof of continuous survival. Equal personal bests link to the earliest recorded completion, then the lowest match ID when times tie. @@ -206,6 +209,7 @@ Use URL encoding for map keys and search text. | --- | --- | --- | | `game` | Both games | Exactly `t5` or `t6`. An empty value is invalid. | | `map` | All maps | Opaque `maps[].key`, at most 1024 characters. Empty means no map filter. An unknown key or a game/map mismatch returns an empty selection, not an error. | +| `players` | All squad sizes | Exactly `1`, `2`, `3`, or `4`, matching the recorded starting player count. Unknown counts contribute only when this filter is absent. | | `metric` | `highestRound` | One of the metric keys below. An empty or unknown value is invalid. | | `q` | No search | At most 100 characters before trimming. Matches a literal substring of the current name or GUID, ignoring ASCII letter case. Surrounding whitespace is trimmed; whitespace-only input means no search. `%` and `_` are literal characters, not wildcards. | | `limit` | `50` | Integer from 1 to 100. | diff --git a/src/lib/leaderboard-navigation.ts b/src/lib/leaderboard-navigation.ts index 67c4759..ff23cb6 100644 --- a/src/lib/leaderboard-navigation.ts +++ b/src/lib/leaderboard-navigation.ts @@ -1,7 +1,7 @@ import { leaderboardMetrics } from './leaderboard.js'; const origin = 'https://electric-cherry.invalid'; -const contextKeys = ['game', 'map', 'metric', 'q', 'offset'] as const; +const contextKeys = ['game', 'map', 'players', 'metric', 'q', 'offset'] as const; /** Only retain a local leaderboard destination and its supported view state. */ export function leaderboardReturnHref(value: string | null | undefined): string { @@ -12,7 +12,7 @@ export function leaderboardReturnHref(value: string | null | undefined): string const params = new URLSearchParams(); for (const key of contextKeys) { const entry = url.searchParams.get(key); - if (entry) params.set(key, entry); + if (entry || key === 'map' && entry !== null) params.set(key, entry!); } return `/leaderboard${params.size ? `?${params}` : ''}`; } catch { return '/leaderboard'; } @@ -26,9 +26,11 @@ export function playerContextHref(guid: string, source: string): string { const game = original.get('game'); const map = original.get('map'); const metric = original.get('metric'); + const players = original.get('players'); // 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 (map !== null && map.length <= 1024) params.set('map', map); + if (players && /^[1-4]$/.test(players)) params.set('players', players); if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric); return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`; } @@ -37,7 +39,7 @@ export function profileViewHref(current: URL, changes: Record): const params = new URLSearchParams(current.search); params.delete('offset'); for (const [key, value] of Object.entries(changes)) { - if (value) params.set(key, value); + if (value || key === 'map') 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 8d3c3a2..f5f75d2 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -101,9 +101,12 @@ export interface LeaderboardMap { name: string; } +export type LeaderboardPlayerCount = 1 | 2 | 3 | 4; + export interface LeaderboardOptions { game?: string; map?: string; + players?: LeaderboardPlayerCount; metric?: LeaderboardMetric; q?: string; limit?: number; diff --git a/src/lib/server/fleet/leaderboard-records.ts b/src/lib/server/fleet/leaderboard-records.ts index ab2f9a2..8aacbd3 100644 --- a/src/lib/server/fleet/leaderboard-records.ts +++ b/src/lib/server/fleet/leaderboard-records.ts @@ -1,11 +1,12 @@ import type Database from 'better-sqlite3'; -import type { PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js'; +import type { LeaderboardPlayerCount, PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js'; interface RecordRequest extends Record { guid: string; game?: string; map?: string; + players?: LeaderboardPlayerCount; } /** Fetch evidence only for requested players and scopes, never rank the whole fleet's history. */ @@ -15,9 +16,9 @@ export function getPersonalBestRecords(handle: Database.Database, requests: Reco for (let offset = 0; offset < requests.length; offset += 100) { const batch = requests.slice(offset, offset + 100); const parameters = batch.flatMap((request, index) => [index, request.guid, request.game ?? null, - request.map ?? null, request.highestRound, request.bestKills, request.bestScore]); - const rows = handle.prepare(`WITH requested (requestId, guid, game, map, highestRound, bestKills, bestScore) AS ( - VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')} + request.map ?? null, request.players ?? null, request.highestRound, request.bestKills, request.bestScore]); + const rows = handle.prepare(`WITH requested (requestId, guid, game, map, players, highestRound, bestKills, bestScore) AS ( + VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ')} ), candidates AS ( SELECT r.requestId, m.id AS matchId, m.game, m.map_key AS mapKey, m.map_name AS mapName, m.server, m.started_at AS startedAt, m.ended_at AS endedAt, m.start_observed AS startObserved, @@ -30,6 +31,7 @@ export function getPersonalBestRecords(handle: Database.Database, requests: Reco 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 (r.game IS NULL OR m.game = r.game) AND (r.map IS NULL OR m.map_key = r.map) + AND (r.players IS NULL OR m.start_players = r.players) AND (mp.highest_round = r.highestRound OR mp.kills = r.bestKills OR mp.best_score = r.bestScore) ), valuesByMetric AS ( SELECT *, 'highestRound' AS metric, highestRound AS value FROM candidates WHERE highestRound = targetRound diff --git a/src/lib/server/fleet/leaderboard.ts b/src/lib/server/fleet/leaderboard.ts index 326fd51..5ee5228 100644 --- a/src/lib/server/fleet/leaderboard.ts +++ b/src/lib/server/fleet/leaderboard.ts @@ -32,6 +32,20 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric { return metrics.has(value); } +/** Pick a usable initial map without ranking every player across the fleet first. */ +export function defaultLeaderboardMap(options: Pick = {}): string | null { + const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", `m.highest_round >= ${MIN_RECORDED_ROUND}`, + `EXISTS (SELECT 1 FROM leaderboard_match_players mp WHERE mp.match_id = m.id + AND mp.has_stats = 1 AND mp.highest_round >= ${MIN_RECORDED_ROUND})`]; + const parameters: Array = []; + if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); } + if (options.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); } + const row = db().prepare(`SELECT m.map_key AS key FROM leaderboard_matches m + WHERE ${conditions.join(' AND ')} ORDER BY m.game, m.map_name COLLATE NOCASE, m.map_key, m.id DESC LIMIT 1`) + .get(...parameters) as { key: string } | undefined; + return row?.key ?? null; +} + /** Rank each GUID once within the selected matches; player search keeps their actual rank. */ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse { return readLeaderboard(options); @@ -39,13 +53,13 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes /** 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); + options: Pick = {}): LeaderboardEntry | null { + return readLeaderboard({ game: options.game, map: options.map, players: options.players, metric: options.metric, limit: 1 }, guid); } function addRecordSources(handle: ReturnType, entries: RankedEntry[], options: LeaderboardOptions): LeaderboardEntry[] { const records = getPersonalBestRecords(handle, entries.map(entry => ({ - ...entry, game: options.game || undefined, map: options.map || undefined + ...entry, game: options.game || undefined, map: options.map || undefined, players: options.players }))); return entries.map((entry, index) => ({ ...entry, records: records[index] })); } @@ -59,9 +73,10 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0; const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", 'mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`, `mp.highest_round >= ${MIN_RECORDED_ROUND}`]; - const parameters: string[] = []; + const parameters: Array = []; if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); } if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); } + if (options.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); } const qualification = isEfficiencyMetric(metric) ? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL` + (metric === 'headshotRate' ? ` AND measures.kills >= ${EFFICIENCY_MIN_KILLS}` : '') @@ -101,7 +116,7 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade catch { /* Standalone history tools can operate without cache metadata. */ } let cache = scopeCaches.get(handle); if (!cache) { cache = new Map(); scopeCaches.set(handle, cache); } - const cacheKey = JSON.stringify([options.game ?? '', options.map ?? '', metric]); + const cacheKey = JSON.stringify([options.game ?? '', options.map ?? '', options.players ?? '', metric]); let saved = cache.get(cacheKey); if (!saved || revision < 0 || saved.revision !== revision) { const rows = handle.prepare(`${aggregated}, playerSummary AS ( diff --git a/src/lib/server/fleet/player-profile.ts b/src/lib/server/fleet/player-profile.ts index ecda526..de66ef2 100644 --- a/src/lib/server/fleet/player-profile.ts +++ b/src/lib/server/fleet/player-profile.ts @@ -12,7 +12,7 @@ const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`; export function getPlayerProfile(guid: string, requestedOffset = 0, - options: Pick = {}): PlayerProfile | null { + 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, @@ -22,11 +22,12 @@ export function getPlayerProfile(guid: string, requestedOffset = 0, if (!player) return null; const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats; if (!stats.gamesPlayed) return null; - const historyParameters = [guid]; + const historyParameters: Array = [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 + if (options.players) { historyScope += ' AND m.start_players = ?'; historyParameters.push(options.players); } + const matchesTotal = options.game || options.map || options.players ? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total : stats.gamesPlayed; const limit = 25; diff --git a/src/lib/ui/components/Freshness.svelte b/src/lib/ui/components/Freshness.svelte new file mode 100644 index 0000000..3c1a621 --- /dev/null +++ b/src/lib/ui/components/Freshness.svelte @@ -0,0 +1,17 @@ + +
+ {#if error}{error}{#if onRetry}{/if} + {:else}{updatedAt === null ? 'Waiting for update' : `Live · updated ${ago}`}{intervalLabel ? ` · ${intervalLabel}` : ''}{/if} +
+ diff --git a/src/lib/ui/components/MatchHistory.svelte b/src/lib/ui/components/MatchHistory.svelte index 24861a7..81379c5 100644 --- a/src/lib/ui/components/MatchHistory.svelte +++ b/src/lib/ui/components/MatchHistory.svelte @@ -11,6 +11,8 @@ import { formatDuration } from '$ui/duration.js'; import Button from './primitives/Button.svelte'; import EmptyState from './primitives/EmptyState.svelte'; + import Dialog from './primitives/Dialog.svelte'; + import Freshness from './Freshness.svelte'; let { server, initial }: { server?: string; initial?: MatchHistoryResponse } = $props(); let data = $state(untrack(() => initial)); @@ -18,6 +20,8 @@ let error = $state(''); let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? '')); let retry = $state(0); + let filtersOpen = $state(false); + let checkedAt = $state(untrack(() => initial ? Date.now() : null)); let tableRegion = $state(); let pendingUrl: URL | undefined; let navigationId = 0; @@ -29,13 +33,14 @@ return `${endpoint}?${params}`; }); let loadedUrl = $state(untrack(() => initial ? apiUrl : '')); + const filterCount = $derived([filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length); const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status)); const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }]; $effect(() => { search = filters.q; }); $effect(() => { const report = initial; - if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; }); + if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); }); }); $effect(() => { const url = apiUrl; @@ -49,7 +54,7 @@ if (signal.aborted || url !== apiUrl) return; // Do not move keyboard users to another match while they are following a link. if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body; - loadedUrl = url; loading = false; error = ''; + loadedUrl = url; loading = false; error = ''; checkedAt = Date.now(); } catch (cause) { if (signal.aborted || url !== apiUrl) return; error = cause instanceof Error ? cause.message : 'Could not load match history.'; @@ -71,27 +76,33 @@
-
- {#if server === undefined} - - {/if} - - {#if server === undefined} -
{ event.preventDefault(); update({ q: search.trim() }); }}> - - Press Enter to search by instance name. -
- {/if} - -
More filters{filters.status ? ' (1)' : ''} - -
-
-
Times in {$timeZone}{filters.days ? ` · Matches started in the last ${filters.days} days` : ''}{#if hasFilters}{/if}
+ {#snippet filterFields(suffix: string)} +
+ {#if server === undefined}{/if} + + {#if server === undefined}
{ event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}> + + Press Enter to search by server name. +
{/if} + + + {#if suffix} + + + {/if} +
+ {/snippet} +
{@render filterFields('')}
+
{data?.total ?? '—'} matches{filters.map ? ` · ${data?.maps.find(map => map.key === filters.map)?.name ?? 'Selected map'}` : ''}
+ + {@render filterFields('-mobile')} + {#snippet actions()}{/snippet} + +
Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}{#if hasFilters}{/if}{#if !error}{/if}
{#if error}{/if} {#if loading} {:else if loadedUrl !== apiUrl && error} @@ -112,11 +123,11 @@ - {#if match.status === 'completed'}{match.map} + {#if match.status === 'completed'}{match.map} · Match #{match.id} {:else}{match.map}{/if} + · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''} {#if server === undefined}
- {#if !match.server_deleted_at}{matchInstanceLabel(match)} - {:else}{matchInstanceLabel(match)} · Historical instance{/if} + {matchInstanceLabel(match)}{#if match.server_deleted_at} · Historical instance{/if}
{/if} {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'} @@ -143,12 +154,10 @@ label, form { flex: 1; min-width: 145px; } label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); } input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); } - .more-filters { padding-top: 25px; font-size: var(--text-sm); } - .more-filters label { margin-top: 10px; } summary { cursor: pointer; color: var(--text-secondary); } .context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); } .pagination > div { display: flex; gap: var(--space-2); } - .table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); } + .table-scroll { position: relative; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); } .table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); } table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); } th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; } @@ -165,7 +174,6 @@ a:hover { text-decoration: underline; } a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; } .instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); } - .instance a { color: inherit; text-decoration: none; } .status { color: var(--text-secondary); } .status.active { color: var(--accent-primary-hover); } .status.interrupted { color: var(--state-warning); } @@ -173,5 +181,17 @@ .about p { max-width: 90ch; line-height: 1.6; } .error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } - @media (max-width: 640px) { .filters > label, .filters > form { flex-basis: 45%; min-width: 120px; } .more-filters { padding-top: 0; width: 100%; } th, td { padding: 10px 12px; } } + .mobile-filters, .mobile-match-context { display: none; } + @media (max-width: 640px) { + .desktop-filters { display: none; } + .mobile-match-context { display: block; } + .mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); } + .filters { display: grid; grid-template-columns: minmax(0, 1fr); } + .filters > label, .filters > form { min-width: 0; } + .context { gap: var(--space-1) var(--space-2); } + th, td { padding: 10px 12px; } + th:nth-child(1), td:nth-child(1) { display: none; } + th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; } + .match { min-width: 130px; } + } diff --git a/src/lib/ui/components/PlayerMapCards.svelte b/src/lib/ui/components/PlayerMapCards.svelte index dbe40ab..8245c81 100644 --- a/src/lib/ui/components/PlayerMapCards.svelte +++ b/src/lib/ui/components/PlayerMapCards.svelte @@ -1,66 +1,27 @@ -
{#each maps as map (`${map.game}:${map.key}`)} -
-
-

{gameName(map.game)}

-

{map.name}

-

Last played

-
-
-
Highest round
{number.format(map.highestRound)}
-
Total kills
{number.format(map.kills)}
-
-
- All stats and record runs -
- {#each otherMetrics as metric (metric.key)} -
{metric.label}
{number.format(map[metric.key])}
- {/each} -
-
- {#each recordMetrics as key (key)} -
-

{playerStatMetrics.find(metric => metric.key === key)!.label} · {number.format(map[key])}

- -
- {/each} -
-
+
+

{map.name}

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

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

{/each}
- diff --git a/src/lib/ui/components/PlayerMatchCards.svelte b/src/lib/ui/components/PlayerMatchCards.svelte index 4d26875..b99be45 100644 --- a/src/lib/ui/components/PlayerMatchCards.svelte +++ b/src/lib/ui/components/PlayerMatchCards.svelte @@ -1,64 +1,27 @@ -
{#each matches as match (match.id)} -
-
-

{gameName(match.game)}

-

{#if match.status === 'completed'}{match.mapName}{:else}{match.mapName}{/if}

-

{match.server} {#if match.server_deleted_at}Deleted{/if}

-
-
-
First observed
-
Match status
{match.status} · {match.completed ? 'Completion credited' : 'No completion credit'}
-
-
-
Highest round
{number.format(match.highestRound)}
-
Kills
{number.format(match.kills)}
-
-
- More match stats -
- {#each otherMetrics as key (key)} -
{playerStatMetrics.find(metric => metric.key === key)!.label}
{number.format(match[key])}
- {/each} -
-

Last observed

-

Match #{match.id}

-
+
+

{#if match.status === 'completed'}{match.mapName} · Match #{match.id}{:else}{match.mapName} · Match #{match.id}{/if}

+

{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}

+

· {match.completed ? 'Completion credited' : 'No completion credit'}

+
Round
{number.format(match.highestRound)}
Kills
{number.format(match.kills)}
Revives
{number.format(match.revives)}
{/each}
- diff --git a/src/lib/ui/components/RecordSource.svelte b/src/lib/ui/components/RecordSource.svelte index 6cd2320..7107ba9 100644 --- a/src/lib/ui/components/RecordSource.svelte +++ b/src/lib/ui/components/RecordSource.svelte @@ -2,36 +2,10 @@ import type { RecordEvidence } from '$lib/leaderboard.js'; import { timeFormat } from '$ui/stores/time-zone.js'; import { contextHref } from '$lib/navigation-context.js'; - - let { evidence, returnTo, disclosure = true }: { evidence: RecordEvidence; returnTo?: string; disclosure?: boolean } = $props(); - const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II'); + let { evidence, returnTo, metric = 'Record' }: { evidence: RecordEvidence; returnTo?: string; metric?: string; disclosure?: boolean } = $props(); - -
- {evidence.mapName} · {gameName} - {#if evidence.endedAt}Completed {:else}Completed · End time unknown{/if} - {#snippet runDetails()} -
-
Instance
{evidence.server}
-
{evidence.startObserved ? 'Match started' : 'Match first observed'}
-
Player participation
{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}
-
-

Recorded presence does not establish continuous survival.

- {/snippet} - {#if disclosure}
Run details{@render runDetails()}
- {:else}{@render runDetails()}{/if} -
- +Match #{evidence.matchId} · {evidence.mapName} diff --git a/src/routes/api/leaderboard/+server.ts b/src/routes/api/leaderboard/+server.ts index c3e7a70..14a2b98 100644 --- a/src/routes/api/leaderboard/+server.ts +++ b/src/routes/api/leaderboard/+server.ts @@ -1,6 +1,7 @@ import { leaderboardReport } from '$lib/server/reports/index.js'; import { json } from '@sveltejs/kit'; import { isLeaderboardMetric, MAX_LEADERBOARD_OFFSET } from '$lib/server/fleet/leaderboard.js'; +import type { LeaderboardPlayerCount } from '$lib/leaderboard.js'; export async function GET({ url }: { url: URL }) { const params = url.searchParams; @@ -8,6 +9,7 @@ export async function GET({ url }: { url: URL }) { const map = params.get('map'); const metric = params.get('metric'); const q = params.get('q'); + const players = params.get('players'); const rawLimit = params.get('limit'); const rawOffset = params.get('offset'); const integer = (value: string | null, fallback: number) => value === null ? fallback @@ -17,11 +19,13 @@ export async function GET({ url }: { url: URL }) { if ((game !== null && game !== 't5' && game !== 't6') || (map !== null && map.length > 1024) || (q !== null && q.length > 100) || (metric !== null && !isLeaderboardMetric(metric)) + || (players !== null && !/^[1-4]$/.test(players)) || !Number.isFinite(limit) || limit < 1 || limit > 100 || !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) { - return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 }); + return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, players 1–4, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 }); } return json(await leaderboardReport({ game: game ?? undefined, map: map || undefined, metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined, + players: players === null ? undefined : Number(players) as LeaderboardPlayerCount, q: q ?? undefined, limit, offset })); } diff --git a/src/routes/leaderboard/+page.server.ts b/src/routes/leaderboard/+page.server.ts index b53ff38..a8453e2 100644 --- a/src/routes/leaderboard/+page.server.ts +++ b/src/routes/leaderboard/+page.server.ts @@ -1,9 +1,26 @@ import { GET } from '../api/leaderboard/+server.js'; -import { error } from '@sveltejs/kit'; -import type { LeaderboardResponse } from '$lib/leaderboard.js'; +import { error, redirect } from '@sveltejs/kit'; +import type { LeaderboardPlayerCount, LeaderboardResponse } from '$lib/leaderboard.js'; import { leaderboardPageSize } from '$lib/leaderboard-updates.js'; +import { defaultLeaderboardMap } from '$lib/server/fleet/leaderboard.js'; export async function load({ url }: { url: URL }) { + // A present empty map is the explicit All maps view. The API remains + // unfiltered by default for callers such as global player search. + if (!url.searchParams.has('map')) { + const game = url.searchParams.get('game'); + const players = url.searchParams.get('players'); + if (game !== null && game !== 't5' && game !== 't6' || players !== null && !/^[1-4]$/.test(players)) { + error(400, 'Use game t5 or t6 and players 1–4.'); + } + const map = defaultLeaderboardMap({ game: game ?? undefined, players: players === null ? undefined : Number(players) as LeaderboardPlayerCount }); + if (map) { + const destination = new URL(url); + destination.searchParams.set('map', map); + destination.searchParams.delete('offset'); + redirect(307, destination.pathname + destination.search); + } + } const reportUrl = new URL(url); reportUrl.searchParams.set('limit', String(leaderboardPageSize)); const response = await GET({ url: reportUrl }); diff --git a/src/routes/leaderboard/+page.svelte b/src/routes/leaderboard/+page.svelte index 0a454fe..8a14e18 100644 --- a/src/routes/leaderboard/+page.svelte +++ b/src/routes/leaderboard/+page.svelte @@ -12,15 +12,17 @@ import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Button from '$ui/components/primitives/Button.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; - import CopyValue from '$ui/components/CopyValue.svelte'; + import Dialog from '$ui/components/primitives/Dialog.svelte'; + import Freshness from '$ui/components/Freshness.svelte'; import RecordSource from '$ui/components/RecordSource.svelte'; const number = new Intl.NumberFormat('en-US'); const pageSize = leaderboardPageSize; const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }]; - const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed']; + const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'gamesPlayed']; + const squadSizes = [{ key: '1', label: 'Solo' }, { key: '2', label: 'Duo' }, { key: '3', label: 'Trio' }, { key: '4', label: 'Four players' }]; const descriptions: Record = { - highestRound: 'The highest round reached in a recorded game.', + highestRound: 'The highest recorded round, including players who joined mid-game.', kills: 'Cumulative kills across recorded games.', roundsSurvived: 'Round progression during observed participation, added across games.', headshots: 'Cumulative headshots across recorded games.', @@ -41,9 +43,10 @@ let loading = $state(false); let error = $state(''); let search = $state(untrack(() => ($page.url.searchParams.get('q') ?? '').trim().slice(0, 100))); - let checkedAt = $state(null); + let checkedAt = $state(untrack(() => initial.leaderboard ? new Date() : null)); let pendingUpdate = $state(null); - let expandedPlayers = $state([]); + let filtersOpen = $state(false); + let showAllStats = $state(false); let tableRegion = $state(); let controller: AbortController | undefined; let requestId = 0; @@ -53,6 +56,8 @@ const game = $derived(games.some(item => item.key === $page.url.searchParams.get('game')) ? $page.url.searchParams.get('game')! : ''); const map = $derived($page.url.searchParams.get('map') ?? ''); + const players = $derived(squadSizes.some(size => size.key === $page.url.searchParams.get('players')) ? $page.url.searchParams.get('players')! : ''); + const playersLabel = $derived(squadSizes.find(size => size.key === players)?.label ?? 'All squad sizes'); const query = $derived(($page.url.searchParams.get('q') ?? '').trim().slice(0, 100)); const metric = $derived(leaderboardMetrics.find(item => item.key === $page.url.searchParams.get('metric'))?.key ?? 'highestRound'); const offset = $derived(Math.max(0, Math.min(1_000_000, Math.floor(Number($page.url.searchParams.get('offset')) || 0)))); @@ -60,11 +65,13 @@ const mapName = $derived(maps.find(item => item.key === map)?.name); const scope = $derived(mapName ?? (game ? games.find(item => item.key === game)!.label : 'All games & maps')); const metricLabel = $derived(leaderboardMetrics.find(item => item.key === metric)!.label); - const columns = $derived([metric, ...defaultColumns.filter(key => key !== metric)]); + const columns = $derived([metric, ...(showAllStats ? playerStatMetrics.map(item => item.key) : defaultColumns).filter(key => key !== metric)]); + const filterCount = $derived([game, map, players, query].filter(Boolean).length); const apiUrl = $derived.by(() => { const params = new URLSearchParams({ metric, limit: String(pageSize), offset: String(offset) }); if (game) params.set('game', game); - if (map) params.set('map', map); + params.set('map', map); + if (players) params.set('players', players); if (query) params.set('q', query); return `/api/leaderboard?${params}`; }); @@ -76,8 +83,8 @@ const report = initial.leaderboard; untrack(() => { ++requestId; controller?.abort(); data = report; maps = report.maps; - loadedUrl = apiUrl; loading = false; error = ''; checkedAt = null; - pendingUpdate = null; expandedPlayers = []; updates.reset(apiUrl, report); + loadedUrl = apiUrl; loading = false; error = ''; checkedAt = new Date(); + pendingUpdate = null; updates.reset(apiUrl, report); }); }); @@ -107,8 +114,7 @@ if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`); const result: LeaderboardResponse = await response.json(); if (id !== requestId || url !== apiUrl || changingFilters || pendingUrl) return; - const inspecting = expandedPlayers.length > 0 || !!tableRegion?.contains(document.activeElement) - || !!tableRegion?.querySelector('details[open]'); + const inspecting = !!tableRegion?.contains(document.activeElement); const next = updates.receive(url, result, inspecting); if (!next) return; data = next.current; @@ -126,25 +132,18 @@ } function applyUpdate() { - expandedPlayers = []; - tableRegion?.querySelectorAll('details[open]').forEach(detail => detail.removeAttribute('open')); data = updates.apply(); maps = data.maps; pendingUpdate = null; } - function togglePlayer(guid: string) { - expandedPlayers = expandedPlayers.includes(guid) - ? expandedPlayers.filter(value => value !== guid) : [...expandedPlayers, guid]; - } - - function updateFilters(changes: Record) { + function updateFilters(changes: Record) { ++requestId; controller?.abort(); loading = false; // Keep rapid changes together while the preceding navigation settles. const url = new URL(pendingUrl ?? $page.url); url.searchParams.delete('offset'); for (const [key, value] of Object.entries(changes)) { - if (value) url.searchParams.set(key, value); + if (value || key === 'map' && value !== null) url.searchParams.set(key, value); else url.searchParams.delete(key); } pendingUrl = url; @@ -156,7 +155,7 @@ function clearFilters() { search = ''; - updateFilters({ game: '', map: '', q: '' }); + updateFilters({ game: '', map: null, players: '', q: '' }); } @@ -166,40 +165,49 @@
- - {#snippet actions()} - - {/snippet} + + {#snippet actions()}
{/snippet}
-
-
- + {#snippet filterFields(suffix: string)} +
+
+ +
+
+ +
+
+ Players recorded at the start of each match. Matches with unknown starting counts appear only under All squad sizes. +
+
+ +
+
-
- -
-
- -
- -
+ {/snippet} +
{@render filterFields('')}
+ + {@render filterFields('-mobile')} + {#snippet actions()}{/snippet} +
-
{scope}All time
- {#if game || map || query}{/if} +
{scope}{playersLabel}All time
+ {#if game || players || query}{/if}
{#if error} @@ -215,13 +223,13 @@
Players{number.format(data.summary.players)}Unique players
-
+

{metricLabel}

{#if isEfficiencyMetric(metric)}

{efficiencyRequirement(metric)} within the selected game and map.

{/if} -
About this ranking

{description(metric)} Equal values share a rank.

+

{description(metric)} Equal values share a rank.

-
{changingFilters ? 'Updating filters…' : loading ? 'Checking…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : 'Checks every 15 seconds'}
+
{#if !error}{/if}
{#if pendingUpdate}
{/if} {#if data.entries.length} @@ -244,7 +252,7 @@ {number.format(entry.rank)} {entry.name} - + {#each columns as key (key)} @@ -252,24 +260,12 @@ {#if isEfficiencyMetric(key)} {efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games {/if} - {#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'} - {entry.records[key].mapName} -
- Record details for {entry.name} - -
+ {#if key === metric && (key === 'highestRound' || key === 'bestKills' || key === 'bestScore')} + {/if} {/each} - {#if expandedPlayers.includes(entry.guid)} - -
GUID
-
{#each playerStatMetrics.filter(item => !columns.includes(item.key)) as stat (stat.key)} -
{stat.label}
{formatValue(entry, stat.key)}
- {/each}
- - {/if} {/each} @@ -282,9 +278,9 @@ {#snippet actions()}{/snippet} - {:else if query || game || map || offset} - - {#snippet actions()}{/snippet} + {:else if query || game || map || players || offset} + + {#snippet actions()}{/snippet} {:else} @@ -294,11 +290,12 @@
Loading player records…
{/if} + {#if data}
Selection totals
Highest round
{number.format(data.summary.highestRound)}
Total kills
{number.format(data.summary.kills)}
Total rounds
{number.format(data.summary.roundsSurvived)}
Games tracked
{number.format(data.summary.matches)}
Players
{number.format(data.summary.players)}
{/if}
How these records work

Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name changes keep a player's records together. The latest reported name across both games appears here and on the player's profile, regardless of filters.

-

Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game and map filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.

+

Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game, map, and starting squad-size filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.

Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.

Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.

Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: .{/if}

@@ -308,24 +305,18 @@ diff --git a/src/routes/matches/+page.svelte b/src/routes/matches/+page.svelte index ae1ed7d..2ab1060 100644 --- a/src/routes/matches/+page.svelte +++ b/src/routes/matches/+page.svelte @@ -4,10 +4,10 @@ import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; -Match History · Electric Cherry +Matches · Electric Cherry
- +
diff --git a/tests/unit/leaderboard-navigation.test.ts b/tests/unit/leaderboard-navigation.test.ts index c3d3e7d..78b9726 100644 --- a/tests/unit/leaderboard-navigation.test.ts +++ b/tests/unit/leaderboard-navigation.test.ts @@ -22,11 +22,22 @@ describe('leaderboard profile navigation', () => { 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('map')).toBe(''); expect(filtered.searchParams.get('game')).toBe('t6'); expect(filtered.searchParams.get('returnTo')).toBe('/leaderboard?q=Ace'); }); + it('preserves explicit all maps and squad scope through profile links and paging', () => { + const source = '/leaderboard?map=&players=2&metric=highestRound'; + expect(leaderboardReturnHref(source)).toBe(source); + const profile = new URL(playerContextHref('101', source), 'http://localhost'); + expect(profile.searchParams.get('map')).toBe(''); + expect(profile.searchParams.get('players')).toBe('2'); + const next = new URL(profileViewHref(profile, { offset: '25' }), profile); + expect(next.searchParams.get('map')).toBe(''); + expect(next.searchParams.get('players')).toBe('2'); + }); + 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'); diff --git a/tests/unit/leaderboard-page-load.test.ts b/tests/unit/leaderboard-page-load.test.ts index b1e19fe..e372d57 100644 --- a/tests/unit/leaderboard-page-load.test.ts +++ b/tests/unit/leaderboard-page-load.test.ts @@ -1,15 +1,38 @@ -import { expect, it, vi } from 'vitest'; +import { beforeEach, expect, it, vi } from 'vitest'; import { GET } from '../../src/routes/api/leaderboard/+server.js'; import { load } from '../../src/routes/leaderboard/+page.server.js'; +import { defaultLeaderboardMap } from '../../src/lib/server/fleet/leaderboard.js'; vi.mock('../../src/routes/api/leaderboard/+server.js', () => ({ GET: vi.fn() })); +vi.mock('../../src/lib/server/fleet/leaderboard.js', () => ({ defaultLeaderboardMap: vi.fn() })); +beforeEach(() => { vi.clearAllMocks(); }); it('requests the same 25-player page on initial load and preserves filters without mutating the page URL', async () => { vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], limit: 25, offset: 25 }))); - const url = new URL('http://localhost/leaderboard?game=t5&map=theater&metric=kills&q=123&offset=25&limit=100'); + const url = new URL('http://localhost/leaderboard?game=t5&map=theater&players=2&metric=kills&q=123&offset=25&limit=100'); const original = url.href; const data = await load({ url }); const requested = vi.mocked(GET).mock.calls[0][0].url; expect(requested.searchParams.get('limit')).toBe('25'); - for (const key of ['game', 'map', 'metric', 'q', 'offset']) expect(requested.searchParams.get(key)).toBe(url.searchParams.get(key)); + for (const key of ['game', 'map', 'players', 'metric', 'q', 'offset']) expect(requested.searchParams.get(key)).toBe(url.searchParams.get(key)); expect(url.href).toBe(original); expect(data.leaderboard.limit).toBe(25); }); + +it('opens a missing map at a recorded map in the selected game and squad without a whole-fleet report', async () => { + vi.mocked(defaultLeaderboardMap).mockReturnValue('t6:town'); + const url = new URL('http://localhost/leaderboard?game=t6&players=2&metric=kills&q=Cherry&offset=25'); + const original = url.href; + await expect(load({ url })).rejects.toMatchObject({ status: 307, location: '/leaderboard?game=t6&players=2&metric=kills&q=Cherry&map=t6%3Atown' }); + expect(defaultLeaderboardMap).toHaveBeenCalledWith({ game: 't6', players: 2 }); + expect(GET).not.toHaveBeenCalled(); expect(url.href).toBe(original); +}); + +it('honors explicit All maps and handles an empty recorded-map catalog without redirecting', async () => { + vi.mocked(GET).mockResolvedValue(new Response(JSON.stringify({ entries: [], maps: [], limit: 25 }))); + await load({ url: new URL('http://localhost/leaderboard?map=&players=3') }); + expect(defaultLeaderboardMap).not.toHaveBeenCalled(); + expect(vi.mocked(GET).mock.calls[0][0].url.searchParams.get('map')).toBe(''); + vi.mocked(defaultLeaderboardMap).mockReturnValue(null); + vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], maps: [], limit: 25 }))); + expect(await load({ url: new URL('http://localhost/leaderboard') })).toMatchObject({ leaderboard: { entries: [], maps: [] } }); + expect(defaultLeaderboardMap).toHaveBeenCalledOnce(); +}); diff --git a/tests/unit/leaderboard-players.test.ts b/tests/unit/leaderboard-players.test.ts new file mode 100644 index 0000000..b0a9283 --- /dev/null +++ b/tests/unit/leaderboard-players.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, 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 () => { await fixture.cleanup(); }); + +function seed(handle: Database.Database, id: number, players: number | null, options: { + guid?: string; round?: number; game?: string; map?: string; mapName?: string; status?: string; hasStats?: number; +} = {}) { + const { guid = 'ace', round = 20, game = 't5', map = 'theater', mapName = 'Kino Der Toten', status = 'completed', hasStats = 1 } = options; + const timestamp = new Date(Date.UTC(2026, 9, 1, 0, id)).toISOString(); + handle.prepare(`INSERT INTO leaderboard_matches + (id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_players,highest_round,last_event,last_fingerprint) + VALUES (?,'removed-server',?,?,?,?,?,?,?,?,?,?,'game_end','fixture')`) + .run(id, game, map, `${game}:${map}`, mapName, timestamp, timestamp, timestamp, status, players, round); + handle.prepare(`INSERT OR IGNORE INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)`) + .run(game, guid, guid, timestamp, timestamp); + handle.prepare(`INSERT INTO leaderboard_match_players + (match_id,guid,highest_round,kills,best_score,rounds_survived,has_stats,completed,first_seen_at,last_seen_at) + VALUES(?,?,?,100,1000,?, ?,1,?,?)`).run(id, guid, round, round, hasStats, timestamp, timestamp); +} + +describe('leaderboard starting-player scopes', () => { + it('separates each squad and its cache, with unknown starts available only in All players', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + seed(db(), 1, null); + for (const players of [1, 2, 3, 4] as const) seed(db(), players + 1, players, { round: players < 3 ? 20 : players * 10 }); + seed(db(), 6, 1, { guid: 'blake', round: 30 }); + seed(db(), 7, 2, { guid: 'blake', round: 10 }); + seed(db(), 8, null, { guid: 'legacy', round: 99 }); + const { getLeaderboard, getPlayerLeaderboardEntry } = await import('../../src/lib/server/fleet/leaderboard.js'); + const all = getLeaderboard(); + expect(all).toMatchObject({ total: 3, summary: { matches: 8, highestRound: 99 } }); + for (const players of [1, 2, 3, 4, 2, 1] as const) { + const board = getLeaderboard({ players }); + expect(board.summary.matches).toBe(players < 3 ? 2 : 1); + expect(board.entries.some(entry => entry.guid === 'legacy')).toBe(false); + const ace = board.entries.find(entry => entry.guid === 'ace')!; + expect(ace.gamesPlayed).toBe(1); + expect(ace.rank).toBe(players === 1 ? 2 : 1); + // All three equal-valued records must come from this squad, not + // the earlier unknown-start or solo match with matching values. + for (const record of Object.values(ace.records)) expect(record.matchId).toBe(players + 1); + expect(getPlayerLeaderboardEntry('ace', { players })).toEqual(ace); + } + expect(getLeaderboard()).toEqual(all); + }); + + it('filters profile history and rank while keeping explicitly lifetime records intact', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + for (let id = 1; id <= 30; id++) seed(db(), id, 2); + seed(db(), 31, 1, { round: 40 }); seed(db(), 32, null, { round: 99 }); + const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js'); + const lifetime = getPlayerProfile('ace')!; + const first = getPlayerProfile('ace', 0, { players: 2 })!; + const last = getPlayerProfile('ace', 999, { players: 2, map: 't5:theater', game: 't5' })!; + expect(first).toMatchObject({ matchesTotal: 30, offset: 0 }); expect(first.matches).toHaveLength(25); + expect(last).toMatchObject({ matchesTotal: 30, offset: 25 }); expect(last.matches).toHaveLength(5); + expect(new Set([...first.matches, ...last.matches].map(match => match.id))).toEqual(new Set(Array.from({ length: 30 }, (_, i) => i + 1))); + expect(first.stats).toEqual(lifetime.stats); expect(first.records).toEqual(lifetime.records); expect(first.maps).toEqual(lifetime.maps); + expect(getPlayerProfile('ace', 25, { players: 4 })).toMatchObject({ matchesTotal: 0, matches: [], offset: 0, stats: lifetime.stats }); + const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js'); + const scoped = await load({ params: { guid: 'ace' }, url: new URL('http://localhost/leaderboard/players/ace?players=2&map=t5%3Atheater') }); + expect(scoped).toMatchObject({ profile: { matchesTotal: 30, stats: { gamesPlayed: 32, highestRound: 99 } }, + scope: { players: '2', entry: { gamesPlayed: 30, highestRound: 20, records: { highestRound: { matchId: 1 } } } } }); + }); + + it('chooses a deterministic usable default map within game and squad, ignoring unfinished and statless records', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + seed(db(), 1, 2); seed(db(), 2, 3, { map: 'moon', mapName: 'Moon' }); + seed(db(), 3, 4, { game: 't6', map: 'town', mapName: 'Town' }); + seed(db(), 4, 2, { map: 'empty', mapName: 'A statless map', hasStats: 0 }); + seed(db(), 5, 2, { map: 'active', mapName: 'A match in progress', status: 'active' }); + const { defaultLeaderboardMap } = await import('../../src/lib/server/fleet/leaderboard.js'); + expect(defaultLeaderboardMap()).toBe('t5:theater'); + expect(defaultLeaderboardMap({ players: 3 })).toBe('t5:moon'); + expect(defaultLeaderboardMap({ game: 't6' })).toBe('t6:town'); + expect(defaultLeaderboardMap({ game: 't5', players: 4 })).toBeNull(); + }); + + it('keeps API absent-map searches global while filtering all contributions by the selected squad', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + seed(db(), 1, 2); seed(db(), 2, 2, { map: 'moon', mapName: 'Moon' }); seed(db(), 3, null, { round: 99 }); + const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); + expect(await (await GET({ url: new URL('http://localhost/api/leaderboard?players=2') })).json()) + .toMatchObject({ summary: { matches: 2, highestRound: 20 }, entries: [{ gamesPlayed: 2 }] }); + expect(await (await GET({ url: new URL('http://localhost/api/leaderboard') })).json()) + .toMatchObject({ summary: { matches: 3, highestRound: 99 } }); + for (const players of ['0', '5', '', '2.0', '-1', 'two', '01']) { + const response = await GET({ url: new URL(`http://localhost/api/leaderboard?players=${players}`) }); + expect(response.status).toBe(400); + } + const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js'); + await expect(load({ params: { guid: 'ace' }, url: new URL('http://localhost/leaderboard/players/ace?players=5') })) + .rejects.toMatchObject({ status: 400 }); + }); +}); diff --git a/tests/unit/ui-leaderboard.test.ts b/tests/unit/ui-leaderboard.test.ts index b586951..1d6de13 100644 --- a/tests/unit/ui-leaderboard.test.ts +++ b/tests/unit/ui-leaderboard.test.ts @@ -17,14 +17,16 @@ function scope(search = '') { (page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost/leaderboard${search}`) }); } function html(data = report()) { return render(Leaderboard, { props: { data: { leaderboard: data, user: null, setupRequired: false } } }).body; } -beforeEach(() => scope()); +beforeEach(() => scope('?map=')); -it('shows prominent totals and restored stat columns, preserving ranks and collapsed identity and evidence', () => { +it('shows compact totals and core stat columns, preserving ranks without repeated row disclosures', () => { const body = html(); const headers = body.slice(body.indexOf('')); - expect(headers.match(/scope="col"/g)).toHaveLength(9); + expect(headers.match(/scope="col"/g)).toHaveLength(6); expect(headers).toContain('Highest round'); expect(headers).toContain('Total kills'); expect(headers).toContain('Games played'); - for (const label of ['Total rounds', 'Headshots', 'Revives', 'Downs']) expect(headers).toContain(label); + expect(headers).toContain('Total rounds'); + for (const label of ['Headshots', 'Revives', 'Downs', 'Games completed', 'Best game kills', 'Highest reported score']) expect(body).toContain(label); + expect(body).toContain('Show all stats'); expect(body).toMatch(/]*>27<\/span>/); expect(body).toContain('26–26 of 60 players'); const overview = body.slice(body.indexOf('class="overview'), body.indexOf('class="rankings')); @@ -34,7 +36,12 @@ it('shows prominent totals and restored stat columns, preserving ranks and colla expect(overview).not.toContain(']*open/); expect(body).not.toContain('Copy GUID'); expect(body).not.toContain('Every round counts'); - expect(body).toContain('Record details'); expect(body).toContain('First observed at round 8'); + expect(body).not.toContain('Record details'); expect(body).not.toContain('Hide details'); + const rows = body.slice(body.indexOf('')); + expect(rows.match(/Search'); }); @@ -43,7 +50,7 @@ it('deduplicates the selected metric and preserves scope and pagination on playe const data = report(); data.metric = 'kills'; const body = html(data); const headers = body.slice(body.indexOf('')); - expect(headers.match(/scope="col"/g)).toHaveLength(8); + expect(headers.match(/scope="col"/g)).toHaveLength(5); expect(body).toContain('offset%3D25'); expect(body).toContain('q%3DCherry'); scope('?game=t5&map=t5%3Atheater&metric=highestRound&q=Cherry&offset=25'); const recordBody = html(); @@ -52,6 +59,22 @@ it('deduplicates the selected metric and preserves scope and pagination on playe expect(recordBody).toContain('Selection record'); expect(recordBody).not.toContain('Fleet record'); }); +it('provides separate mobile filter controls and preserves explicit all-map and squad context', () => { + scope('?map=&players=2&metric=highestRound&offset=25'); + const body = html(); + expect(body).toContain('Leaderboard filters'); + expect(body).toContain('id="leaderboard-players-mobile"'); + expect(body).toContain('value="2" selected=""'); + expect(body).toContain('All squad sizes'); + expect(body).toContain('Solo'); expect(body).toContain('Duo'); expect(body).toContain('Trio'); expect(body).toContain('Four players'); + expect(body).toContain('unknown starting counts appear only under All squad sizes'); + expect(body).toContain('players%3D2'); + expect(body).toContain('map%3D'); + const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); + expect(new Set(ids).size).toBe(ids.length); + expect(body).not.toMatch(/>Refresh<\/span>/); +}); + it('keeps qualification and denominator visible for efficiency rankings and missing values distinct', () => { scope('?metric=headshotRate'); const data = report(); data.metric = 'headshotRate'; diff --git a/tests/unit/ui-match-history.test.ts b/tests/unit/ui-match-history.test.ts index d6e5a23..1c22546 100644 --- a/tests/unit/ui-match-history.test.ts +++ b/tests/unit/ui-match-history.test.ts @@ -15,7 +15,7 @@ beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026- afterEach(() => vi.useRealTimers()); it('renders one page heading, six columns, a combined identity, and compact dates', () => { const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body; - expect(body.match(/]/g)).toHaveLength(1); + expect(body.match(/]/g)).toHaveLength(1); expect(body.match(/scope="col"/g)).toHaveLength(6); expect(body).toContain('Today, 1:26 PM'); expect(body).toContain('Lobby 1 · Black Ops II'); expect(body).not.toContain('>Server'); expect(body).not.toContain('Show 25 more'); @@ -28,7 +28,21 @@ it('preserves filters and page in match links and avoids linking deleted instanc expect(body).toContain('/matches/1?returnTo=%2Fmatches'); expect(body).toContain('matchPage%3D2'); expect(body).toContain('matchSort%3Dround'); expect(body).toContain('Historical instance'); expect(body).not.toContain('href="/servers/'); expect(body).not.toContain('>Deleted<'); - expect(body).toContain('26–26 of 40 matches'); expect(body).toContain('More filters (1)'); + expect(body).toContain('26–26 of 40 matches'); expect(body).toContain('Filters · 2'); +}); +it('keeps mobile filters and sorting in one labelled sheet with distinct controls and one named match link', () => { + const body = render(MatchHistory, { props: { initial: report() } }).body; + expect(body).toContain('Match filters'); + expect(body).toContain('id="match-map-mobile"'); + expect(body).toContain('id="match-sort-mobile"'); + expect(body).toContain('id="match-order-mobile"'); + expect(body).toContain('Show results'); + const rows = body.slice(body.indexOf('')); + expect(rows.match(/ match[1]); + expect(new Set(ids).size).toBe(ids.length); + expect(body).toContain('Updates every 10 seconds'); }); it('labels unfinished rounds and missing durations without inventing final results or detail links', () => { const data = report(); data.matches = [{ ...match, status: 'interrupted', finalRound: null, durationMs: null, uniquePlayers: 0 }];