This commit is contained in:
@@ -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]`
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface PlayerProfile {
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
stats: PlayerStats;
|
||||
records: PersonalBestRecords;
|
||||
records: PersonalBestRecords | null;
|
||||
maps: PlayerMapRecord[];
|
||||
matches: PlayerMatch[];
|
||||
matchesTotal: number;
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user