Count only completed games in player leaderboards
This commit is contained in:
@@ -34,7 +34,7 @@ async function matches() {
|
||||
}>;
|
||||
}
|
||||
|
||||
it('starts counting at round 2, preserves early stats, and excludes players who only stayed for round 1', 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 { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
@@ -45,14 +45,18 @@ it('starts counting at round 2, preserves early stats, and excludes players who
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(serverMatchHistory()).toEqual({ total: 0, matches: [] });
|
||||
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
|
||||
expect(getLeaderboard()).toMatchObject({ total: 1, entries: [expect.objectContaining({
|
||||
highestRound: 2, kills: 6, bestScore: 1000, roundsSurvived: 1, gamesPlayed: 1, gamesCompleted: 0
|
||||
})], summary: { players: 1, matches: 1, kills: 6 } });
|
||||
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(serverMatchHistory()).toMatchObject({ total: 1, matches: [{ status: 'active' }] });
|
||||
expect(getPlayerProfile(departed.guid)).toBeNull();
|
||||
await report('game_end', 2, [player(undefined, { kills: 10 })]);
|
||||
const board = getLeaderboard();
|
||||
const profile = getPlayerProfile(player().guid);
|
||||
const history = serverMatchHistory();
|
||||
expect(board).toMatchObject({ total: 1, entries: [expect.objectContaining({
|
||||
highestRound: 2, kills: 10, bestScore: 1000, roundsSurvived: 1, gamesPlayed: 1, gamesCompleted: 1
|
||||
})], summary: { players: 1, matches: 1, kills: 10 } });
|
||||
expect(getPlayerProfile(departed.guid)).toBeNull();
|
||||
expect(history).toMatchObject({ total: 1, matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }] });
|
||||
await report('game_start');
|
||||
await report('game_end', 1, [player(undefined, { kills: 5000 })]);
|
||||
@@ -225,12 +229,19 @@ it('credits the highest round without inventing survival before the first midgam
|
||||
});
|
||||
|
||||
it('keeps disconnected player records while completing only the final roster', async () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const other = player('4229696', { kills: 40, name: 'Other player' });
|
||||
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();
|
||||
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 })]);
|
||||
expect(await records()).toEqual([expect.objectContaining({ kills: 100, highest_round: 5, rounds_survived: 4, completed: 1 })]);
|
||||
expect(getLeaderboard()).toMatchObject({ total: 2, summary: { matches: 1, players: 2, kills: 140 } });
|
||||
expect(getPlayerProfile(other.guid)).toMatchObject({ stats: { kills: 40, highestRound: 4,
|
||||
roundsSurvived: 3, gamesPlayed: 1, gamesCompleted: 0 }, matches: [{ status: 'completed', completed: 0 }] });
|
||||
});
|
||||
|
||||
it('starts separate matches for game starts, map changes, round resets, and new rounds after an end', async () => {
|
||||
@@ -329,10 +340,12 @@ it('keeps deleted match identities and starts a separate match when the server n
|
||||
server, server_deleted_at: expect.any(String), status: 'interrupted'
|
||||
});
|
||||
expect(history.find(match => match.id !== original.id)).toMatchObject({ server, server_deleted_at: null, status: 'active' });
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
await report('game_end', 8, [player(undefined, { kills: 120 })]);
|
||||
const profile = getPlayerProfile(player().guid)!;
|
||||
expect(profile.stats.gamesPlayed).toBe(2);
|
||||
expect(profile.matches.find(match => match.id === original.id)?.server_deleted_at).toEqual(expect.any(String));
|
||||
expect(profile.stats.kills).toBe(240);
|
||||
expect(profile.stats).toMatchObject({ gamesPlayed: 1, kills: 120 });
|
||||
expect(profile.matches).toEqual([expect.objectContaining({ id: history.find(match => match.id !== original.id)!.id,
|
||||
server_deleted_at: null, status: 'completed' })]);
|
||||
});
|
||||
|
||||
it('does not fabricate players for an empty match', async () => {
|
||||
|
||||
@@ -20,11 +20,11 @@ async function seedRecords() {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`);
|
||||
const iso = (day: number) => `2026-09-0${day}T00:00:00.000Z`;
|
||||
match.run(1, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(1), iso(2), 'completed', 10);
|
||||
match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'active', 8);
|
||||
match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'completed', 8);
|
||||
match.run(3, 't5', 'zombie_coast', 't5:coast', 'Call of the Dead', iso(3), iso(4), 'completed', 4);
|
||||
match.run(4, 't6', 'zm_transit', 't6:town', 'Town Survival', iso(4), iso(5), 'completed', 20);
|
||||
match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'interrupted', 15);
|
||||
match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'active', 2);
|
||||
match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'completed', 15);
|
||||
match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'completed', 2);
|
||||
player.run('t5', '101', 'Ace');
|
||||
player.run('t5', '202', 'Blake');
|
||||
player.run('t5', '303', 'Casey');
|
||||
@@ -33,6 +33,7 @@ async function seedRecords() {
|
||||
player.run('t6', '505', 'Eve');
|
||||
record.run(1, '101', 100, 10, 9, 50, 2, 1, 1000, 1, 1, iso(2));
|
||||
record.run(1, '202', 100, 10, 9, 40, 3, 2, 2000, 1, 1, iso(2));
|
||||
// An observed match ending also publishes saved stats for players who departed before it.
|
||||
record.run(2, '101', 150, 8, 7, 60, 1, 2, 3000, 0, 1, iso(3));
|
||||
record.run(2, '303', 70, 8, 7, 20, 0, 0, 1200, 0, 1, iso(3));
|
||||
record.run(3, '101', 20, 4, 3, 10, 1, 0, 500, 1, 1, iso(4));
|
||||
@@ -44,6 +45,38 @@ async function seedRecords() {
|
||||
}
|
||||
|
||||
describe('persisted leaderboard queries', () => {
|
||||
it('excludes active and interrupted matches from ranks, summaries, map options and every part of player profiles', async () => {
|
||||
const handle = await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const { leaderboardMetrics } = await import('../../src/lib/leaderboard.js');
|
||||
const boards = leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }));
|
||||
const profile = getPlayerProfile('101');
|
||||
for (const [index, status] of ['active', 'interrupted'].entries()) {
|
||||
const id = index + 10;
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at,
|
||||
status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', 't5', ?, ?, 'Unfinished Map',
|
||||
'2026-09-20', '2026-09-20', ?, 99, 'heartbeat', 'unfinished')`)
|
||||
.run(id, status, `t5:${status}`, status);
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t5', ?, ?, '2026-09-20', '2026-09-20')`).run(status, status);
|
||||
for (const guid of ['101', status]) {
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, highest_round, rounds_survived, kills, headshots, revives, downs, best_score,
|
||||
has_stats, completed, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, 99, 99, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
|
||||
.run(id, guid);
|
||||
}
|
||||
expect(getPlayerProfile(status)).toBeNull();
|
||||
expect(getLeaderboard({ map: `t5:${status}` })).toMatchObject({ entries: [], total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
|
||||
}
|
||||
expect(leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }))).toEqual(boards);
|
||||
expect(getPlayerProfile('101')).toEqual(profile);
|
||||
});
|
||||
|
||||
it('excludes persisted round 0/1 matches from every history, total, ranking and map option', async () => {
|
||||
const handle = await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
@@ -232,7 +265,7 @@ describe('player profiles', () => {
|
||||
expect(profile.maps[0]).toMatchObject({ game: 't6', key: 't6:town', highestRound: 20, kills: 500, gamesPlayed: 1, gamesCompleted: 1 });
|
||||
expect(profile.maps[1]).toMatchObject({ game: 't5', key: 't5:theater', highestRound: 10, kills: 250, gamesPlayed: 2, gamesCompleted: 1 });
|
||||
expect(profile.matches.map(match => [match.id, match.game])).toEqual([[4, 't6'], [3, 't5'], [2, 't5'], [1, 't5']]);
|
||||
expect(profile.matches[2]).toMatchObject({ status: 'active', completed: 0, server: 'removed-server', kills: 150 });
|
||||
expect(profile.matches[2]).toMatchObject({ status: 'completed', completed: 0, server: 'removed-server', kills: 150 });
|
||||
expect(getPlayerProfile('505')).toMatchObject({ name: 'Eve', stats: { kills: 200, gamesPlayed: 1 },
|
||||
maps: [expect.objectContaining({ game: 't6' })], matches: [expect.objectContaining({ game: 't6' })] });
|
||||
expect(getPlayerProfile('404')).toBeNull();
|
||||
|
||||
@@ -117,6 +117,9 @@ describe('completed match details', () => {
|
||||
|
||||
// A start-only participant can still have a valid profile from another match.
|
||||
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 });
|
||||
expect(getPlayerProfile('303')).toBeNull();
|
||||
await report('game_end', 3, [player('303')]);
|
||||
participants = getMatchDetail(1)!.players;
|
||||
expect(participants.find(entry => entry.guid === '303')).toMatchObject({ hasStats: false, canViewProfile: true });
|
||||
|
||||
Reference in New Issue
Block a user