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
+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 };
}