210 lines
12 KiB
TypeScript
210 lines
12 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';
|
|
|
|
let fixture: Fixture;
|
|
let received = 0;
|
|
const server = 't5_zombie_theater_1';
|
|
const player = (guid = '2741626', stats: Partial<TelemetryPlayerStats> = {}): TelemetryPlayerStats => ({
|
|
guid, name: 'JoshWright', kills: 0, headshots: 0, downs: 0, revives: 0, score: 0, ...stats
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
received = 0;
|
|
fixture = await newFixture([server, 't6_zombie_town_1', 't6_zombie_farm_1']);
|
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
|
await seedServerRegistry();
|
|
});
|
|
afterEach(async () => fixture.cleanup());
|
|
|
|
async function report(event: GameEvent['event'], round = 0, players = [player()], source = server, map = 'zombie_theater') {
|
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
|
const receivedAt = new Date(Date.UTC(2026, 8, 18, 20, 0, received++)).toISOString();
|
|
saveTelemetry(event === 'game_start'
|
|
? { event, server: source, map, players: players.map(({ name, guid }) => ({ name, guid })), receivedAt }
|
|
: { event, server: source, map, round, players, receivedAt });
|
|
}
|
|
|
|
async function matches() {
|
|
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; game: string; map: string; map_key: string; map_name: string;
|
|
status: string; highest_round: number; start_observed: number;
|
|
}>;
|
|
}
|
|
|
|
async function records(guid = '2741626') {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
return db().prepare('SELECT * FROM leaderboard_match_players WHERE guid = ? ORDER BY match_id').all(guid) as Array<{
|
|
match_id: number; guid: string; highest_round: number; rounds_survived: number;
|
|
kills: number; headshots: number; downs: number; revives: number; best_score: number;
|
|
completed: number; present: number;
|
|
}>;
|
|
}
|
|
|
|
it('persists independent stat high-water marks without adding cumulative snapshots or retries', async () => {
|
|
await report('game_start');
|
|
await report('round_change', 1);
|
|
const first = player(undefined, { kills: 100, headshots: 60, downs: 2, revives: 3, score: 5000 });
|
|
await report('round_change', 3, [first]);
|
|
await report('round_change', 3, [first]);
|
|
await report('round_change', 4, [player(undefined, {
|
|
kills: 120, headshots: 55, downs: 1, revives: 5, score: 1000
|
|
})]);
|
|
await report('game_end', 4, [player(undefined, {
|
|
kills: 125, headshots: 62, downs: 3, revives: 5, score: 2000
|
|
})]);
|
|
expect(await matches()).toHaveLength(1);
|
|
expect(await records()).toEqual([expect.objectContaining({
|
|
highest_round: 4, rounds_survived: 3, kills: 125, headshots: 62,
|
|
downs: 3, revives: 5, best_score: 5000, completed: 1
|
|
})]);
|
|
});
|
|
|
|
it('keeps a latest player identity across games on different servers without merging different game GUIDs', async () => {
|
|
await report('round_change', 8, [player(undefined, { kills: 111 })]);
|
|
await report('game_end', 8, [player(undefined, { kills: 120, name: 'New name' })]);
|
|
await report('game_start');
|
|
await report('game_end', 2, [player(undefined, { kills: 20, name: 'Latest name' })]);
|
|
await report('game_end', 5, [player(undefined, { kills: 200, name: 'Other game' })], 't6_zombie_town_1', 'zm_transit');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(db().prepare('SELECT game, guid, name FROM leaderboard_players ORDER BY game').all()).toEqual([
|
|
{ game: 't5', guid: '2741626', name: 'Latest name' },
|
|
{ game: 't6', guid: '2741626', name: 'Other game' }
|
|
]);
|
|
expect((await records()).map(row => row.kills)).toEqual([120, 20, 200]);
|
|
expect(db().prepare(`SELECT SUM(p.rounds_survived) AS total FROM leaderboard_match_players p
|
|
JOIN leaderboard_matches m ON m.id = p.match_id WHERE m.game = 't5' AND p.guid = ?`).get('2741626'))
|
|
.toEqual({ total: 1 });
|
|
});
|
|
|
|
it('credits the highest round but only observed survival for a late join or a return after an absent roster', async () => {
|
|
await report('game_start');
|
|
await report('round_change', 2);
|
|
await report('round_change', 3, []);
|
|
const late = player('4229696', { name: 'Late player' });
|
|
await report('round_change', 5, [player(), late]);
|
|
await report('round_change', 6, [late, player()]);
|
|
await report('game_end', 6, [player()]);
|
|
expect(await records()).toEqual([expect.objectContaining({
|
|
highest_round: 6, rounds_survived: 2, completed: 1
|
|
})]);
|
|
expect(await records(late.guid)).toEqual([expect.objectContaining({
|
|
highest_round: 6, rounds_survived: 1, completed: 0, present: 0
|
|
})]);
|
|
});
|
|
|
|
it('credits the highest round without inventing survival before the first midgame snapshot', async () => {
|
|
await report('round_change', 8, [player(undefined, { kills: 111 })]);
|
|
expect(await records()).toEqual([expect.objectContaining({ highest_round: 8, rounds_survived: 0, kills: 111 })]);
|
|
await report('round_change', 10, [player(undefined, { kills: 160 })]);
|
|
await report('game_end', 10, [player(undefined, { kills: 170 })]);
|
|
expect(await records()).toEqual([expect.objectContaining({ highest_round: 10, rounds_survived: 2, completed: 1 })]);
|
|
expect(await matches()).toEqual([expect.objectContaining({ start_observed: 0, status: 'completed' })]);
|
|
});
|
|
|
|
it('keeps disconnected player records while completing only the final roster', async () => {
|
|
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]);
|
|
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 })]);
|
|
});
|
|
|
|
it('starts separate matches for game starts, map changes, round resets, and new rounds after an end', async () => {
|
|
await report('game_start');
|
|
await report('round_change', 5, [player(undefined, { kills: 50 })]);
|
|
await report('game_start');
|
|
await report('round_change', 2, [player(undefined, { kills: 20 })]);
|
|
await report('round_change', 3, [player(undefined, { kills: 30 })], server, 'zombie_moon');
|
|
await report('round_change', 1, [player(undefined, { kills: 10 })], server, 'zombie_moon');
|
|
await report('game_end', 1, [player(undefined, { kills: 15 })], server, 'zombie_moon');
|
|
await report('round_change', 1, [player(undefined, { kills: 5 })], server, 'zombie_moon');
|
|
expect((await matches()).map(row => [row.status, row.highest_round])).toEqual([
|
|
['interrupted', 5], ['interrupted', 2], ['interrupted', 3], ['completed', 1], ['active', 1]
|
|
]);
|
|
expect((await records()).map(row => row.kills)).toEqual([50, 20, 30, 15, 5]);
|
|
});
|
|
|
|
it('deduplicates consecutive start and end retries even with new receipt timestamps and reordered players', async () => {
|
|
const other = player('4229696');
|
|
await report('game_start', 0, [player(), other]);
|
|
await report('game_start', 0, [other, player()]);
|
|
await report('round_change', 3, [player(), other]);
|
|
await report('game_end', 3, [player(), other]);
|
|
await report('game_end', 3, [other, player()]);
|
|
expect(await matches()).toHaveLength(1);
|
|
expect(await records()).toEqual([expect.objectContaining({ rounds_survived: 2, completed: 1 })]);
|
|
});
|
|
|
|
it('rejects a GUID repeated in the same telemetry roster before it can double-count participation', async () => {
|
|
const { parseGameEvent } = await import('../../src/lib/telemetry.js');
|
|
const snapshot = player(undefined, { kills: 100, headshots: 50 });
|
|
for (const event of ['game_start', 'round_change', 'game_end']) {
|
|
expect(() => parseGameEvent({ event, server, map: 'zombie_theater', round: 5, players: [snapshot, snapshot] }))
|
|
.toThrow(/guid must be unique/);
|
|
}
|
|
});
|
|
|
|
it('keeps T6 location variants distinct when they share the same engine map', async () => {
|
|
await report('game_end', 8, [player()], 't6_zombie_town_1', 'zm_transit');
|
|
await report('game_end', 12, [player()], 't6_zombie_farm_1', 'zm_transit');
|
|
const rows = await matches();
|
|
expect(rows.map(row => row.game)).toEqual(['t6', 't6']);
|
|
expect(rows.map(row => row.map)).toEqual(['zm_transit', 'zm_transit']);
|
|
expect(new Set(rows.map(row => row.map_key)).size).toBe(2);
|
|
expect(rows.map(row => row.map_name)).toEqual(['Town Survival', 'Farm Survival']);
|
|
});
|
|
|
|
it('retains historical records across app restarts and server removal', async () => {
|
|
await report('game_start');
|
|
await report('round_change', 4, [player(undefined, { kills: 100 })]);
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
closeDb();
|
|
await report('game_end', 5, [player(undefined, { kills: 150 })]);
|
|
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
|
|
closeDb();
|
|
expect(await records()).toEqual([expect.objectContaining({ kills: 150, rounds_survived: 4, completed: 1 })]);
|
|
expect(await matches()).toHaveLength(1);
|
|
});
|
|
|
|
it('backfills valid saved telemetry once while skipping malformed and legacy snapshots', async () => {
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
const receivedAt = '2026-09-18T20:01:39.729Z';
|
|
const saved = { event: 'round_change', server, map: 'zombie_theater', round: 8,
|
|
players: [player(undefined, { kills: 111, headshots: 61 })], receivedAt };
|
|
const insert = db().prepare('INSERT INTO server_telemetry (server, round, players, received_at, payload) VALUES (?, ?, ?, ?, ?)');
|
|
insert.run(server, 8, 1, receivedAt, JSON.stringify(saved));
|
|
insert.run('t6_zombie_town_1', 99, 4, receivedAt, '{malformed');
|
|
insert.run('t6_zombie_farm_1', 99, 4, receivedAt, null);
|
|
db().prepare("DELETE FROM settings WHERE key = 'leaderboard_v1'").run();
|
|
closeDb();
|
|
expect(await matches()).toEqual([expect.objectContaining({ highest_round: 8, start_observed: 0 })]);
|
|
expect(await records()).toEqual([expect.objectContaining({ kills: 111, headshots: 61, rounds_survived: 0 })]);
|
|
expect(db().prepare("SELECT value FROM settings WHERE key = 'leaderboard_v1'").get()).toEqual({ value: '1' });
|
|
closeDb();
|
|
await report('round_change', 9, [player(undefined, { kills: 150, headshots: 80 })]);
|
|
closeDb();
|
|
expect(await matches()).toHaveLength(1);
|
|
expect(await records()).toEqual([expect.objectContaining({ kills: 150, headshots: 80, rounds_survived: 1 })]);
|
|
});
|
|
|
|
it('does not fabricate players for an empty match', async () => {
|
|
await report('game_start', 0, []);
|
|
await report('game_end', 0, []);
|
|
expect(await records()).toEqual([]);
|
|
expect(await matches()).toEqual([expect.objectContaining({ highest_round: 0, status: 'completed' })]);
|
|
});
|
|
|
|
it('rolls back history if writing the latest scoreboard fails', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().exec(`CREATE TRIGGER reject_scoreboard BEFORE INSERT ON server_telemetry
|
|
BEGIN SELECT RAISE(ABORT, 'scoreboard write failed'); END`);
|
|
await expect(report('round_change', 8, [player(undefined, { kills: 111 })])).rejects.toThrow('scoreboard write failed');
|
|
expect(await records()).toEqual([]);
|
|
expect(await matches()).toEqual([]);
|
|
expect(db().prepare('SELECT COUNT(*) AS count FROM leaderboard_players').get()).toEqual({ count: 0 });
|
|
});
|