From bd34c3c9e71cdff611009e3ebd58f929ac3d5325 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 10 Oct 2026 13:50:53 -0400 Subject: [PATCH] Show profiles for players without qualifying matches --- docs/guides/leaderboard.md | 8 +- src/lib/leaderboard.ts | 2 +- src/lib/server/fleet/match-history.ts | 10 +- src/lib/server/fleet/player-profile.ts | 26 +++- .../players/[guid]/+page.server.ts | 2 +- .../leaderboard/players/[guid]/+page.svelte | 130 ++++++++++-------- tests/unit/leaderboard-ingest.test.ts | 81 ++++++++++- tests/unit/leaderboard-query.test.ts | 18 ++- tests/unit/leaderboard-records.test.ts | 12 +- tests/unit/match-detail.test.ts | 13 +- tests/unit/ui-player-map-records.test.ts | 56 ++++++++ 11 files changed, 271 insertions(+), 87 deletions(-) diff --git a/docs/guides/leaderboard.md b/docs/guides/leaderboard.md index af2e9dd..345594f 100644 --- a/docs/guides/leaderboard.md +++ b/docs/guides/leaderboard.md @@ -13,7 +13,7 @@ only under All squad sizes. The JSON API also defaults to all maps. ## Qualifying matches Matches only appear in server/global history once they reach round 2. -Leaderboards, summaries, map options, profiles and player history include only +Leaderboards, summaries, map options, profile statistics and player history include only completed matches with a received `game_end`. Active and interrupted matches do not contribute. A player must also be observed at round 2 or later; players who left before a completed game's end retain their recorded stats. @@ -24,6 +24,12 @@ qualifying games keep their start time, roster and cumulative counters. ### Profiles and filters +Players have a profile as soon as they are first reported, even before they have +qualifying match history. These profiles show their identity, an explanation of +when statistics become available, and dashes for personal bests with no recorded +match. The page fills in on refresh after a qualifying game ends. Unknown player +GUIDs still return 404. + Click a player's name to open their shareable profile at `/leaderboard/players/[guid]`. Profiles combine Black Ops I (T5) and Black Ops II (T6) records for the same GUID. Existing `/leaderboard/players/[game]/[guid]` diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts index 4458a0a..b776ee5 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -65,7 +65,7 @@ export interface PlayerProfile { firstSeen: string; lastSeen: string; stats: PlayerStats; - records: PersonalBestRecords; + records: PersonalBestRecords | null; maps: PlayerMapRecord[]; matches: PlayerMatch[]; matchesTotal: number; diff --git a/src/lib/server/fleet/match-history.ts b/src/lib/server/fleet/match-history.ts index 453eb2d..14f983c 100644 --- a/src/lib/server/fleet/match-history.ts +++ b/src/lib/server/fleet/match-history.ts @@ -120,14 +120,8 @@ export function getMatchDetail(id: number): MatchDetail | null { CASE WHEN p.has_stats = 1 THEN p.downs END AS downs, CASE WHEN p.has_stats = 1 THEN p.revives END AS revives, CASE WHEN p.has_stats = 1 THEN p.best_score END AS bestScore, - (EXISTS (SELECT 1 FROM leaderboard_players identity - WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) - AND EXISTS (SELECT 1 FROM leaderboard_match_players recorded - JOIN leaderboard_matches history ON history.id = recorded.match_id - WHERE recorded.guid = p.guid AND history.game IN ('t5', 't6') AND recorded.has_stats = 1 - AND history.status = 'completed' - AND history.highest_round >= ${MIN_RECORDED_ROUND} - AND recorded.highest_round >= ${MIN_RECORDED_ROUND})) AS canViewProfile + EXISTS (SELECT 1 FROM leaderboard_players identity + WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) AS canViewProfile FROM leaderboard_match_players p WHERE p.match_id = ? ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid` ) diff --git a/src/lib/server/fleet/player-profile.ts b/src/lib/server/fleet/player-profile.ts index 6f8442e..54d085d 100644 --- a/src/lib/server/fleet/player-profile.ts +++ b/src/lib/server/fleet/player-profile.ts @@ -36,7 +36,30 @@ export function getPlayerProfile( const { matchCount, ...stats } = handle .prepare(`SELECT COUNT(*) AS matchCount, ${totals} ${scope}`) .get(guid) as PlayerStats & { matchCount: number }; - if (!matchCount) return null; + const limit = 25; + if (!matchCount) { + return { + ...player, + stats: { + highestRound: 0, + kills: 0, + roundsSurvived: 0, + headshots: 0, + revives: 0, + downs: 0, + gamesPlayed: 0, + gamesCompleted: 0, + bestKills: 0, + bestScore: 0 + }, + records: null, + maps: [], + matches: [], + matchesTotal: 0, + offset: 0, + limit + }; + } const historyParameters: Array = [guid]; let historyScope = scope; if (options.game) { @@ -59,7 +82,6 @@ export function getPlayerProfile( .get(...historyParameters) as { total: number } ).total : matchCount; - const limit = 25; const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit); const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) diff --git a/src/routes/leaderboard/players/[guid]/+page.server.ts b/src/routes/leaderboard/players/[guid]/+page.server.ts index b916d1e..df67413 100644 --- a/src/routes/leaderboard/players/[guid]/+page.server.ts +++ b/src/routes/leaderboard/players/[guid]/+page.server.ts @@ -35,7 +35,7 @@ export async function load({ params, url }: { params: { guid: string }; url: URL Number(url.searchParams.get('offset') ?? 0), options ); - if (!profile) error(404, 'No recorded stats found for this player'); + if (!profile) error(404, 'Player not found'); return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')), diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index 7a433a7..aaec566 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -156,6 +156,12 @@ onRetry={() => void refresh()} />{/snippet} + {#if !profile.records} + + {/if}