789 lines
31 KiB
TypeScript
789 lines
31 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());
|
|
|
|
const emptyProfile = {
|
|
stats: {
|
|
highestRound: 0,
|
|
kills: 0,
|
|
roundsSurvived: 0,
|
|
headshots: 0,
|
|
revives: 0,
|
|
downs: 0,
|
|
gamesPlayed: 0,
|
|
gamesCompleted: 0,
|
|
bestKills: 0,
|
|
bestScore: 0
|
|
},
|
|
records: null,
|
|
maps: [],
|
|
matches: [],
|
|
matchesTotal: 0,
|
|
offset: 0,
|
|
limit: 25
|
|
};
|
|
|
|
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('keeps report revisions stable during a match and invalidates completed data and renamed players', async () => {
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
const revision = () =>
|
|
(
|
|
db()
|
|
.prepare("SELECT revision FROM report_revisions WHERE topic='leaderboard'")
|
|
.get() as { revision: number }
|
|
).revision;
|
|
await report('game_start');
|
|
const started = revision();
|
|
for (let round = 1; round <= 30; round++) {
|
|
await report('round_change', round, [player(undefined, { kills: round * 10 })]);
|
|
await report('heartbeat', round);
|
|
}
|
|
expect(revision()).toBe(started);
|
|
await report('game_end', 30);
|
|
expect(revision()).toBeGreaterThan(started);
|
|
const completed = revision();
|
|
db().prepare('UPDATE leaderboard_players SET last_seen_at=?').run('2026-10-06T12:00:00.000Z');
|
|
expect(revision()).toBe(completed);
|
|
db().prepare("UPDATE leaderboard_players SET name='Renamed'").run();
|
|
expect(revision()).toBe(completed + 1);
|
|
db().prepare('UPDATE leaderboard_match_players SET kills=kills+1').run();
|
|
expect(revision()).toBe(completed + 2);
|
|
closeDb();
|
|
const reopened = revision();
|
|
db().prepare('DELETE FROM leaderboard_matches').run();
|
|
expect(revision()).toBeGreaterThan(reopened);
|
|
});
|
|
|
|
it.each([
|
|
['t5', server, 'zombie_theater'],
|
|
['t6', 't6_zombie_town_1', 'zm_transit']
|
|
])(
|
|
'loads a known %s player before qualification and refreshes cached profiles after game end',
|
|
async (game, source, map) => {
|
|
const { load } =
|
|
await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
|
const request = {
|
|
params: { guid: player().guid },
|
|
url: new URL(
|
|
`http://localhost/leaderboard/players/${player().guid}?game=${game}&offset=999`
|
|
)
|
|
};
|
|
const send = (event: GameEvent['event'], round = 0) =>
|
|
report(event, round, [player(undefined, { kills: 20 })], source, map);
|
|
|
|
await expect(load(request)).rejects.toMatchObject({ status: 404 });
|
|
await send('game_start');
|
|
const started = await load(request);
|
|
expect(started).toMatchObject({
|
|
profile: { ...emptyProfile, guid: player().guid, name: player().name },
|
|
scope: { entry: null }
|
|
});
|
|
expect(started.profile.firstSeen).toBe('2026-09-18T20:00:00.000Z');
|
|
expect(started.profile.lastSeen).toBe(started.profile.firstSeen);
|
|
await send('game_end', 1);
|
|
expect((await load(request)).profile).toMatchObject(emptyProfile);
|
|
await send('game_start');
|
|
await send('heartbeat', 2);
|
|
expect((await load(request)).profile).toMatchObject(emptyProfile);
|
|
await send('game_end', 2);
|
|
const completed = await load(request);
|
|
expect(completed).toMatchObject({
|
|
profile: {
|
|
stats: { highestRound: 2, kills: 20, gamesPlayed: 1 },
|
|
records: { highestRound: { value: 2 } },
|
|
matchesTotal: 1,
|
|
offset: 0
|
|
},
|
|
scope: { entry: { rank: 1, kills: 20 } }
|
|
});
|
|
await send('game_start');
|
|
await send('heartbeat', 10);
|
|
expect((await load(request)).profile.stats).toEqual(completed.profile.stats);
|
|
}
|
|
);
|
|
|
|
it('publishes round 2 or higher games only after game end, preserving early stats and excluding round 1 departures', 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(player().guid)).toMatchObject(emptyProfile);
|
|
expect(serverMatchHistory()).toEqual({ total: 0, matches: [], maps: [], page: 1, limit: 25 });
|
|
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
|
|
expect(getLeaderboard()).toMatchObject({
|
|
entries: [],
|
|
maps: [],
|
|
total: 0,
|
|
summary: { players: 0, matches: 0, kills: 0 }
|
|
});
|
|
expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
|
|
expect(serverMatchHistory()).toMatchObject({ total: 1, matches: [{ status: 'active' }] });
|
|
expect(getPlayerProfile(departed.guid)).toMatchObject(emptyProfile);
|
|
await report('game_end', 2, [player(undefined, { kills: 10 })]);
|
|
const board = getLeaderboard();
|
|
const profile = getPlayerProfile(player().guid);
|
|
const history = serverMatchHistory();
|
|
expect(board).toMatchObject({
|
|
total: 1,
|
|
entries: [
|
|
expect.objectContaining({
|
|
highestRound: 2,
|
|
kills: 10,
|
|
bestScore: 1000,
|
|
roundsSurvived: 1,
|
|
gamesPlayed: 1,
|
|
gamesCompleted: 1
|
|
})
|
|
],
|
|
summary: { players: 1, matches: 1, kills: 10 }
|
|
});
|
|
expect(getPlayerProfile(departed.guid)).toMatchObject(emptyProfile);
|
|
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(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,
|
|
limit: 1
|
|
});
|
|
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, limit: 1 });
|
|
});
|
|
|
|
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)).toMatchObject({
|
|
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: [],
|
|
maps: [],
|
|
page: 1,
|
|
limit: 25
|
|
});
|
|
});
|
|
|
|
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('combines the same GUID into one profile and ranking across games', 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, closeDb } = 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 });
|
|
closeDb();
|
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
const profile = getPlayerProfile(player().guid)!;
|
|
expect(profile).toMatchObject({
|
|
guid: player().guid,
|
|
name: 'Other game',
|
|
firstSeen: '2026-09-18T20:00:00.000Z',
|
|
lastSeen: '2026-09-18T20:00:04.000Z',
|
|
stats: {
|
|
gamesPlayed: 3,
|
|
gamesCompleted: 3,
|
|
kills: 340,
|
|
highestRound: 8,
|
|
bestKills: 200,
|
|
roundsSurvived: 1
|
|
}
|
|
});
|
|
expect(profile.matches.map((match) => match.game)).toEqual(['t6', 't5', 't5']);
|
|
const board = getLeaderboard();
|
|
expect(board).toMatchObject({ total: 1, summary: { players: 1, matches: 3, kills: 340 } });
|
|
expect(board.entries).toEqual([
|
|
expect.objectContaining({
|
|
...profile.stats,
|
|
guid: player().guid,
|
|
name: profile.name,
|
|
rank: 1
|
|
})
|
|
]);
|
|
expect(getLeaderboard({ game: 't5' }).entries).toEqual([
|
|
expect.objectContaining({
|
|
guid: player().guid,
|
|
name: 'Other game',
|
|
kills: 140,
|
|
gamesPlayed: 2
|
|
})
|
|
]);
|
|
expect(getLeaderboard({ game: 't6' }).entries).toEqual([
|
|
expect.objectContaining({
|
|
guid: player().guid,
|
|
name: 'Other game',
|
|
kills: 200,
|
|
gamesPlayed: 1
|
|
})
|
|
]);
|
|
await report('game_start', 0, [player(undefined, { name: 'Live name' })]);
|
|
expect(getPlayerProfile(player().guid)).toMatchObject({
|
|
name: 'Live name',
|
|
lastSeen: '2026-09-18T20:00:05.000Z',
|
|
stats: profile.stats,
|
|
maps: profile.maps,
|
|
matches: profile.matches
|
|
});
|
|
expect(getLeaderboard().entries).toEqual([
|
|
expect.objectContaining({ ...profile.stats, name: 'Live name' })
|
|
]);
|
|
});
|
|
|
|
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 { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
|
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]);
|
|
expect(getLeaderboard()).toMatchObject({ entries: [], total: 0 });
|
|
expect(getPlayerProfile(other.guid)).toMatchObject(emptyProfile);
|
|
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 })
|
|
]);
|
|
expect(getLeaderboard()).toMatchObject({
|
|
total: 2,
|
|
summary: { matches: 1, players: 2, kills: 140 }
|
|
});
|
|
expect(getPlayerProfile(other.guid)).toMatchObject({
|
|
stats: { kills: 40, highestRound: 4, roundsSurvived: 3, gamesPlayed: 1, gamesCompleted: 0 },
|
|
matches: [{ status: 'completed', completed: 0 }]
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[server, 'zombie_theater', 't5'],
|
|
['t6_zombie_town_1', 'zm_transit', 't6']
|
|
])('does not count same-round departures as games played on %s', async (source, map, game) => {
|
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
|
const departed = player('departed', { kills: 10 });
|
|
const advanced = player('advanced', { kills: 20 });
|
|
const finished = player('finished', { kills: 30 });
|
|
await report('game_start', 0, [], source, map);
|
|
await report('heartbeat', 5, [departed, advanced], source, map);
|
|
await report('heartbeat', 5, [player(departed.guid, { kills: 15 }), advanced], source, map);
|
|
await report('heartbeat', 5, [advanced], source, map);
|
|
await report('round_change', 6, [advanced, finished], source, map);
|
|
await report('game_end', 6, [finished], source, map);
|
|
|
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
|
closeDb();
|
|
const board = getLeaderboard({ game });
|
|
expect(board.entries.find((entry) => entry.guid === departed.guid)).toMatchObject({
|
|
gamesPlayed: 0,
|
|
gamesCompleted: 0,
|
|
kills: 15,
|
|
highestRound: 5,
|
|
averageKills: null,
|
|
averageRounds: null,
|
|
completionRate: null
|
|
});
|
|
expect(board.entries.find((entry) => entry.guid === advanced.guid)).toMatchObject({
|
|
gamesPlayed: 1,
|
|
gamesCompleted: 0
|
|
});
|
|
expect(board.entries.find((entry) => entry.guid === finished.guid)).toMatchObject({
|
|
gamesPlayed: 1,
|
|
gamesCompleted: 1
|
|
});
|
|
const profile = getPlayerProfile(departed.guid)!;
|
|
expect(profile).toMatchObject({
|
|
stats: { gamesPlayed: 0, kills: 15 },
|
|
maps: [{ gamesPlayed: 0, kills: 15 }],
|
|
matchesTotal: 1,
|
|
matches: [{ kills: 15, completed: 0 }],
|
|
records: { bestKills: { value: 15 } }
|
|
});
|
|
expect(
|
|
getLeaderboard({ game, map: profile.maps[0].key, metric: 'gamesPlayed' }).entries
|
|
).toContainEqual(expect.objectContaining({ guid: departed.guid, gamesPlayed: 0 }));
|
|
expect(
|
|
getPlayerProfile(departed.guid, 0, { game, map: profile.maps[0].key })?.matchesTotal
|
|
).toBe(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();
|
|
db().pragma('user_version = 0');
|
|
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'
|
|
});
|
|
expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
|
|
await report('game_end', 8, [player(undefined, { kills: 120 })]);
|
|
const profile = getPlayerProfile(player().guid)!;
|
|
expect(profile.stats).toMatchObject({ gamesPlayed: 1, kills: 120 });
|
|
expect(profile.matches).toEqual([
|
|
expect.objectContaining({
|
|
id: history.find((match) => match.id !== original.id)!.id,
|
|
server_deleted_at: null,
|
|
status: 'completed'
|
|
})
|
|
]);
|
|
});
|
|
|
|
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
|
|
});
|
|
});
|