Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -4,28 +4,84 @@ import type { GameTelemetry, TelemetryPlayerStats } from '../../src/lib/telemetr
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
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
|
||||
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,
|
||||
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,
|
||||
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);
|
||||
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 () => {
|
||||
@@ -40,9 +96,19 @@ it('links each best to its exact player/game/map scope, including profile map re
|
||||
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 });
|
||||
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] });
|
||||
}
|
||||
@@ -57,8 +123,11 @@ it('links each best to its exact player/game/map scope, including profile map re
|
||||
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 });
|
||||
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 () => {
|
||||
@@ -71,7 +140,8 @@ it('resolves tied bests by earliest completion, receipt time for unknown endings
|
||||
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 });
|
||||
for (const record of Object.values(records))
|
||||
expect(record).toMatchObject({ matchId: 3, endedAt: null });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,7 +157,13 @@ it('ranks each map best against distinct players using leaderboard ties, qualifi
|
||||
seed(handle, 7, { game: 't6', round: 6, kills: 6, score: 6 });
|
||||
seed(handle, 8, { game: 't6', guid: '202', round: 7, kills: 7, score: 7 });
|
||||
seed(handle, 9, { guid: 'active', status: 'active', round: 99, kills: 9999, score: 9999 });
|
||||
seed(handle, 10, { guid: 'interrupted', status: 'interrupted', round: 99, kills: 9999, score: 9999 });
|
||||
seed(handle, 10, {
|
||||
guid: 'interrupted',
|
||||
status: 'interrupted',
|
||||
round: 99,
|
||||
kills: 9999,
|
||||
score: 9999
|
||||
});
|
||||
seed(handle, 11, { guid: 'unrecorded', hasStats: 0, round: 99, kills: 9999, score: 9999 });
|
||||
seed(handle, 12, { guid: 'unqualified', round: 1, kills: 9999, score: 9999 });
|
||||
handle.prepare('UPDATE leaderboard_matches SET start_players=2 WHERE id IN (1,6,7)').run();
|
||||
@@ -95,16 +171,36 @@ it('ranks each map best against distinct players using leaderboard ties, qualifi
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const { getPlayerLeaderboardEntry } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const profile = getPlayerProfile('101')!;
|
||||
expect(profile.maps.find(map => map.key === 't5:theater')?.ranks).toEqual({ highestRound: 3, bestKills: 2, bestScore: 2 });
|
||||
expect(profile.maps.find(map => map.key === 't5:moon')?.ranks).toEqual({ highestRound: 1, bestKills: 1, bestScore: 1 });
|
||||
expect(profile.maps.find(map => map.key === 't6:theater')?.ranks).toEqual({ highestRound: 2, bestKills: 2, bestScore: 2 });
|
||||
for (const map of profile.maps) for (const metric of ['highestRound', 'bestKills', 'bestScore'] as const) {
|
||||
expect(map.ranks[metric]).toBe(getPlayerLeaderboardEntry('101', { game: map.game, map: map.key, metric })?.rank);
|
||||
}
|
||||
expect(getPlayerProfile('101', 0, { game: 't6', map: 't6:theater', players: 2 })?.maps).toEqual(profile.maps);
|
||||
expect(profile.maps.find((map) => map.key === 't5:theater')?.ranks).toEqual({
|
||||
highestRound: 3,
|
||||
bestKills: 2,
|
||||
bestScore: 2
|
||||
});
|
||||
expect(profile.maps.find((map) => map.key === 't5:moon')?.ranks).toEqual({
|
||||
highestRound: 1,
|
||||
bestKills: 1,
|
||||
bestScore: 1
|
||||
});
|
||||
expect(profile.maps.find((map) => map.key === 't6:theater')?.ranks).toEqual({
|
||||
highestRound: 2,
|
||||
bestKills: 2,
|
||||
bestScore: 2
|
||||
});
|
||||
for (const map of profile.maps)
|
||||
for (const metric of ['highestRound', 'bestKills', 'bestScore'] as const) {
|
||||
expect(map.ranks[metric]).toBe(
|
||||
getPlayerLeaderboardEntry('101', { game: map.game, map: map.key, metric })?.rank
|
||||
);
|
||||
}
|
||||
expect(getPlayerProfile('101', 0, { game: 't6', map: 't6:theater', players: 2 })?.maps).toEqual(
|
||||
profile.maps
|
||||
);
|
||||
seed(handle, 13, { guid: 'new-leader', round: 40, kills: 400, score: 4000 });
|
||||
expect(getPlayerProfile('101')?.maps.find(map => map.key === 't5:theater')?.ranks)
|
||||
.toEqual({ highestRound: 4, bestKills: 3, bestScore: 3 });
|
||||
expect(getPlayerProfile('101')?.maps.find((map) => map.key === 't5:theater')?.ranks).toEqual({
|
||||
highestRound: 4,
|
||||
bestKills: 3,
|
||||
bestScore: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('never uses unfinished, unsupported, nonqualifying or unrecorded stats as evidence', async () => {
|
||||
@@ -126,12 +222,21 @@ it('never uses unfinished, unsupported, nonqualifying or unrecorded stats as evi
|
||||
|
||||
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 { 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) });
|
||||
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);
|
||||
@@ -140,12 +245,24 @@ it('records first observed rounds for start participants and late arrivals witho
|
||||
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 });
|
||||
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 () => {
|
||||
@@ -154,25 +271,58 @@ it('migrates legacy participation as unknown before snapshot backfill and preser
|
||||
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, game, map)
|
||||
VALUES ('t5_zombie_moon_1', '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 (?, ?, ?, ?, ?)')
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO servers (name, game, map)
|
||||
VALUES ('t5_zombie_moon_1', '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([
|
||||
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) });
|
||||
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 });
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user