Add persistent player leaderboards from game telemetry

This commit is contained in:
2026-09-18 16:43:44 -04:00
parent 0f55fa5f77
commit a182aeb950
16 changed files with 1135 additions and 17 deletions
+209
View File
@@ -0,0 +1,209 @@
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 });
});
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { LeaderboardResponse } from '../../src/lib/leaderboard.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
async function seedRecords() {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const match = handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, last_event, last_fingerprint)
VALUES (?, 'removed-server', ?, ?, ?, ?, ?, ?, ?, 'round_change', 'test')`);
const player = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, '2026-09-01T00:00:00.000Z', '2026-09-05T00:00:00.000Z')`);
const record = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, kills, highest_round, rounds_survived, headshots, revives, downs, best_score,
completed, has_stats, first_seen_at, last_seen_at)
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');
match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'active');
match.run(3, 't5', 'zombie_coast', 't5:coast', 'Call of the Dead', iso(3), iso(4), 'completed');
match.run(4, 't6', 'zm_transit', 't6:town', 'Town Survival', iso(4), iso(5), 'completed');
match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'interrupted');
match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'active');
player.run('t5', '101', 'Ace');
player.run('t5', '202', 'Blake');
player.run('t5', '303', 'Casey');
player.run('t5', '404', 'No stats');
player.run('t6', '101', 'Other game');
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));
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));
record.run(3, '404', 9999, 99, 99, 999, 99, 99, 99999, 1, 0, iso(4));
record.run(4, '101', 500, 20, 19, 200, 4, 1, 5000, 1, 1, iso(5));
record.run(5, '505', 200, 15, 14, 80, 0, 3, 3500, 0, 1, iso(6));
record.run(6, '404', 9999, 99, 99, 999, 99, 99, 99999, 1, 0, iso(7));
return handle;
}
describe('persisted leaderboard queries', () => {
it('sums counters across matches, retains personal bests and separates games for the same GUID', async () => {
await seedRecords();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const board = getLeaderboard();
expect(board.entries.find(entry => entry.game === 't5' && entry.guid === '101')).toEqual({
rank: 3, game: 't5', guid: '101', name: 'Ace', highestRound: 10, kills: 270,
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3,
gamesPlayed: 3, gamesCompleted: 2, bestKills: 150, bestScore: 3000,
lastSeen: '2026-09-04T00:00:00.000Z'
});
expect(board.entries.find(entry => entry.game === 't6' && entry.guid === '101'))
.toMatchObject({ name: 'Other game', kills: 500, gamesPlayed: 1, rank: 1 });
expect(board.entries.some(entry => entry.guid === '404')).toBe(false);
expect(board.summary).toEqual({ players: 5, matches: 5, kills: 1140, roundsSurvived: 68,
highestRound: 20, firstTrackedAt: '2026-09-01T00:00:00.000Z', updatedAt: '2026-09-06T00:00:00.000Z' });
});
it('filters exact map variants and retains historical and custom map options independently of selection', async () => {
await seedRecords();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const theater = getLeaderboard({ game: 't5', map: 't5:theater', metric: 'kills' });
expect(theater.entries.map(entry => [entry.name, entry.kills])).toEqual([['Ace', 250], ['Blake', 100], ['Casey', 70]]);
expect(theater.summary).toMatchObject({ players: 3, matches: 2, kills: 420, roundsSurvived: 32, highestRound: 10 });
expect(theater.maps.map(map => map.key)).toEqual(['t5:coast', 't5:custom_map', 't5:theater']);
const town = getLeaderboard({ map: 't6:town' });
expect(town.entries.map(entry => entry.name)).toEqual(['Other game']);
expect(town.maps).toContainEqual({ key: 't6:farm', game: 't6', map: 'zm_transit', name: 'Farm Survival' });
});
it('uses competition ranks with deterministic ties and searches after ranking before pagination', async () => {
await seedRecords();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const board = getLeaderboard({ game: 't5' });
expect(board.entries.map(entry => [entry.name, entry.rank])).toEqual([['Ace', 1], ['Blake', 1], ['Casey', 3]]);
const search = getLeaderboard({ game: 't5', q: 'CASE', limit: 1 });
expect(search.entries.map(entry => [entry.name, entry.rank])).toEqual([['Casey', 3]]);
expect(search.total).toBe(1);
expect(search.summary).toEqual(board.summary);
expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0])
.toMatchObject({ name: 'Blake', rank: 2 });
expect(getLeaderboard({ q: '303' }).entries[0]).toMatchObject({ name: 'Casey', rank: 5 });
});
it('treats SQL and LIKE punctuation as literal input and allows only known ranking columns', async () => {
const handle = await seedRecords();
handle.prepare("UPDATE leaderboard_players SET name = '100%_Blake' WHERE guid = '202'").run();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
expect(getLeaderboard({ q: '%' }).entries.map(entry => entry.guid)).toEqual(['202']);
expect(getLeaderboard({ map: "' OR 1=1 --" }).entries).toEqual([]);
expect(getLeaderboard({ q: "' OR 1=1 --" }).entries).toEqual([]);
expect(getLeaderboard({ metric: 'kills; DROP TABLE leaderboard_players' as never }).metric).toBe('highestRound');
expect(getLeaderboard({ metric: 'downs', game: 't5' }).entries.map(entry => entry.downs)).toEqual([3, 2, 0]);
});
it('returns an empty summary and bounds pagination for direct callers', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
expect(getLeaderboard()).toEqual({ entries: [], maps: [], total: 0, limit: 50, offset: 0, metric: 'highestRound',
summary: { players: 0, matches: 0, kills: 0, roundsSurvived: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
expect(getLeaderboard({ limit: 1000, offset: -1 })).toMatchObject({ limit: 100, offset: 0 });
expect(getLeaderboard({ limit: NaN, offset: 2_000_000 })).toMatchObject({ limit: 50, offset: 1_000_000 });
});
});
describe('leaderboard API', () => {
it('returns the requested ranking and pagination', async () => {
await seedRecords();
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
const response = GET({ url: new URL('http://localhost/api/leaderboard?game=t5&map=t5%3Atheater&metric=kills&limit=1&offset=1') });
expect(response.status).toBe(200);
const result = await response.json() as LeaderboardResponse;
expect(result).toMatchObject({ metric: 'kills', limit: 1, offset: 1, total: 3 });
expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toMatchObject({ guid: '202', rank: 2, kills: 100 });
});
it('rejects invalid filters and non-integer or out-of-range pagination', async () => {
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
for (const query of ['game=t7', 'game=', 'metric=unknown', 'limit=0', 'limit=101', 'limit=',
'limit=1.5', 'limit=1e1', 'offset=-1', 'offset=1000001', 'offset=Infinity', 'offset=0.5',
`q=${'a'.repeat(101)}`, `map=${'a'.repeat(1025)}`]) {
const response = GET({ url: new URL(`http://localhost/api/leaderboard?${query}`) });
expect(response.status, query).toBe(400);
expect(await response.json()).toMatchObject({ ok: false, kind: 'bad-request' });
}
});
it('accepts returned opaque map keys longer than the telemetry map name limit', async () => {
const handle = await seedRecords();
const map = 'a'.repeat(255);
const key = `t5:${map}`;
handle.prepare('UPDATE leaderboard_matches SET map = ?, map_key = ?, map_name = ? WHERE id = 1')
.run(map, key, map);
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
const all = await GET({ url: new URL('http://localhost/api/leaderboard') }).json() as LeaderboardResponse;
const returnedKey = all.maps.find(option => option.map === map)!.key;
const response = GET({ url: new URL(`http://localhost/api/leaderboard?map=${encodeURIComponent(returnedKey)}`) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ total: 2, summary: { matches: 1, kills: 200 } });
});
});