diff --git a/docs/leaderboard.md b/docs/leaderboard.md index ae0ed66..f910b99 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -25,6 +25,14 @@ across both games. Map names link back to that map's leaderboard in its game. Use **Refresh stats** to load the latest reports. Ratios with a zero denominator display a dash. Profiles always cover all recorded maps across both games. +Highest-round, best-game-kill and highest-score records link to the completed +match that earned them on the leaderboard and in lifetime/per-map profile stats. +Each source shows its map, game, completion time, server, and whether the match +start was recorded. **Run details** shows when the player was first observed: +at the recorded start, at a later round, or unknown for older stored records. +Presence is not proof of continuous survival. Equal personal bests link to the +earliest recorded completion, then the lowest match ID when times tie. + Completed matches link to their own results page at `/matches/[id]` from global Match History, server history, and player profiles. The page shows the recorded map and server, final round, start/end times, duration when the start was observed, diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts index f36a542..13c6347 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -14,11 +14,27 @@ export const leaderboardMetrics = [ export type LeaderboardMetric = typeof leaderboardMetrics[number]['key']; export type PlayerStats = Record; +export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore'; +export interface RecordEvidence { + matchId: number; + game: string; + mapKey: string; + mapName: string; + server: string; + startedAt: string; + endedAt: string | null; + startObserved: boolean; + presentAtStart: boolean | null; + firstObservedRound: number | null; + value: number; +} +export type PersonalBestRecords = Record; export interface PlayerMapRecord extends PlayerStats { game: string; key: string; name: string; lastSeen: string; + records: PersonalBestRecords; } export interface PlayerMatch { id: number; @@ -44,6 +60,7 @@ export interface PlayerProfile { firstSeen: string; lastSeen: string; stats: PlayerStats; + records: PersonalBestRecords; maps: PlayerMapRecord[]; matches: PlayerMatch[]; offset: number; @@ -69,6 +86,7 @@ export interface LeaderboardEntry { bestKills: number; bestScore: number; lastSeen: string; + records: PersonalBestRecords; } export interface LeaderboardMap { diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index eb49db0..09f5c21 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -360,6 +360,10 @@ export function db(): Database.Database { assertSnapshotSchemeSafe(handle); migrateGameTelemetry(handle); migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]); + migrateTable(handle, 'leaderboard_match_players', [ + { name: 'first_observed_round', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN first_observed_round INTEGER' }, + { name: 'present_at_start', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN present_at_start INTEGER' } + ]); backfillLeaderboard(handle); initializeFleetSettings(handle); } catch (err) { diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 80b5f95..37f7fa5 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS leaderboard_match_players ( completed INTEGER NOT NULL DEFAULT 0, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, + first_observed_round INTEGER, -- null for legacy participation + present_at_start INTEGER, -- null for legacy participation; one only when first seen in game_start PRIMARY KEY (match_id, guid) ); CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id); diff --git a/src/lib/server/fleet/leaderboard-ingest.ts b/src/lib/server/fleet/leaderboard-ingest.ts index 84b0072..9e09dfd 100644 --- a/src/lib/server/fleet/leaderboard-ingest.ts +++ b/src/lib/server/fleet/leaderboard-ingest.ts @@ -91,8 +91,8 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`); const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, rounds_survived, kills, headshots, downs, revives, best_score, - last_round, present, has_stats, completed, first_seen_at, last_seen_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) ON CONFLICT(match_id, guid) DO UPDATE SET highest_round = MAX(highest_round, excluded.highest_round), rounds_survived = rounds_survived + excluded.rounds_survived, @@ -113,7 +113,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga const stats = 'kills' in player ? player : null; savePlayer.run(match.id, player.guid, round, survived, stats?.kills ?? 0, stats?.headshots ?? 0, stats?.downs ?? 0, stats?.revives ?? 0, stats?.score ?? 0, - completedRounds, Number(!isStart), Number(report.event === 'game_end'), report.receivedAt, report.receivedAt); + completedRounds, Number(!isStart), Number(report.event === 'game_end'), report.receivedAt, report.receivedAt, round, Number(isStart)); } handle.prepare(`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?), last_received_at = ?, last_event = ?, last_fingerprint = ?, diff --git a/src/lib/server/fleet/leaderboard-records.ts b/src/lib/server/fleet/leaderboard-records.ts new file mode 100644 index 0000000..ab2f9a2 --- /dev/null +++ b/src/lib/server/fleet/leaderboard-records.ts @@ -0,0 +1,54 @@ +import type Database from 'better-sqlite3'; +import type { PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js'; +import { MIN_RECORDED_ROUND } from '../../match-history.js'; + +interface RecordRequest extends Record { + guid: string; + game?: string; + map?: string; +} + +/** Fetch evidence only for requested players and scopes, never rank the whole fleet's history. */ +export function getPersonalBestRecords(handle: Database.Database, requests: RecordRequest[]): PersonalBestRecords[] { + const result: PersonalBestRecords[] = []; + // Page requests are at most 100 players; chunk unusually large profile map collections too. + for (let offset = 0; offset < requests.length; offset += 100) { + const batch = requests.slice(offset, offset + 100); + const parameters = batch.flatMap((request, index) => [index, request.guid, request.game ?? null, + request.map ?? null, request.highestRound, request.bestKills, request.bestScore]); + const rows = handle.prepare(`WITH requested (requestId, guid, game, map, highestRound, bestKills, bestScore) AS ( + VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')} + ), candidates AS ( + SELECT r.requestId, m.id AS matchId, m.game, m.map_key AS mapKey, m.map_name AS mapName, + m.server, m.started_at AS startedAt, m.ended_at AS endedAt, m.start_observed AS startObserved, + mp.first_observed_round AS firstObservedRound, mp.present_at_start AS presentAtStart, + COALESCE(m.ended_at, m.last_received_at) AS completionOrder, + mp.highest_round AS highestRound, mp.kills AS bestKills, mp.best_score AS bestScore, + r.highestRound AS targetRound, r.bestKills AS targetKills, r.bestScore AS targetScore + FROM requested r JOIN leaderboard_match_players mp ON mp.guid = r.guid + JOIN leaderboard_matches m ON m.id = mp.match_id + WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.has_stats = 1 + AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND} + AND (r.game IS NULL OR m.game = r.game) AND (r.map IS NULL OR m.map_key = r.map) + AND (mp.highest_round = r.highestRound OR mp.kills = r.bestKills OR mp.best_score = r.bestScore) + ), valuesByMetric AS ( + SELECT *, 'highestRound' AS metric, highestRound AS value FROM candidates WHERE highestRound = targetRound + UNION ALL SELECT *, 'bestKills', bestKills FROM candidates WHERE bestKills = targetKills + UNION ALL SELECT *, 'bestScore', bestScore FROM candidates WHERE bestScore = targetScore + ), ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY requestId, metric ORDER BY completionOrder, matchId) AS choice + FROM valuesByMetric + ) SELECT requestId, metric, value, matchId, game, mapKey, mapName, server, startedAt, endedAt, + startObserved, firstObservedRound, presentAtStart FROM ranked WHERE choice = 1`) + .all(...parameters) as Array & { + requestId: number; metric: PersonalBestMetric; startObserved: number; presentAtStart: number | null; + }>; + const records = batch.map(() => ({} as PersonalBestRecords)); + for (const { requestId, metric, startObserved, presentAtStart, ...record } of rows) { + records[requestId][metric] = { ...record, startObserved: Boolean(startObserved), + presentAtStart: presentAtStart === null ? null : Boolean(presentAtStart) }; + } + result.push(...records); + } + return result; +} diff --git a/src/lib/server/fleet/leaderboard.ts b/src/lib/server/fleet/leaderboard.ts index 2bc1570..4fd2b13 100644 --- a/src/lib/server/fleet/leaderboard.ts +++ b/src/lib/server/fleet/leaderboard.ts @@ -1,5 +1,6 @@ import { db } from '../db/index.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js'; +import { getPersonalBestRecords } from './leaderboard-records.js'; import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric, type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js'; @@ -41,9 +42,13 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes const query = options.q?.trim() ?? ''; const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : ''; const searchedParameters = query ? [...parameters, query, query] : parameters; - const entries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search} + const rankedEntries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search} ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`) - .all(...searchedParameters, limit, offset) as LeaderboardEntry[]; + .all(...searchedParameters, limit, offset) as Array>; + const records = getPersonalBestRecords(handle, rankedEntries.map(entry => ({ + ...entry, game: options.game || undefined, map: options.map || undefined + }))); + const entries = rankedEntries.map((entry, index) => ({ ...entry, records: records[index] })); const { total } = handle.prepare(`${aggregated} SELECT COUNT(*) AS total FROM ranked ${search}`) .get(...searchedParameters) as { total: number }; const summary = handle.prepare(`SELECT diff --git a/src/lib/server/fleet/player-profile.ts b/src/lib/server/fleet/player-profile.ts index c3c4524..a84f7c0 100644 --- a/src/lib/server/fleet/player-profile.ts +++ b/src/lib/server/fleet/player-profile.ts @@ -1,5 +1,6 @@ import { db } from '../db/index.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js'; +import { getPersonalBestRecords } from './leaderboard-records.js'; import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js'; const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills, @@ -23,15 +24,18 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi const limit = 25; const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit; const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0; - const maps = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name, + const mapStats = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name, MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope} GROUP BY m.game, m.map_key ORDER BY highestRound DESC, kills DESC, name COLLATE NOCASE, m.game, key`) - .all(guid) as PlayerMapRecord[]; + .all(guid) as Array>; + const [records, ...mapRecords] = getPersonalBestRecords(handle, [{ guid, ...stats }, + ...mapStats.map(map => ({ guid, ...map, map: map.key }))]); + const maps = mapStats.map((map, index) => ({ ...map, records: mapRecords[index] })); const matches = handle.prepare(`SELECT m.id, m.game, m.server, m.server_deleted_at, m.map_name AS mapName, m.started_at AS startedAt, mp.last_seen_at AS lastSeen, m.status, mp.completed, mp.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived, mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore ${scope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`) .all(guid, limit, offset) as PlayerMatch[]; - return { ...player, stats, maps, matches, offset, limit }; + return { ...player, stats, records, maps, matches, offset, limit }; } diff --git a/src/lib/ui/components/RecordSource.svelte b/src/lib/ui/components/RecordSource.svelte new file mode 100644 index 0000000..3fc9f28 --- /dev/null +++ b/src/lib/ui/components/RecordSource.svelte @@ -0,0 +1,35 @@ + + +
+ {evidence.mapName} · {gameName} + {#if evidence.endedAt}Completed {:else}Completed · End time unknown{/if} +
+ Run details +
+
Server
{evidence.server}
+
{evidence.startObserved ? 'Match started' : 'Match first observed'}
+
Player participation
{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}
+
+

Recorded presence does not establish continuous survival.

+
+
+ + diff --git a/src/routes/leaderboard/+page.svelte b/src/routes/leaderboard/+page.svelte index aef97ce..708cb1b 100644 --- a/src/routes/leaderboard/+page.svelte +++ b/src/routes/leaderboard/+page.svelte @@ -8,6 +8,7 @@ import Button from '$ui/components/primitives/Button.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import CopyValue from '$ui/components/CopyValue.svelte'; + import RecordSource from '$ui/components/RecordSource.svelte'; const number = new Intl.NumberFormat('en-US'); const pageSize = 50; @@ -212,7 +213,7 @@ {entry.name}
- {#each columns as key (key)}{number.format(entry[key])}{/each} + {#each columns as key (key)}{number.format(entry[key])}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}{/if}{/each} {/each} diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index fb07564..9407318 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -6,6 +6,7 @@ import Button from '$ui/components/primitives/Button.svelte'; import CopyValue from '$ui/components/CopyValue.svelte'; import Badge from '$ui/components/primitives/Badge.svelte'; + import RecordSource from '$ui/components/RecordSource.svelte'; let { data }: { data: { profile: PlayerProfile } } = $props(); const profile = $derived(data.profile); @@ -40,7 +41,7 @@

Lifetime stats

{#each leaderboardMetrics as metric (metric.key)} -
{metric.label}{number.format(stats[metric.key])}
+
{metric.label}{number.format(stats[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}{/if}
{/each}
@@ -65,7 +66,7 @@ Map{#each leaderboardMetrics as metric}{metric.label}{/each} {#each profile.maps as map (`${map.game}:${map.key}`)} {map.name}{gameName(map.game)}Last played {date(map.lastSeen)} - {#each leaderboardMetrics as metric}{number.format(map[metric.key])}{/each} + {#each leaderboardMetrics as metric}{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}{/if}{/each} {/each} diff --git a/tests/unit/leaderboard-query.test.ts b/tests/unit/leaderboard-query.test.ts index 83c622a..3f6475e 100644 --- a/tests/unit/leaderboard-query.test.ts +++ b/tests/unit/leaderboard-query.test.ts @@ -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 } }); }); diff --git a/tests/unit/leaderboard-records.test.ts b/tests/unit/leaderboard-records.test.ts new file mode 100644 index 0000000..b603314 --- /dev/null +++ b/tests/unit/leaderboard-records.test.ts @@ -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 }); +});