Show profiles for players without qualifying matches
CI / checks (push) Successful in 4m56s

This commit is contained in:
2026-10-10 13:50:53 -04:00
parent cc9c10226e
commit bd34c3c9e7
11 changed files with 271 additions and 87 deletions
+7 -1
View File
@@ -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]`
+1 -1
View File
@@ -65,7 +65,7 @@ export interface PlayerProfile {
firstSeen: string;
lastSeen: string;
stats: PlayerStats;
records: PersonalBestRecords;
records: PersonalBestRecords | null;
maps: PlayerMapRecord[];
matches: PlayerMatch[];
matchesTotal: number;
+2 -8
View File
@@ -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`
)
+24 -2
View File
@@ -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<string | number> = [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))
@@ -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')),
@@ -156,6 +156,12 @@
onRetry={() => void refresh()}
/>{/snippet}
</SectionHeader>
{#if !profile.records}
<EmptyState
title="No qualifying match history yet"
description="Stats appear after a recorded game ends, provided this player was observed at round 2 or later. Games in progress and interrupted games do not contribute."
/>
{/if}
<nav class="jump-links" aria-label="Player sections">
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
>Matches</a
@@ -185,12 +191,13 @@
><a href={scopedLeaderboard}>Selected leaderboard</a>
</div>
<div class="stat">
<span>Lifetime best round</span><strong>{number.format(stats.highestRound)}</strong
><RecordSource
evidence={profile.records.highestRound}
{returnTo}
metric="Lifetime best round"
/>
<span>Lifetime best round</span><strong
>{profile.records ? number.format(stats.highestRound) : '—'}</strong
>{#if profile.records}<RecordSource
evidence={profile.records.highestRound}
{returnTo}
metric="Lifetime best round"
/>{/if}
</div>
<div class="stat">
<span>Lifetime games played</span><strong>{number.format(stats.gamesPlayed)}</strong
@@ -219,54 +226,60 @@
map. Tied records share a rank. Each record links to the match that established it.
</p>
</div>
<div class="mobile-records">
<PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} />
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
<div
class="table-scroll desktop-records"
role="region"
aria-label="Per-map player records"
tabindex="0"
>
<table>
<thead
><tr
><th scope="col">Map</th>{#each records as record}<th scope="col"
>{record.label}</th
>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th
scope="col">Total rounds</th
></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, metric: scope.metric })}`}
>{map.name}</a
><small>{gameName(map.game)}</small></th
>{#each records as record}<td
{#if profile.maps.length}
<div class="mobile-records">
<PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} />
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
<div
class="table-scroll desktop-records"
role="region"
aria-label="Per-map player records"
tabindex="0"
>
<table>
<thead
><tr
><th scope="col">Map</th>{#each records as record}<th scope="col"
>{record.label}</th
>{/each}<th scope="col">Games</th><th scope="col">Total kills</th
><th scope="col">Total rounds</th></tr
></thead
>
<tbody
>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr
><th scope="row"
><a
href={contextHref(
`/matches/${map.records[record.key].matchId}`,
$page.url
)}
aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`}
title={map.records[record.key].endedAt
? $timeFormat.dateTime(map.records[record.key].endedAt!)
: 'Completed match'}>{number.format(map[record.key])}</a
><small
aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}
>Rank #{number.format(map.ranks[record.key])}</small
></td
>{/each}<td>{number.format(map.gamesPlayed)}</td><td
>{number.format(map.kills)}</td
><td>{number.format(map.roundsSurvived)}</td></tr
>{/each}</tbody
>
</table>
</div>
href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}
>{map.name}</a
><small>{gameName(map.game)}</small></th
>{#each records as record}<td
><a
href={contextHref(
`/matches/${map.records[record.key].matchId}`,
$page.url
)}
aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`}
title={map.records[record.key].endedAt
? $timeFormat.dateTime(
map.records[record.key].endedAt!
)
: 'Completed match'}
>{number.format(map[record.key])}</a
><small
aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}
>Rank #{number.format(map.ranks[record.key])}</small
></td
>{/each}<td>{number.format(map.gamesPlayed)}</td><td
>{number.format(map.kills)}</td
><td>{number.format(map.roundsSurvived)}</td></tr
>{/each}</tbody
>
</table>
</div>
{:else}
<EmptyState headingLevel={3} title="No map records yet" />
{/if}
</section>
<section class="panel" aria-labelledby="history-title">
<div class="panel-heading">
@@ -333,6 +346,10 @@
>
</div>
</div>
{:else if !profile.records}<EmptyState
headingLevel={3}
title="No qualifying matches recorded"
/>
{:else}<EmptyState
headingLevel={3}
title="No completed matches in this selection"
@@ -352,7 +369,12 @@
<dl class="metric-list">
{#each otherMetrics as metric}<div>
<dt>{metric.label}</dt>
<dd>{number.format(stats[metric.key])}</dd>
<dd>
{!profile.records &&
(metric.key === 'bestKills' || metric.key === 'bestScore')
? '—'
: number.format(stats[metric.key])}
</dd>
</div>{/each}{#each efficiencyMetrics as metric}<div>
<dt>{metric.label}</dt>
<dd>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</dd>
+75 -6
View File
@@ -28,6 +28,27 @@ beforeEach(async () => {
});
afterEach(async () => fixture.cleanup());
const emptyProfile = {
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: 25
};
async function report(
event: GameEvent['event'],
round = 0,
@@ -94,6 +115,54 @@ it('keeps report revisions stable during a match and invalidates completed data
expect(revision()).toBeGreaterThan(reopened);
});
it.each([
['t5', server, 'zombie_theater'],
['t6', 't6_zombie_town_1', 'zm_transit']
])(
'loads a known %s player before qualification and refreshes cached profiles after game end',
async (game, source, map) => {
const { load } =
await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const request = {
params: { guid: player().guid },
url: new URL(
`http://localhost/leaderboard/players/${player().guid}?game=${game}&offset=999`
)
};
const send = (event: GameEvent['event'], round = 0) =>
report(event, round, [player(undefined, { kills: 20 })], source, map);
await expect(load(request)).rejects.toMatchObject({ status: 404 });
await send('game_start');
const started = await load(request);
expect(started).toMatchObject({
profile: { ...emptyProfile, guid: player().guid, name: player().name },
scope: { entry: null }
});
expect(started.profile.firstSeen).toBe('2026-09-18T20:00:00.000Z');
expect(started.profile.lastSeen).toBe(started.profile.firstSeen);
await send('game_end', 1);
expect((await load(request)).profile).toMatchObject(emptyProfile);
await send('game_start');
await send('heartbeat', 2);
expect((await load(request)).profile).toMatchObject(emptyProfile);
await send('game_end', 2);
const completed = await load(request);
expect(completed).toMatchObject({
profile: {
stats: { highestRound: 2, kills: 20, gamesPlayed: 1 },
records: { highestRound: { value: 2 } },
matchesTotal: 1,
offset: 0
},
scope: { entry: { rank: 1, kills: 20 } }
});
await send('game_start');
await send('heartbeat', 10);
expect((await load(request)).profile.stats).toEqual(completed.profile.stats);
}
);
it('publishes round 2 or higher games only after game end, preserving early stats and excluding round 1 departures', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
@@ -107,7 +176,7 @@ it('publishes round 2 or higher games only after game end, preserving early stat
total: 0,
summary: { players: 0, matches: 0, kills: 0 }
});
expect(getPlayerProfile(player().guid)).toBeNull();
expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
expect(serverMatchHistory()).toEqual({ total: 0, matches: [], maps: [], page: 1, limit: 25 });
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
expect(getLeaderboard()).toMatchObject({
@@ -116,9 +185,9 @@ it('publishes round 2 or higher games only after game end, preserving early stat
total: 0,
summary: { players: 0, matches: 0, kills: 0 }
});
expect(getPlayerProfile(player().guid)).toBeNull();
expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
expect(serverMatchHistory()).toMatchObject({ total: 1, matches: [{ status: 'active' }] });
expect(getPlayerProfile(departed.guid)).toBeNull();
expect(getPlayerProfile(departed.guid)).toMatchObject(emptyProfile);
await report('game_end', 2, [player(undefined, { kills: 10 })]);
const board = getLeaderboard();
const profile = getPlayerProfile(player().guid);
@@ -137,7 +206,7 @@ it('publishes round 2 or higher games only after game end, preserving early stat
],
summary: { players: 1, matches: 1, kills: 10 }
});
expect(getPlayerProfile(departed.guid)).toBeNull();
expect(getPlayerProfile(departed.guid)).toMatchObject(emptyProfile);
expect(history).toMatchObject({
total: 1,
matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }]
@@ -478,7 +547,7 @@ it('keeps disconnected player records while completing only the final roster', a
await report('game_start', 0, [player(), other]);
await report('round_change', 4, [player(undefined, { kills: 80 }), other]);
expect(getLeaderboard()).toMatchObject({ entries: [], total: 0 });
expect(getPlayerProfile(other.guid)).toBeNull();
expect(getPlayerProfile(other.guid)).toMatchObject(emptyProfile);
await report('game_end', 5, [player(undefined, { kills: 100 })]);
expect(await records(other.guid)).toEqual([
expect.objectContaining({ kills: 40, highest_round: 4, rounds_survived: 3, completed: 0 })
@@ -682,7 +751,7 @@ it('keeps deleted match identities and starts a separate match when the server n
server_deleted_at: null,
status: 'active'
});
expect(getPlayerProfile(player().guid)).toBeNull();
expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
await report('game_end', 8, [player(undefined, { kills: 120 })]);
const profile = getPlayerProfile(player().guid)!;
expect(profile.stats).toMatchObject({ gamesPlayed: 1, kills: 120 });
+16 -2
View File
@@ -117,7 +117,14 @@ describe('persisted leaderboard queries', () => {
)
.run(id, guid);
}
expect(getPlayerProfile(status)).toBeNull();
expect(getPlayerProfile(status)).toMatchObject({
name: status,
stats: { gamesPlayed: 0, kills: 0, highestRound: 0 },
records: null,
maps: [],
matches: [],
matchesTotal: 0
});
expect(getLeaderboard({ map: `t5:${status}` })).toMatchObject({
entries: [],
total: 0,
@@ -594,7 +601,14 @@ describe('player profiles', () => {
maps: [expect.objectContaining({ game: 't6' })],
matches: [expect.objectContaining({ game: 't6' })]
});
expect(getPlayerProfile('404')).toBeNull();
expect(getPlayerProfile('404')).toMatchObject({
name: 'No stats',
stats: { gamesPlayed: 0, kills: 0, highestRound: 0 },
records: null,
maps: [],
matches: [],
matchesTotal: 0
});
expect(getPlayerProfile("' OR 1=1 --")).toBeNull();
});
+6 -6
View File
@@ -140,7 +140,7 @@ it('resolves tied bests by earliest completion, receipt time for unknown endings
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
for (const records of [getLeaderboard().entries[0].records, getPlayerProfile('101')!.records]) {
for (const record of Object.values(records))
for (const record of Object.values(records!))
expect(record).toMatchObject({ matchId: 3, endedAt: null });
}
});
@@ -245,19 +245,19 @@ it('records first observed rounds for start participants and late arrivals witho
report('game_end', 52, [player('101', 200), player('202', 40), player('303')], 4);
closeDb();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
expect(getPlayerProfile('101')!.records.highestRound).toMatchObject({
expect(getPlayerProfile('101')!.records?.highestRound).toMatchObject({
value: 52,
startObserved: true,
firstObservedRound: 0,
presentAtStart: true
});
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({
expect(getPlayerProfile('202')!.records?.highestRound).toMatchObject({
value: 52,
startObserved: true,
firstObservedRound: 50,
presentAtStart: false
});
expect(getPlayerProfile('303')!.records.highestRound).toMatchObject({
expect(getPlayerProfile('303')!.records?.highestRound).toMatchObject({
value: 52,
startObserved: true,
firstObservedRound: 0,
@@ -315,13 +315,13 @@ it('migrates legacy participation as unknown before snapshot backfill and preser
});
closeDb();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
expect(getPlayerProfile('101')!.records.bestKills).toMatchObject({
expect(getPlayerProfile('101')!.records?.bestKills).toMatchObject({
matchId: 1,
value: 150,
firstObservedRound: null,
presentAtStart: null
});
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({
expect(getPlayerProfile('202')!.records?.highestRound).toMatchObject({
firstObservedRound: 8,
startObserved: false,
presentAtStart: false
+7 -6
View File
@@ -123,7 +123,7 @@ describe('completed match details', () => {
name: 'Player 303',
hasStats: false,
presentAtEnd: false,
canViewProfile: false,
canViewProfile: true,
firstSeenAt: timestamp(0),
lastSeenAt: timestamp(0),
highestRound: null,
@@ -232,28 +232,29 @@ describe('completed match details', () => {
expect(participants.find((entry) => entry.guid === '202')).toMatchObject({
hasStats: true,
highestRound: 1,
canViewProfile: false
canViewProfile: true
});
expect(participants.find((entry) => entry.guid === '303')).toMatchObject({
hasStats: false,
canViewProfile: false
canViewProfile: true
});
for (const participant of participants)
expect(participant.canViewProfile).toBe(getPlayerProfile(participant.guid) !== null);
// A start-only participant can still have a valid profile from another match.
// A start-only participant has an empty profile until another match qualifies.
await report('game_start', 0, [player('303')]);
await report('heartbeat', 3, [player('303')]);
expect(getMatchDetail(1)!.players.find((entry) => entry.guid === '303')).toMatchObject({
canViewProfile: false
canViewProfile: true
});
expect(getPlayerProfile('303')).toBeNull();
expect(getPlayerProfile('303')).toMatchObject({ records: null, matches: [] });
await report('game_end', 3, [player('303')]);
participants = getMatchDetail(1)!.players;
expect(participants.find((entry) => entry.guid === '303')).toMatchObject({
hasStats: false,
canViewProfile: true
});
expect(getPlayerProfile('303')).toMatchObject({ stats: { gamesPlayed: 1 } });
// Missing identities must not discard a saved roster row or produce a dead link.
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run();
+56
View File
@@ -102,4 +102,60 @@ it('shows the same ranks in the desktop profile with lifetime map scope explaine
'Lifetime personal bests across all squad sizes, ranked against other players on each map.'
);
expect(body).toContain('Tied records share a rank.');
expect(body).not.toContain('No qualifying match history yet');
expect(body).toContain('Show all completed matches');
});
it('renders a known player without qualifying history with identity and unavailable personal bests', () => {
const profile: PlayerProfile = {
guid: '101',
name: 'New player',
firstSeen: evidence.startedAt,
lastSeen: evidence.startedAt,
stats: {
highestRound: 0,
bestKills: 0,
bestScore: 0,
kills: 0,
roundsSurvived: 0,
headshots: 0,
revives: 0,
downs: 0,
gamesPlayed: 0,
gamesCompleted: 0
},
records: null,
maps: [],
matches: [],
matchesTotal: 0,
limit: 25,
offset: 0
};
const body = render(PlayerProfilePage, {
props: {
data: {
profile,
returnTo: '/leaderboard',
scope: { game: '', map: '', players: '', metric: 'highestRound', entry: null }
}
}
}).body;
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
expect(text).toContain('New player');
expect(text).toContain('No qualifying match history yet');
expect(text).toContain('Stats appear after a recorded game ends');
expect(text).toContain('observed at round 2 or later');
expect(text).toContain('Lifetime best round —');
expect(text).toContain('Best game kills —');
expect(text).toContain('Highest reported score —');
expect(text).toContain('Lifetime games played 0');
expect(text).toContain('No map records yet');
expect(text).toContain('No qualifying matches recorded');
expect(text).toContain('First seen');
expect(text).toContain('Last seen');
expect(body).toContain('Player GUID');
expect(body).not.toContain('/matches/');
expect(body).not.toContain('Show all completed matches');
expect(body).not.toContain('<table');
expect(body).not.toContain('NaN');
});