Link personal bests to their recorded matches

This commit is contained in:
2026-10-01 12:12:21 -04:00
parent 13963cba1c
commit c7d71c63ba
13 changed files with 304 additions and 14 deletions
+15 -3
View File
@@ -123,7 +123,11 @@ describe('persisted leaderboard queries', () => {
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
lastSeen: '2026-09-05T00:00:00.000Z'
lastSeen: '2026-09-05T00:00:00.000Z', records: {
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
}
}]);
expect(board.total).toBe(4);
expect(board.entries.map(entry => [entry.guid, entry.rank])).toEqual([['101', 1], ['505', 2], ['202', 3], ['303', 4]]);
@@ -141,14 +145,22 @@ describe('persisted leaderboard queries', () => {
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
rank: 1, 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'
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z', records: {
highestRound: expect.objectContaining({ matchId: 1, value: 10 }),
bestKills: expect.objectContaining({ matchId: 2, value: 150 }),
bestScore: expect.objectContaining({ matchId: 2, value: 3000 })
}
});
expect(t5).toMatchObject({ total: 3, summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 } });
const t6 = getLeaderboard({ game: 't6' });
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1,
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z'
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z', records: {
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
}
});
expect(t6).toMatchObject({ total: 2, summary: { players: 2, matches: 2, kills: 700, roundsSurvived: 33 } });
});
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import type { GameTelemetry, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => fixture.cleanup());
const timestamp = (minute: number) => new Date(Date.UTC(2026, 8, 18, 20, minute)).toISOString();
const player = (guid: string, kills = 0): TelemetryPlayerStats => ({
guid, name: guid, kills, headshots: 0, downs: 0, revives: 0, score: 0
});
function seed(handle: Database.Database, id: number, options: {
game?: string; map?: string; guid?: string; round?: number; kills?: number; score?: number;
ended?: string | null; received?: string; status?: string; hasStats?: number; observed?: number | null;
} = {}) {
const { game = 't5', map = 'theater', guid = '101', round = 10, kills = 100, score = 1000,
ended = timestamp(id), received = timestamp(id), status = 'completed', hasStats = 1, observed = null } = options;
handle.prepare(`INSERT INTO leaderboard_matches (id, server, game, map, map_key, map_name, started_at,
ended_at, last_received_at, status, highest_round, last_event, last_fingerprint)
VALUES (?, 't5_zombie_theater_1', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'game_end', ?)`)
.run(id, game, map, `${game}:${map}`, `${map} name`, timestamp(0), ended, received, status, round, String(id));
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?)`).run(game, guid, guid, timestamp(0), received);
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, kills, best_score,
has_stats, completed, first_seen_at, last_seen_at, first_observed_round)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`).run(id, guid, round, kills, score, hasStats, timestamp(0), received, observed);
}
it('links each best to its exact player/game/map scope, including profile map records', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
seed(handle, 1, { round: 20, kills: 100, score: 500 });
seed(handle, 2, { round: 10, kills: 200, score: 2000 });
seed(handle, 3, { map: 'moon', round: 30, kills: 300, score: 1000 });
seed(handle, 4, { game: 't6', map: 'town', round: 40, kills: 400, score: 4000 });
seed(handle, 5, { guid: '202', round: 50, kills: 500, score: 5000 });
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const overall = getLeaderboard({ q: '101' }).entries[0];
expect(overall.rank).toBe(2);
expect(overall.records.highestRound).toEqual({ matchId: 4, game: 't6', mapKey: 't6:town', mapName: 'town name',
server: 't5_zombie_theater_1', startedAt: timestamp(0), endedAt: timestamp(4), startObserved: false,
presentAtStart: null, firstObservedRound: null, value: 40 });
for (const metric of ['highestRound', 'bestKills', 'bestScore'] as const) {
expect(overall.records[metric]).toMatchObject({ matchId: 4, value: overall[metric] });
}
const t5 = getLeaderboard({ game: 't5', q: '101' }).entries[0].records;
expect(t5.highestRound).toMatchObject({ matchId: 3, value: 30 });
expect(t5.bestKills).toMatchObject({ matchId: 3, value: 300 });
expect(t5.bestScore).toMatchObject({ matchId: 2, value: 2000 });
const theater = getLeaderboard({ map: 't5:theater', q: '101' }).entries[0].records;
expect(theater.highestRound).toMatchObject({ matchId: 1, value: 20 });
expect(theater.bestKills).toMatchObject({ matchId: 2, value: 200 });
expect(theater.bestScore).toMatchObject({ matchId: 2, value: 2000 });
expect(getLeaderboard({ offset: 1, limit: 1 }).entries[0].records).toEqual(overall.records);
const profile = getPlayerProfile('101')!;
expect(profile.records).toEqual(overall.records);
expect(profile.maps.find(map => map.key === 't5:theater')!.records).toEqual(theater);
expect(profile.maps.find(map => map.key === 't5:moon')!.records.bestScore).toMatchObject({ matchId: 3, value: 1000 });
});
it('resolves tied bests by earliest completion, receipt time for unknown endings, then match ID', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
seed(handle, 1, { ended: timestamp(5) });
seed(handle, 2, { ended: timestamp(4), received: timestamp(1) });
seed(handle, 3, { ended: null, received: timestamp(2) });
seed(handle, 4, { ended: timestamp(2), received: timestamp(0) });
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
for (const records of [getLeaderboard().entries[0].records, getPlayerProfile('101')!.records]) {
for (const record of Object.values(records)) expect(record).toMatchObject({ matchId: 3, endedAt: null });
}
});
it('never uses unfinished, unsupported, nonqualifying or unrecorded stats as evidence', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
seed(handle, 1);
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const board = getLeaderboard();
const profile = getPlayerProfile('101');
seed(handle, 2, { status: 'active', round: 99, kills: 9999, score: 9999 });
seed(handle, 3, { status: 'interrupted', round: 99, kills: 9999, score: 9999 });
seed(handle, 4, { game: 'custom', round: 99, kills: 9999, score: 9999 });
seed(handle, 5, { round: 1, kills: 9999, score: 9999 });
seed(handle, 6, { hasStats: 0, round: 99, kills: 9999, score: 9999 });
expect(getLeaderboard()).toEqual(board);
expect(getPlayerProfile('101')).toEqual(profile);
});
it('records first observed rounds for start participants and late arrivals without overwriting on return', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
const common = { server: 't5_zombie_theater_1', map: 'zombie_theater' };
const report = (event: GameTelemetry['event'], round: number, players: TelemetryPlayerStats[], minute: number) => {
recordLeaderboardTelemetry(db(), event === 'game_start'
? { ...common, event, players, receivedAt: timestamp(minute) }
: { ...common, event, round, players, receivedAt: timestamp(minute) });
};
report('game_start', 0, [player('101')], 0);
report('heartbeat', 0, [player('101'), player('303')], 1);
report('heartbeat', 50, [player('101', 100), player('202', 20)], 2);
report('heartbeat', 51, [], 3);
report('game_end', 52, [player('101', 200), player('202', 40), player('303')], 4);
closeDb();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
expect(getPlayerProfile('101')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
firstObservedRound: 0, presentAtStart: true });
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
firstObservedRound: 50, presentAtStart: false });
expect(getPlayerProfile('303')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
firstObservedRound: 0, presentAtStart: false });
});
it('migrates legacy participation as unknown before snapshot backfill and preserves it across updates and reopens', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
seed(handle, 1, { status: 'active' });
handle.exec('ALTER TABLE leaderboard_match_players DROP COLUMN first_observed_round');
handle.exec('ALTER TABLE leaderboard_match_players DROP COLUMN present_at_start');
handle.prepare(`INSERT INTO servers (name, komodo_name, game, map)
VALUES ('t5_zombie_moon_1', 'moon', 't5', 'zombie_moon')`).run();
const saved: GameTelemetry = { event: 'game_end', server: 't5_zombie_moon_1', map: 'zombie_moon', round: 8,
players: [player('202', 80)], receivedAt: timestamp(2) };
handle.prepare('INSERT INTO server_telemetry (server, round, players, received_at, payload) VALUES (?, ?, ?, ?, ?)')
.run(saved.server, 8, 1, saved.receivedAt, JSON.stringify(saved));
handle.prepare("DELETE FROM settings WHERE key = 'leaderboard_v1'").run();
closeDb();
expect(db().prepare('SELECT guid, first_observed_round, present_at_start FROM leaderboard_match_players ORDER BY guid').all()).toEqual([
{ guid: '101', first_observed_round: null, present_at_start: null },
{ guid: '202', first_observed_round: 8, present_at_start: 0 }
]);
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
recordLeaderboardTelemetry(db(), { event: 'game_end', server: 't5_zombie_theater_1', map: 'theater', round: 11,
players: [player('101', 150)], receivedAt: timestamp(3) });
closeDb();
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
expect(getPlayerProfile('101')!.records.bestKills).toMatchObject({ matchId: 1, value: 150,
firstObservedRound: null, presentAtStart: null });
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({ firstObservedRound: 8,
startObserved: false, presentAtStart: false });
});