import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { gameEvent, postTelemetry } from '../helpers/telemetry.js'; let fixture: Fixture; let received = 0; const server = 't5_zombie_theater_1'; const otherServer = 't5_zombie_theater_2'; const player = (guid = 'ace', stats: Partial = {}): TelemetryPlayerStats => ({ guid, name: guid, kills: 0, headshots: 0, downs: 0, revives: 0, score: 0, ...stats }); beforeEach(async () => { vi.resetModules(); received = 0; fixture = await newFixture([server, otherServer]); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); await seedServerRegistry(); }); afterEach(async () => fixture.cleanup()); async function report( match: string, seq: number | undefined, event: GameEvent['event'], round = 0, players = [player()], source = server ) { const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const common = { match, ...(seq === undefined ? {} : { seq }), server: source, map: 'zombie_theater', receivedAt: new Date(Date.UTC(2026, 9, 10, 12, 0, received++)).toISOString() }; return saveTelemetry( event === 'game_start' ? { ...common, event, players: players.map(({ name, guid }) => ({ name, guid })) } : { ...common, event, round, players } ); } async function records() { const { db } = await import('../../src/lib/server/db/index.js'); return db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all() as Array<{ id: number; server: string; reporter_match_key: string; status: string; highest_round: number; start_observed: number; start_players: number | null; last_event: string; last_seq: number | null; report_count: number; }>; } async function participation(id: number) { const { db } = await import('../../src/lib/server/db/index.js'); return db() .prepare('SELECT * FROM leaderboard_match_players WHERE match_id = ? ORDER BY guid') .all(id); } async function live() { const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const state = getServer(server)!; return { telemetry: listTelemetry().find((entry) => entry.server === server), timers: { active: state.active_since, inactive: state.inactive_since, started: state.game_started_at, ended: state.game_ended_at } }; } it('deduplicates a retried sequence after intervening reports and a database reopen', async () => { await report('one', 1, 'game_start'); await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]); await report('one', 3, 'heartbeat', 5, [player('ace', { kills: 30 })]); const before = await records(); const beforePlayers = await participation(before[0].id); const beforeLive = await live(); const { closeDb, db } = await import('../../src/lib/server/db/index.js'); const identities = db().prepare('SELECT * FROM leaderboard_players ORDER BY guid').all(); closeDb(); await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]); expect(await records()).toEqual(before); expect(await participation(before[0].id)).toEqual(beforePlayers); expect(await live()).toEqual(beforeLive); expect(db().prepare('SELECT * FROM leaderboard_players ORDER BY guid').all()).toEqual( identities ); expect(db().prepare('SELECT seq FROM leaderboard_match_reports ORDER BY seq').all()).toEqual([ { seq: 1 }, { seq: 2 }, { seq: 3 } ]); }); it('records a lower sequence gap fill without rewinding rounds, presence, or live timers', async () => { await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]); await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]); const before = (await records())[0]; const beforePlayers = await participation(before.id); const beforeLive = await live(); await report('one', 2, 'round_change', 2, [player('departed', { kills: 2 })]); const matches = await records(); expect(matches).toHaveLength(1); expect(matches[0]).toMatchObject({ id: before.id, status: 'active', highest_round: 8, last_event: 'heartbeat', last_seq: 4, report_count: 3 }); expect(await participation(before.id)).toEqual(beforePlayers); expect(await live()).toEqual(beforeLive); const { db } = await import('../../src/lib/server/db/index.js'); expect( db().prepare('SELECT applied FROM leaderboard_match_reports WHERE seq = 2').get() ).toEqual({ applied: 0 }); }); it('opens matches by reporter key when starts are missing and isolates late old-key heartbeats', async () => { await report('old', 2, 'heartbeat', 8, [player('old-player', { kills: 80 })]); const first = (await records())[0]; expect(first).toMatchObject({ reporter_match_key: 'old', start_observed: 0 }); await report('new', 2, 'heartbeat', 8, [player('new-player', { kills: 40 })]); const before = await records(); expect(before).toHaveLength(2); expect(before[0]).toMatchObject({ status: 'end-report-missing' }); expect(before[1]).toMatchObject({ reporter_match_key: 'new', status: 'active', start_observed: 0 }); const beforeLive = await live(); const beforePlayers = await participation(before[1].id); const receipt = await report('old', 3, 'heartbeat', 9, []); expect(receipt.timing?.matchId).toBe(first.id); expect(await records()).toHaveLength(2); expect((await records())[1]).toEqual(before[1]); expect(await participation(before[1].id)).toEqual(beforePlayers); expect(await live()).toEqual(beforeLive); }); it('applies a delayed start only to start observation and the original roster', async () => { await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]); const before = (await records())[0]; const beforeLive = await live(); await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]); expect(await records()).toHaveLength(1); expect((await records())[0]).toMatchObject({ id: before.id, start_observed: 1, start_players: 2, highest_round: 8, last_event: 'heartbeat', status: 'active', last_seq: 4, report_count: 2 }); expect(await participation(before.id)).toEqual([ expect.objectContaining({ guid: 'ace', highest_round: 8, last_round: 7, present: 1, has_stats: 1, kills: 80, present_at_start: 1 }), expect.objectContaining({ guid: 'departed', present: 0, has_stats: 0, kills: 0, present_at_start: 1 }) ]); expect(await live()).toEqual(beforeLive); }); it('marks a missing end when the next key starts and gives a late end credit only to its own match', async () => { const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); const { serverMatchHistory, getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js'); await report('old', 1, 'game_start'); await report('old', 2, 'round_change', 2, [player('ace', { kills: 10 })]); await report('old', 3, 'heartbeat', 8, [player('ace', { kills: 70 })]); await report('new', 1, 'game_start', 0, [player('other')]); await report('new', 2, 'heartbeat', 3, [player('other', { kills: 15 })]); const before = await records(); const beforeLive = await live(); expect(before[0].status).toBe('end-report-missing'); expect(serverMatchHistory().matches).toEqual( expect.arrayContaining([ expect.objectContaining({ id: before[0].id, status: 'end-report-missing', reporterMatchKey: 'old' }) ]) ); expect(getLeaderboard().entries).toEqual([]); await report('old', 4, 'game_end', 9, [player('ace', { kills: 90 })]); const after = await records(); expect(after).toHaveLength(2); expect(after[0]).toMatchObject({ status: 'completed', highest_round: 9 }); expect(after[1]).toEqual(before[1]); expect(await live()).toEqual(beforeLive); expect(getMatchDetail(before[0].id)).toMatchObject({ status: 'completed', reporterMatchKey: 'old', missingReports: 0, finalRound: 9 }); expect(getLeaderboard().entries).toEqual([ expect.objectContaining({ guid: 'ace', kills: 90, gamesCompleted: 1 }) ]); }); it('counts missing unique sequences and reduces gaps when out-of-order reports arrive', async () => { const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js'); await report('one', 1, 'game_start'); await report('one', 5, 'heartbeat', 8); expect(serverMatchHistory().matches[0]).toMatchObject({ reporterMatchKey: 'one', missingReports: 3 }); await report('one', 3, 'heartbeat', 5); expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 2 }); await report('one', 3, 'heartbeat', 5); expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 2 }); await report('one', 2, 'round_change', 2); await report('one', 4, 'heartbeat', 7); expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 0 }); expect((await records())[0]).toMatchObject({ last_seq: 5, report_count: 5 }); }); it('does not claim gap knowledge for a keyed reporter that omits sequence numbers', async () => { const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js'); await report('one', undefined, 'game_start'); await report('one', undefined, 'heartbeat', 4); expect(serverMatchHistory().matches[0]).toMatchObject({ reporterMatchKey: 'one', missingReports: null }); }); it('ignores a lower-round key-only report without inflating observed rounds survived', async () => { await report('one', undefined, 'game_start'); await report('one', undefined, 'heartbeat', 8, [player('ace', { kills: 80 })]); const match = (await records())[0]; const beforePlayers = await participation(match.id); const beforeLive = await live(); await report('one', undefined, 'heartbeat', 4, [player('ace', { kills: 40 })]); expect(await participation(match.id)).toEqual(beforePlayers); expect(await live()).toEqual(beforeLive); await report('one', undefined, 'game_end', 9, [player('ace', { kills: 90 })]); expect(await records()).toHaveLength(1); expect(await participation(match.id)).toEqual([ expect.objectContaining({ guid: 'ace', rounds_survived: 8, last_round: 8 }) ]); const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); expect(getLeaderboard().entries).toEqual([ expect.objectContaining({ guid: 'ace', roundsSurvived: 8 }) ]); }); it('does not recount completed rounds when a newer sequence reports a lower round', async () => { await report('one', 1, 'game_start'); await report('one', 2, 'heartbeat', 8, [player('ace', { kills: 80 })]); await report('one', 3, 'heartbeat', 4, [player('ace', { kills: 40 })]); const match = (await records())[0]; expect(await participation(match.id)).toEqual([ expect.objectContaining({ guid: 'ace', rounds_survived: 7, last_round: 7 }) ]); await report('one', 4, 'game_end', 9, [player('ace', { kills: 90 })]); expect(await records()).toHaveLength(1); expect(await participation(match.id)).toEqual([ expect.objectContaining({ guid: 'ace', rounds_survived: 8, last_round: 8 }) ]); }); it('marks both existing and new start-roster players after an initial round-zero heartbeat', async () => { await report('one', 1, 'heartbeat', 0); const match = (await records())[0]; expect(match.start_observed).toBe(0); await report('one', 2, 'game_start', 0, [player('ace'), player('new-player')]); expect(await records()).toHaveLength(1); expect((await records())[0]).toMatchObject({ id: match.id, start_observed: 1, start_players: 2 }); expect(await participation(match.id)).toEqual([ expect.objectContaining({ guid: 'ace', present_at_start: 1, first_observed_round: 0 }), expect.objectContaining({ guid: 'new-player', present_at_start: 1, first_observed_round: 0 }) ]); }); it('scopes opaque reporter match keys and sequences to the reporting server', async () => { await report('same-key', 1, 'game_start'); await report('same-key', 1, 'game_start', 0, [player('other')], otherServer); await report('same-key', 2, 'game_end', 4, [player('ace', { kills: 10 })]); await report('same-key', 2, 'heartbeat', 6, [player('other')], otherServer); const matches = await records(); expect(matches).toHaveLength(2); expect(matches[0]).toMatchObject({ server, status: 'completed', report_count: 2 }); expect(matches[1]).toMatchObject({ server: otherServer, status: 'active', highest_round: 6, report_count: 2 }); }); it('allows a delayed lower-sequence end to complete a match without rewinding its latest roster', async () => { await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]); await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]); const before = (await records())[0]; const beforeLive = await live(); await report('one', 3, 'game_end', 7, [ player('departed', { kills: 5 }), player('final-new', { kills: 20 }) ]); expect(await records()).toHaveLength(1); expect((await records())[0]).toMatchObject({ id: before.id, highest_round: 8, status: 'completed', last_seq: 4, report_count: 3 }); expect(await participation(before.id)).toEqual([ expect.objectContaining({ guid: 'ace', present: 1, highest_round: 8, kills: 80 }), expect.objectContaining({ guid: 'departed', present: 0, completed: 1 }), expect.objectContaining({ guid: 'final-new', present: 0, completed: 1, has_stats: 1, highest_round: 7, rounds_survived: 0, kills: 20 }) ]); expect(await live()).toEqual(beforeLive); const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js'); expect(getMatchDetail(before.id)).toMatchObject({ endPlayers: 2, players: expect.arrayContaining([ expect.objectContaining({ guid: 'ace', presentAtEnd: false }), expect.objectContaining({ guid: 'departed', presentAtEnd: true }), expect.objectContaining({ guid: 'final-new', presentAtEnd: true }) ]) }); const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); expect(getLeaderboard().entries).toEqual( expect.arrayContaining([ expect.objectContaining({ guid: 'final-new', kills: 20, gamesCompleted: 1 }) ]) ); }); it('acknowledges retries and old keys with their own match ID without broadcasting stale telemetry', async () => { const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const listener = vi.fn(); const unsubscribe = subscribe(listener); try { const oldStart = { ...gameEvent('game_start'), match: 'old', seq: 1 }; const oldHeartbeat = { ...gameEvent('heartbeat', 5), match: 'old', seq: 2 }; const started = await (await postTelemetry(oldStart)).json(); await postTelemetry(oldHeartbeat); await postTelemetry({ ...gameEvent('game_start'), match: 'new', seq: 1 }); await postTelemetry({ ...gameEvent('heartbeat', 3), match: 'new', seq: 2 }); const beforeLive = await live(); listener.mockClear(); for (const stale of [oldStart, oldHeartbeat, { ...oldHeartbeat, seq: 3 }]) { const response = await postTelemetry(stale); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ ok: true, telemetry: { match: 'old', seq: stale.seq, timing: { matchId: started.telemetry.timing.matchId } } }); } expect(listener).not.toHaveBeenCalled(); expect(await live()).toEqual(beforeLive); } finally { unsubscribe(); } });