418 lines
17 KiB
TypeScript
418 lines
17 KiB
TypeScript
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> = {}): 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();
|
|
}
|
|
});
|