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
+8
View File
@@ -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 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. 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 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 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, map and server, final round, start/end times, duration when the start was observed,
+18
View File
@@ -14,11 +14,27 @@ export const leaderboardMetrics = [
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key']; export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
export type PlayerStats = Record<LeaderboardMetric, number>; export type PlayerStats = Record<LeaderboardMetric, number>;
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<PersonalBestMetric, RecordEvidence>;
export interface PlayerMapRecord extends PlayerStats { export interface PlayerMapRecord extends PlayerStats {
game: string; game: string;
key: string; key: string;
name: string; name: string;
lastSeen: string; lastSeen: string;
records: PersonalBestRecords;
} }
export interface PlayerMatch { export interface PlayerMatch {
id: number; id: number;
@@ -44,6 +60,7 @@ export interface PlayerProfile {
firstSeen: string; firstSeen: string;
lastSeen: string; lastSeen: string;
stats: PlayerStats; stats: PlayerStats;
records: PersonalBestRecords;
maps: PlayerMapRecord[]; maps: PlayerMapRecord[];
matches: PlayerMatch[]; matches: PlayerMatch[];
offset: number; offset: number;
@@ -69,6 +86,7 @@ export interface LeaderboardEntry {
bestKills: number; bestKills: number;
bestScore: number; bestScore: number;
lastSeen: string; lastSeen: string;
records: PersonalBestRecords;
} }
export interface LeaderboardMap { export interface LeaderboardMap {
+4
View File
@@ -360,6 +360,10 @@ export function db(): Database.Database {
assertSnapshotSchemeSafe(handle); assertSnapshotSchemeSafe(handle);
migrateGameTelemetry(handle); migrateGameTelemetry(handle);
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]); 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); backfillLeaderboard(handle);
initializeFleetSettings(handle); initializeFleetSettings(handle);
} catch (err) { } catch (err) {
+2
View File
@@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS leaderboard_match_players (
completed INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL, first_seen_at TEXT NOT NULL,
last_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) PRIMARY KEY (match_id, guid)
); );
CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id); CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id);
+3 -3
View File
@@ -91,8 +91,8 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`); last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, rounds_survived, kills, headshots, downs, revives, best_score, (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) last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
ON CONFLICT(match_id, guid) DO UPDATE SET ON CONFLICT(match_id, guid) DO UPDATE SET
highest_round = MAX(highest_round, excluded.highest_round), highest_round = MAX(highest_round, excluded.highest_round),
rounds_survived = rounds_survived + excluded.rounds_survived, 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; const stats = 'kills' in player ? player : null;
savePlayer.run(match.id, player.guid, round, survived, savePlayer.run(match.id, player.guid, round, survived,
stats?.kills ?? 0, stats?.headshots ?? 0, stats?.downs ?? 0, stats?.revives ?? 0, stats?.score ?? 0, 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, ?), handle.prepare(`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?),
last_received_at = ?, last_event = ?, last_fingerprint = ?, last_received_at = ?, last_event = ?, last_fingerprint = ?,
@@ -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<PersonalBestMetric, number> {
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<Omit<RecordEvidence, 'startObserved' | 'presentAtStart'> & {
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;
}
+7 -2
View File
@@ -1,5 +1,6 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { getPersonalBestRecords } from './leaderboard-records.js';
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric, import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js'; type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
@@ -41,9 +42,13 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
const query = options.q?.trim() ?? ''; const query = options.q?.trim() ?? '';
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : ''; const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
const searchedParameters = query ? [...parameters, query, query] : parameters; 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 ?`) ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`)
.all(...searchedParameters, limit, offset) as LeaderboardEntry[]; .all(...searchedParameters, limit, offset) as Array<Omit<LeaderboardEntry, 'records'>>;
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}`) const { total } = handle.prepare(`${aggregated} SELECT COUNT(*) AS total FROM ranked ${search}`)
.get(...searchedParameters) as { total: number }; .get(...searchedParameters) as { total: number };
const summary = handle.prepare(`SELECT const summary = handle.prepare(`SELECT
+7 -3
View File
@@ -1,5 +1,6 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { getPersonalBestRecords } from './leaderboard-records.js';
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js'; import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills, 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 limit = 25;
const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit; const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit;
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0; 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} 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`) 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<Omit<PlayerMapRecord, 'records'>>;
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, 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, 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.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived,
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore 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 ?`) ${scope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
.all(guid, limit, offset) as PlayerMatch[]; .all(guid, limit, offset) as PlayerMatch[];
return { ...player, stats, maps, matches, offset, limit }; return { ...player, stats, records, maps, matches, offset, limit };
} }
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { RecordEvidence } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { evidence }: { evidence: RecordEvidence } = $props();
const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II');
</script>
<div class="record-source">
<a href={`/matches/${evidence.matchId}`}>{evidence.mapName} · {gameName}</a>
<span>{#if evidence.endedAt}Completed <time datetime={evidence.endedAt}>{$timeFormat.dateTime(evidence.endedAt)}</time>{:else}Completed · End time unknown{/if}</span>
<details>
<summary>Run details</summary>
<dl>
<div><dt>Server</dt><dd>{evidence.server}</dd></div>
<div><dt>{evidence.startObserved ? 'Match started' : 'Match first observed'}</dt><dd><time datetime={evidence.startedAt}>{$timeFormat.dateTime(evidence.startedAt)}</time></dd></div>
<div><dt>Player participation</dt><dd>{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}</dd></div>
</dl>
<p>Recorded presence does not establish continuous survival.</p>
</details>
</div>
<style>
.record-source { margin-top: var(--space-2); max-width: 24rem; white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; color: var(--text-secondary); }
a { color: var(--accent-primary-hover); }
span { display: block; margin-top: 3px; color: var(--text-tertiary); }
details { margin-top: var(--space-1); }
summary { cursor: pointer; }
dl { margin: var(--space-2) 0; }
dl > div + div { margin-top: var(--space-2); }
dt { color: var(--text-tertiary); }
dd { margin: 0; }
p { margin: var(--space-2) 0 0; color: var(--text-tertiary); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
</style>
+2 -1
View File
@@ -8,6 +8,7 @@
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import CopyValue from '$ui/components/CopyValue.svelte'; import CopyValue from '$ui/components/CopyValue.svelte';
import RecordSource from '$ui/components/RecordSource.svelte';
const number = new Intl.NumberFormat('en-US'); const number = new Intl.NumberFormat('en-US');
const pageSize = 50; const pageSize = 50;
@@ -212,7 +213,7 @@
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a> <a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
<div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div> <div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
</th> </th>
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}</td>{/each} {#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}<RecordSource evidence={entry.records[key]} />{/if}</td>{/each}
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -6,6 +6,7 @@
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import CopyValue from '$ui/components/CopyValue.svelte'; import CopyValue from '$ui/components/CopyValue.svelte';
import Badge from '$ui/components/primitives/Badge.svelte'; import Badge from '$ui/components/primitives/Badge.svelte';
import RecordSource from '$ui/components/RecordSource.svelte';
let { data }: { data: { profile: PlayerProfile } } = $props(); let { data }: { data: { profile: PlayerProfile } } = $props();
const profile = $derived(data.profile); const profile = $derived(data.profile);
@@ -40,7 +41,7 @@
<h2 id="totals-title">Lifetime stats</h2> <h2 id="totals-title">Lifetime stats</h2>
<div class="stats"> <div class="stats">
{#each leaderboardMetrics as metric (metric.key)} {#each leaderboardMetrics as metric (metric.key)}
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong></div> <div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong>{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={profile.records[metric.key]} />{/if}</div>
{/each} {/each}
</div> </div>
</section> </section>
@@ -65,7 +66,7 @@
<thead><tr><th scope="col">Map</th>{#each leaderboardMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead> <thead><tr><th scope="col">Map</th>{#each leaderboardMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)} <tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th> <tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
{#each leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}</td>{/each}</tr> {#each leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
{/each}</tbody> {/each}</tbody>
</table> </table>
</div> </div>
+15 -3
View File
@@ -123,7 +123,11 @@ describe('persisted leaderboard queries', () => {
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770, rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4, roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000, 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.total).toBe(4);
expect(board.entries.map(entry => [entry.guid, entry.rank])).toEqual([['101', 1], ['505', 2], ['202', 3], ['303', 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({ expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270, rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3, 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 } }); expect(t5).toMatchObject({ total: 3, summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 } });
const t6 = getLeaderboard({ game: 't6' }); const t6 = getLeaderboard({ game: 't6' });
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({ expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500, rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1, 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 } }); 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 });
});