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
+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');
});