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
+18
View File
@@ -14,11 +14,27 @@ export const leaderboardMetrics = [
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
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 {
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 {
+4
View File
@@ -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) {
+2
View File
@@ -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);
+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)`);
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 = ?,
@@ -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 { 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<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}`)
.get(...searchedParameters) as { total: number };
const summary = handle.prepare(`SELECT
+7 -3
View File
@@ -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<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,
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 };
}
+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 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 @@
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
<div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
</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>
{/each}
</tbody>
@@ -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 @@
<h2 id="totals-title">Lifetime stats</h2>
<div class="stats">
{#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}
</div>
</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>
<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>
{#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>
</table>
</div>