Files
electric-cherry/tests/unit/leaderboard-ingest.test.ts
T
josh 981fb27c1c Preserve deleted server history
Retain historical server identities, results, and evidence after deletion. Mark archived entries as Deleted and prevent old work from targeting servers that reuse the same name.

Fixes #16.
2026-09-27 21:48:55 -04:00

337 lines
20 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;
}>;
}
it('starts counting at round 2, preserves early stats, and excludes players who only stayed for round 1', 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');
const departed = player('round-one-only', { kills: 5 });
await report('game_start', 0, [player(), departed]);
await report('round_change', 1, [player(undefined, { kills: 6, score: 1000 }), departed]);
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
expect(getPlayerProfile('t5', 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(getPlayerProfile('t5', departed.guid)).toBeNull();
await report('game_end', 2, [player(undefined, { kills: 10 })]);
const board = getLeaderboard();
const profile = getPlayerProfile('t5', player().guid);
const history = serverMatchHistory();
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 })]);
await report('game_start', 0, []);
await report('game_end', 1, []);
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
expect(getLeaderboard()).toEqual(board);
// Identity timestamps still reflect live presence, but gameplay records do not change.
expect(getPlayerProfile('t5', player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
expect(serverMatchHistory()).toEqual(history);
const { GET } = await import('../../src/routes/api/matches/+server.js');
expect(await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json()).toEqual(history);
const { GET: serverGET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
expect(await serverGET({ params: { name: server }, url: new URL('http://localhost/?limit=1') }).json()).toEqual(history);
});
it('records server match counts, unique rejoins, duration, and final round durably', async () => {
await report('game_start', 0, [player('a'), player('b')]);
await report('round_change', 2, [player('b'), player('c')]);
await report('heartbeat', 2, [player('a'), player('c')]);
await report('game_end', 7, [player('c')]);
await report('game_end', 7, [player('c')]);
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
expect(serverMatchHistory(server)).toEqual({ total: 1, matches: [expect.objectContaining({
status: 'completed', startPlayers: 2, uniquePlayers: 3, endPlayers: 1,
durationMs: 3000, finalRound: 7, endedAt: '2026-09-18T20:00:03.000Z'
})] });
expect(serverMatchHistory('other')).toEqual({ total: 0, matches: [] });
});
it('preserves unknown boundaries and distinguishes empty rosters from missing observations', async () => {
await report('heartbeat', 5);
await report('game_end', 6, []);
await report('game_start', 0, []);
await report('round_change', 2);
await report('game_start');
await report('round_change', 2);
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
expect(serverMatchHistory(server).matches).toEqual([
expect.objectContaining({ status: 'active', startPlayers: 1, endPlayers: null, durationMs: null, finalRound: null, endedAt: null }),
expect.objectContaining({ status: 'interrupted', startPlayers: 0, endPlayers: null, durationMs: null, finalRound: null, endedAt: null }),
expect.objectContaining({ status: 'completed', startPlayers: null, uniquePlayers: 1, endPlayers: 0, durationMs: null, finalRound: 6 })
]);
const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
const response = GET({ params: { name: server }, url: new URL('http://localhost/?limit=1') });
const body = await response.json();
expect(body.total).toBe(3);
expect(body.matches).toHaveLength(1);
expect(GET({ params: { name: server }, url: new URL('http://localhost/?limit=-1') }).status).toBe(400);
});
it('lists global matches newest first across servers with totals and ending timestamps', async () => {
await report('game_start');
await report('game_end', 8);
await report('game_start', 0, [player()], 't6_zombie_town_1', 'zm_transit');
await report('round_change', 2, [player()], 't6_zombie_town_1', 'zm_transit');
const { GET } = await import('../../src/routes/api/matches/+server.js');
const all = await GET({ url: new URL('http://localhost/api/matches') }).json();
expect(all.total).toBe(2);
expect(all.matches).toEqual([
expect.objectContaining({ server: 't6_zombie_town_1', endedAt: null }),
expect.objectContaining({ server, endedAt: '2026-09-18T20:00:01.000Z' })
]);
const limited = await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json();
expect(limited.total).toBe(2);
expect(limited.matches).toEqual([all.matches[0]]);
expect(GET({ url: new URL('http://localhost/api/matches?limit=0') }).status).toBe(400);
});
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('refreshes stats during a round without adding rounds or completing the match', async () => {
await report('game_start');
await report('round_change', 30, [player(undefined, { kills: 100 })]);
await report('heartbeat', 30, [player(undefined, { kills: 150 })]);
await report('heartbeat', 30, [player(undefined, { kills: 150 })]);
expect(await matches()).toEqual([expect.objectContaining({ status: 'active', highest_round: 30 })]);
expect(await records()).toEqual([expect.objectContaining({ kills: 150, rounds_survived: 29, completed: 0 })]);
await report('game_end', 30);
await report('heartbeat', 30);
expect((await matches()).map(match => match.status)).toEqual(['completed', 'active']);
});
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('keeps deleted match identities and starts a separate match when the server name is reused', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
await report('heartbeat', 8, [player(undefined, { kills: 120 })]);
const original = serverMatchHistory(server).matches[0];
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
await seedServerRegistry();
// Even the same report bytes must belong to the replacement's own match.
await report('heartbeat', 8, [player(undefined, { kills: 120 })]);
closeDb();
const history = serverMatchHistory(server).matches;
expect(history).toHaveLength(2);
expect(history.find(match => match.id === original.id)).toMatchObject({
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' });
const profile = getPlayerProfile('t5', 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);
});
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 });
});