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 ## Qualifying matches
Matches only appear in server/global history once they reach round 2. 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 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 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. 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 ### 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 Click a player's name to open their shareable profile at
`/leaderboard/players/[guid]`. Profiles combine Black Ops I (T5) and Black Ops II `/leaderboard/players/[guid]`. Profiles combine Black Ops I (T5) and Black Ops II
(T6) records for the same GUID. Existing `/leaderboard/players/[game]/[guid]` (T6) records for the same GUID. Existing `/leaderboard/players/[game]/[guid]`
+1 -1
View File
@@ -65,7 +65,7 @@ export interface PlayerProfile {
firstSeen: string; firstSeen: string;
lastSeen: string; lastSeen: string;
stats: PlayerStats; stats: PlayerStats;
records: PersonalBestRecords; records: PersonalBestRecords | null;
maps: PlayerMapRecord[]; maps: PlayerMapRecord[];
matches: PlayerMatch[]; matches: PlayerMatch[];
matchesTotal: number; 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.downs END AS downs,
CASE WHEN p.has_stats = 1 THEN p.revives END AS revives, 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, CASE WHEN p.has_stats = 1 THEN p.best_score END AS bestScore,
(EXISTS (SELECT 1 FROM leaderboard_players identity EXISTS (SELECT 1 FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) AS canViewProfile
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
FROM leaderboard_match_players p WHERE p.match_id = ? FROM leaderboard_match_players p WHERE p.match_id = ?
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid` 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 const { matchCount, ...stats } = handle
.prepare(`SELECT COUNT(*) AS matchCount, ${totals} ${scope}`) .prepare(`SELECT COUNT(*) AS matchCount, ${totals} ${scope}`)
.get(guid) as PlayerStats & { matchCount: number }; .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]; const historyParameters: Array<string | number> = [guid];
let historyScope = scope; let historyScope = scope;
if (options.game) { if (options.game) {
@@ -59,7 +82,6 @@ export function getPlayerProfile(
.get(...historyParameters) as { total: number } .get(...historyParameters) as { total: number }
).total ).total
: matchCount; : matchCount;
const limit = 25;
const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit); const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit);
const offset = Number.isSafeInteger(requestedOffset) const offset = Number.isSafeInteger(requestedOffset)
? Math.min(lastOffset, Math.max(0, 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), Number(url.searchParams.get('offset') ?? 0),
options options
); );
if (!profile) error(404, 'No recorded stats found for this player'); if (!profile) error(404, 'Player not found');
return { return {
profile, profile,
returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')), returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
@@ -156,6 +156,12 @@
onRetry={() => void refresh()} onRetry={() => void refresh()}
/>{/snippet} />{/snippet}
</SectionHeader> </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"> <nav class="jump-links" aria-label="Player sections">
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title" <a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
>Matches</a >Matches</a
@@ -185,12 +191,13 @@
><a href={scopedLeaderboard}>Selected leaderboard</a> ><a href={scopedLeaderboard}>Selected leaderboard</a>
</div> </div>
<div class="stat"> <div class="stat">
<span>Lifetime best round</span><strong>{number.format(stats.highestRound)}</strong <span>Lifetime best round</span><strong
><RecordSource >{profile.records ? number.format(stats.highestRound) : '—'}</strong
>{#if profile.records}<RecordSource
evidence={profile.records.highestRound} evidence={profile.records.highestRound}
{returnTo} {returnTo}
metric="Lifetime best round" metric="Lifetime best round"
/> />{/if}
</div> </div>
<div class="stat"> <div class="stat">
<span>Lifetime games played</span><strong>{number.format(stats.gamesPlayed)}</strong <span>Lifetime games played</span><strong>{number.format(stats.gamesPlayed)}</strong
@@ -219,6 +226,7 @@
map. Tied records share a rank. Each record links to the match that established it. map. Tied records share a rank. Each record links to the match that established it.
</p> </p>
</div> </div>
{#if profile.maps.length}
<div class="mobile-records"> <div class="mobile-records">
<PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} /> <PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} />
</div> </div>
@@ -234,9 +242,8 @@
><tr ><tr
><th scope="col">Map</th>{#each records as record}<th scope="col" ><th scope="col">Map</th>{#each records as record}<th scope="col"
>{record.label}</th >{record.label}</th
>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th >{/each}<th scope="col">Games</th><th scope="col">Total kills</th
scope="col">Total rounds</th ><th scope="col">Total rounds</th></tr
></tr
></thead ></thead
> >
<tbody <tbody
@@ -254,8 +261,11 @@
)} )}
aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`}
title={map.records[record.key].endedAt title={map.records[record.key].endedAt
? $timeFormat.dateTime(map.records[record.key].endedAt!) ? $timeFormat.dateTime(
: 'Completed match'}>{number.format(map[record.key])}</a map.records[record.key].endedAt!
)
: 'Completed match'}
>{number.format(map[record.key])}</a
><small ><small
aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`} aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}
>Rank #{number.format(map.ranks[record.key])}</small >Rank #{number.format(map.ranks[record.key])}</small
@@ -267,6 +277,9 @@
> >
</table> </table>
</div> </div>
{:else}
<EmptyState headingLevel={3} title="No map records yet" />
{/if}
</section> </section>
<section class="panel" aria-labelledby="history-title"> <section class="panel" aria-labelledby="history-title">
<div class="panel-heading"> <div class="panel-heading">
@@ -333,6 +346,10 @@
> >
</div> </div>
</div> </div>
{:else if !profile.records}<EmptyState
headingLevel={3}
title="No qualifying matches recorded"
/>
{:else}<EmptyState {:else}<EmptyState
headingLevel={3} headingLevel={3}
title="No completed matches in this selection" title="No completed matches in this selection"
@@ -352,7 +369,12 @@
<dl class="metric-list"> <dl class="metric-list">
{#each otherMetrics as metric}<div> {#each otherMetrics as metric}<div>
<dt>{metric.label}</dt> <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> </div>{/each}{#each efficiencyMetrics as metric}<div>
<dt>{metric.label}</dt> <dt>{metric.label}</dt>
<dd>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</dd> <dd>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</dd>
+75 -6
View File
@@ -28,6 +28,27 @@ beforeEach(async () => {
}); });
afterEach(async () => fixture.cleanup()); 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( async function report(
event: GameEvent['event'], event: GameEvent['event'],
round = 0, round = 0,
@@ -94,6 +115,54 @@ it('keeps report revisions stable during a match and invalidates completed data
expect(revision()).toBeGreaterThan(reopened); 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 () => { 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 { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.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, total: 0,
summary: { players: 0, matches: 0, kills: 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 }); expect(serverMatchHistory()).toEqual({ total: 0, matches: [], maps: [], page: 1, limit: 25 });
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]); await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
expect(getLeaderboard()).toMatchObject({ expect(getLeaderboard()).toMatchObject({
@@ -116,9 +185,9 @@ it('publishes round 2 or higher games only after game end, preserving early stat
total: 0, total: 0,
summary: { players: 0, matches: 0, kills: 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(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 })]); await report('game_end', 2, [player(undefined, { kills: 10 })]);
const board = getLeaderboard(); const board = getLeaderboard();
const profile = getPlayerProfile(player().guid); 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 } summary: { players: 1, matches: 1, kills: 10 }
}); });
expect(getPlayerProfile(departed.guid)).toBeNull(); expect(getPlayerProfile(departed.guid)).toMatchObject(emptyProfile);
expect(history).toMatchObject({ expect(history).toMatchObject({
total: 1, total: 1,
matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }] 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('game_start', 0, [player(), other]);
await report('round_change', 4, [player(undefined, { kills: 80 }), other]); await report('round_change', 4, [player(undefined, { kills: 80 }), other]);
expect(getLeaderboard()).toMatchObject({ entries: [], total: 0 }); 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 })]); await report('game_end', 5, [player(undefined, { kills: 100 })]);
expect(await records(other.guid)).toEqual([ expect(await records(other.guid)).toEqual([
expect.objectContaining({ kills: 40, highest_round: 4, rounds_survived: 3, completed: 0 }) 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, server_deleted_at: null,
status: 'active' status: 'active'
}); });
expect(getPlayerProfile(player().guid)).toBeNull(); expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
await report('game_end', 8, [player(undefined, { kills: 120 })]); await report('game_end', 8, [player(undefined, { kills: 120 })]);
const profile = getPlayerProfile(player().guid)!; const profile = getPlayerProfile(player().guid)!;
expect(profile.stats).toMatchObject({ gamesPlayed: 1, kills: 120 }); expect(profile.stats).toMatchObject({ gamesPlayed: 1, kills: 120 });
+16 -2
View File
@@ -117,7 +117,14 @@ describe('persisted leaderboard queries', () => {
) )
.run(id, guid); .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({ expect(getLeaderboard({ map: `t5:${status}` })).toMatchObject({
entries: [], entries: [],
total: 0, total: 0,
@@ -594,7 +601,14 @@ describe('player profiles', () => {
maps: [expect.objectContaining({ game: 't6' })], maps: [expect.objectContaining({ game: 't6' })],
matches: [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(); 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 { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.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 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 }); 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); report('game_end', 52, [player('101', 200), player('202', 40), player('303')], 4);
closeDb(); closeDb();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js'); 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, value: 52,
startObserved: true, startObserved: true,
firstObservedRound: 0, firstObservedRound: 0,
presentAtStart: true presentAtStart: true
}); });
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({ expect(getPlayerProfile('202')!.records?.highestRound).toMatchObject({
value: 52, value: 52,
startObserved: true, startObserved: true,
firstObservedRound: 50, firstObservedRound: 50,
presentAtStart: false presentAtStart: false
}); });
expect(getPlayerProfile('303')!.records.highestRound).toMatchObject({ expect(getPlayerProfile('303')!.records?.highestRound).toMatchObject({
value: 52, value: 52,
startObserved: true, startObserved: true,
firstObservedRound: 0, firstObservedRound: 0,
@@ -315,13 +315,13 @@ it('migrates legacy participation as unknown before snapshot backfill and preser
}); });
closeDb(); closeDb();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js'); 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, matchId: 1,
value: 150, value: 150,
firstObservedRound: null, firstObservedRound: null,
presentAtStart: null presentAtStart: null
}); });
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({ expect(getPlayerProfile('202')!.records?.highestRound).toMatchObject({
firstObservedRound: 8, firstObservedRound: 8,
startObserved: false, startObserved: false,
presentAtStart: false presentAtStart: false
+7 -6
View File
@@ -123,7 +123,7 @@ describe('completed match details', () => {
name: 'Player 303', name: 'Player 303',
hasStats: false, hasStats: false,
presentAtEnd: false, presentAtEnd: false,
canViewProfile: false, canViewProfile: true,
firstSeenAt: timestamp(0), firstSeenAt: timestamp(0),
lastSeenAt: timestamp(0), lastSeenAt: timestamp(0),
highestRound: null, highestRound: null,
@@ -232,28 +232,29 @@ describe('completed match details', () => {
expect(participants.find((entry) => entry.guid === '202')).toMatchObject({ expect(participants.find((entry) => entry.guid === '202')).toMatchObject({
hasStats: true, hasStats: true,
highestRound: 1, highestRound: 1,
canViewProfile: false canViewProfile: true
}); });
expect(participants.find((entry) => entry.guid === '303')).toMatchObject({ expect(participants.find((entry) => entry.guid === '303')).toMatchObject({
hasStats: false, hasStats: false,
canViewProfile: false canViewProfile: true
}); });
for (const participant of participants) for (const participant of participants)
expect(participant.canViewProfile).toBe(getPlayerProfile(participant.guid) !== null); 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('game_start', 0, [player('303')]);
await report('heartbeat', 3, [player('303')]); await report('heartbeat', 3, [player('303')]);
expect(getMatchDetail(1)!.players.find((entry) => entry.guid === '303')).toMatchObject({ 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')]); await report('game_end', 3, [player('303')]);
participants = getMatchDetail(1)!.players; participants = getMatchDetail(1)!.players;
expect(participants.find((entry) => entry.guid === '303')).toMatchObject({ expect(participants.find((entry) => entry.guid === '303')).toMatchObject({
hasStats: false, hasStats: false,
canViewProfile: true canViewProfile: true
}); });
expect(getPlayerProfile('303')).toMatchObject({ stats: { gamesPlayed: 1 } });
// Missing identities must not discard a saved roster row or produce a dead link. // Missing identities must not discard a saved roster row or produce a dead link.
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run(); 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.' 'Lifetime personal bests across all squad sizes, ranked against other players on each map.'
); );
expect(body).toContain('Tied records share a rank.'); 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');
}); });