Reuse leaderboard aggregates and add repeatable benchmarks

This commit is contained in:
2026-10-01 12:33:11 -04:00
parent f10345ae79
commit 221c4c10cf
5 changed files with 403 additions and 38 deletions
+60 -5
View File
@@ -46,7 +46,7 @@ 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.
at the recorded start, at a reported 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.
@@ -64,8 +64,10 @@ required. The leaderboard and profiles identify a player by **GUID**, displaying
the latest reported name across both games, regardless of game or map filters.
Different names for the same GUID do not split their leaderboard entry or
profile; identical names with different GUIDs remain separate. Stored player
records remain keyed by **game + GUID** for telemetry ingestion; the combined
leaderboard and profiles require no storage migration. GUIDs stay strings.
records remain keyed by **game + GUID** for telemetry ingestion; combining their
identity requires no rekeying. The record-source upgrade automatically adds
nullable participation-start fields; existing rows keep an unknown start instead
of inferring one. GUIDs stay strings.
## Recorded statistics
@@ -152,11 +154,64 @@ key returned in `maps`), `metric` (default `highestRound`), `q` (name/GUID searc
available `maps`, the matching player `total`, pagination values, and `summary`.
Each entry represents one GUID and combines its qualifying matches within the
game/map filters. Entries have no game field; map options retain their game
identity. Both `total` and `summary.players` count unique GUIDs; only `total` is
limited by the player search.
identity. Both `total` and `summary.players` count unique GUIDs; `total` applies
the selected metric's qualification rules and player search, while the summary
covers all completed qualifying participation in the game/map scope.
Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`,
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore`, `headshotRate`,
`killsPerDown`, `averageKills`, `averageRounds`, and `completionRate`.
Efficiency values in entries are unrounded ratios (for example, `0.5` means 50%)
or `null` when their denominator is zero. UI percentages multiply by 100.
Entries also include `records`, keyed by `highestRound`, `bestKills`, and
`bestScore`, with the completed match ID, value, map/game/server, timing and
observed participation behind each personal best.
## Query performance
Leaderboard reads materialize the selected completed participation once, reuse
one player aggregate for ranking and summary totals, and reuse the ranked/search
result for pagination and its matching count. Empty and out-of-range pages still
return accurate totals. Record evidence is fetched only for players on the page.
Exact player-rank reads share the ranking rules but skip unused fleet summaries
and map lists. No response cache delays visibility of a newly completed game.
Run the deterministic, isolated benchmark with:
```sh
node scripts/benchmark-leaderboard.mjs --scenario all --baseline-ref eac9ad4 --runs 3 --json /tmp/leaderboard-benchmark.json
```
The script creates temporary SQLite databases, loads the actual query modules,
checks complete-response parity against the selected Git revision, and reports
timings, source hashes and query plans. It never opens the application database.
The small fixture has 2,000 matches and 8,000 participation rows; the large fixture
has 50,000 matches and 200,000 participation rows. Both include completed,
active, interrupted and nonqualifying records.
On the local Intel i5-8279U / Node 25.9.0 / SQLite 3.49.2 test environment, three
measured runs after warmup gave these median query times on the large fixture:
| Request | Before | After |
| --- | ---: | ---: |
| Highest round, all games | 1,717 ms | 829 ms |
| Total kills, all games | 1,555 ms | 858 ms |
| One game | 899 ms | 491 ms |
| One map | 345 ms | 224 ms |
| Player search | 1,515 ms | 757 ms |
| Deep page | 1,421 ms | 752 ms |
| Headshot percentage | 1,444 ms | 760 ms |
| Exact player rank | 1,524 ms | 554 ms |
The small fixture's unfiltered request fell from 41 to 30 ms. All 150 additional
small-fixture parity cases and 22 timed small/large request cases matched the
baseline's complete responses, including ratios and record evidence. Timings
include SQL preparation and evidence lookup, exclude HTTP/browser rendering, and
use a warm filesystem cache with alternating implementation order. These are
local synthetic measurements, not production latency guarantees. Queries still
run synchronously, so larger histories remain a capacity consideration.
Validation for this change passed 1,019 unit tests, Svelte checking with no errors
or warnings, and a production build. Isolated Firefox walkthroughs at 1440px and
390px checked completed-only totals, record links, efficiency qualification,
scoped ranks/history, preserved navigation, empty scopes and mobile expansion.
+1
View File
@@ -11,6 +11,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
"seed:fixture": "tsx scripts/seed-fixture.ts"
},
"dependencies": {
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env node
/**
* Benchmark actual leaderboard modules against deterministic, isolated SQLite histories.
* node scripts/benchmark-leaderboard.mjs --scenario all --baseline-ref <commit> --runs 3
* Uses a TypeScript module loader to replace only db(); it never opens the application DB.
*/
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { arch, cpus, platform, tmpdir } from 'node:os';
import { dirname, join, relative, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath } from 'node:url';
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(join(repo, 'package.json'));
const Database = require('better-sqlite3');
const ts = require('typescript');
const arguments_ = process.argv.slice(2);
const options = { scenario: 'small', runs: 3 };
const allowed = new Set(['--scenario', '--runs', '--baseline-ref', '--json']);
for (let index = 0; index < arguments_.length; index += 2) {
const key = arguments_[index];
const value = arguments_[index + 1];
if (!allowed.has(key) || !value) throw new Error('Usage: --scenario small|large|all --runs N --baseline-ref COMMIT --json PATH');
options[key.slice(2)] = value;
}
options.runs = Number(options.runs);
if (!Number.isInteger(options.runs) || options.runs < 1 || options.runs > 20) throw new Error('--runs must be an integer from 1 to 20');
if (!['small', 'large', 'all'].includes(options.scenario)) throw new Error('--scenario must be small, large, or all');
const baseline = options['baseline-ref']
? execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${options['baseline-ref']}^{commit}`], { cwd: repo, encoding: 'utf8' }).trim()
: null;
const definitions = [
{ name: 'small', matches: 2000, players: 500 },
{ name: 'large', matches: 50000, players: 10000 }
].filter(item => options.scenario === 'all' || item.name === options.scenario);
const temporary = mkdtempSync(join(tmpdir(), 'cherry-leaderboard-benchmark-'));
const source = (file, ref) => ref
? execFileSync('git', ['show', `${ref}:${file}`], { cwd: repo, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })
: readFileSync(join(repo, file), 'utf8');
const percentile = (values, fraction) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1];
function seed(handle, definition) {
handle.exec(source('src/lib/server/db/schema.sql'));
const match = handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at,
status, start_observed, start_players, highest_round, last_event, last_fingerprint)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 4, ?, ?, ?)`);
const identity = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO UPDATE SET
name = CASE WHEN excluded.last_seen_at >= last_seen_at THEN excluded.name ELSE name END,
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
const participation = 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,
first_observed_round, present_at_start)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
let state = 20260918;
const random = () => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state / 4294967296; };
handle.transaction(() => {
for (let id = 1; id <= definition.matches; id++) {
const mapIndex = Math.floor(random() * 12);
const game = mapIndex < 6 ? 't5' : 't6';
const map = `map_${mapIndex}`;
const status = id % 20 === 0 ? 'active' : id % 20 === 1 ? 'interrupted' : 'completed';
const started = new Date(Date.UTC(2023, 0, 1) + Math.floor(id / definition.matches * 3 * 365 * 86400000)).toISOString();
const ended = new Date(Date.parse(started) + (20 + Math.floor(random() * 130)) * 60000).toISOString();
const round = id % 101 === 0 ? 1 : 2 + Math.floor(random() * 60);
match.run(id, `server_${id % 24}`, game, map, `${game}:${map}`, `Map ${mapIndex}`,
started, status === 'completed' ? ended : null, ended, status, round,
status === 'completed' ? 'game_end' : 'heartbeat', `event_${id}`);
const roster = new Set();
while (roster.size < 4) roster.add(Math.floor(random() ** 2 * definition.players));
// Reserve one completed participation for an explicitly ineligible player.
if (id === definition.matches - 2) roster.delete([...roster][3]);
for (const player of roster) {
const guid = String(10000000000000000n + BigInt(player));
const name = `${game === 't6' ? 'Alias' : 'Player'} ${String(player).padStart(5, '0')}`;
identity.run(game, guid, name, started, ended);
const kills = Math.floor(round * 18 + random() * 800);
const presentAtStart = random() > 0.1;
const firstRound = presentAtStart ? 0 : Math.max(1, Math.floor(random() * round));
participation.run(id, guid, round, Math.max(0, round - Math.max(1, firstRound)), kills,
Math.floor(kills * random()), Math.floor(random() * 8), Math.floor(random() * 12),
Math.floor(random() * 100000), Math.max(0, round - 1), Number(status === 'active'),
Number(random() > 0.01), Number(status === 'completed' && random() > 0.15),
started, ended, firstRound, Number(presentAtStart));
}
if (id === definition.matches - 2) {
identity.run(game, 'benchmark-low-sample', 'Low sample', started, ended);
participation.run(id, 'benchmark-low-sample', 2, 1, 20, 5, 0, 0, 1000,
1, 0, 1, 1, started, ended, 0, 1);
}
}
})();
handle.pragma('wal_checkpoint(TRUNCATE)');
}
function loadImplementation(handle, ref) {
const modules = new Map();
const hashes = {};
let recorded = null;
const database = { prepare(sql) {
const statement = handle.prepare(sql);
return Object.fromEntries(['all', 'get'].map(method => [method, (...parameters) => {
const start = performance.now();
const result = statement[method](...parameters);
if (recorded) recorded.push({ sql, parameters, milliseconds: performance.now() - start });
return result;
}]));
} };
function load(file) {
if (file === 'src/lib/server/db/index.ts') return { db: () => database };
if (modules.has(file)) return modules.get(file);
const text = source(file, ref);
hashes[file] = createHash('sha256').update(text).digest('hex');
const compiled = ts.transpileModule(text, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText;
const exports = {};
modules.set(file, exports);
const localRequire = specifier => {
if (!specifier.startsWith('.')) return require(specifier);
const target = relative(repo, resolve(repo, dirname(file), specifier)).replace(/\.js$/, '.ts');
if (!target.startsWith('src/lib/')) throw new Error(`Unexpected benchmark dependency: ${target}`);
return load(target);
};
new Function('require', 'exports', compiled)(localRequire, exports);
return exports;
}
const api = load('src/lib/server/fleet/leaderboard.ts');
return {
name: ref ? 'baseline' : 'current', ref, hashes,
metrics: load('src/lib/leaderboard.ts').leaderboardMetrics.map(metric => metric.key),
run(test) {
return test.guid ? api.getPlayerLeaderboardEntry(test.guid, test.options) : api.getLeaderboard(test.options);
},
measure(test) {
recorded = [];
const start = performance.now();
const result = this.run(test);
const milliseconds = performance.now() - start;
const statements = recorded;
recorded = null;
return { result, milliseconds, statements };
}
};
}
const report = {
environment: { node: process.version, sqlite: null, platform: platform(), arch: arch(), cpu: cpus()[0]?.model },
baselineRef: baseline, runs: options.runs, seed: 20260918,
notes: 'Synthetic file-backed SQLite, one warmup per case, rotating execution order, warm OS cache. Includes SQL preparation and source-record lookup; excludes HTTP and browser rendering. Shared local CPU; timings are not production latency. Schema and indexes are current for both implementations.',
scenarios: []
};
try {
for (const definition of definitions) {
const handle = new Database(join(temporary, `${definition.name}.sqlite`));
try {
report.environment.sqlite = handle.prepare('SELECT sqlite_version() AS version').get().version;
seed(handle, definition);
const implementations = [...(baseline ? [loadImplementation(handle, baseline)] : []), loadImplementation(handle)];
const cases = [
{ name: 'unfiltered', options: {} },
{ name: 'kills', options: { metric: 'kills' } },
{ name: 'game', options: { game: 't5', metric: 'kills' } },
{ name: 'map', options: { game: 't6', map: 't6:map_8', metric: 'kills' } },
{ name: 'search', options: { q: '00042', metric: 'kills' } },
{ name: 'deep-page', options: { metric: 'kills', offset: Math.floor(definition.players / 2) } },
{ name: 'efficiency', options: { metric: 'headshotRate' } },
{ name: 'exact-player', guid: '10000000000000042', options: { metric: 'kills' } },
{ name: 'empty-search', options: { q: 'No such player exists' } },
{ name: 'past-end', options: { offset: 999999 } },
{ name: 'empty-map', options: { map: 't5:missing' } }
];
const counts = handle.prepare(`SELECT COUNT(*) AS participationRows,
COUNT(DISTINCT guid) AS uniquePlayers FROM leaderboard_match_players`).get();
const scenario = { ...definition, ...counts, maps: 12, servers: 24, completedShare: 0.9, results: [] };
report.scenarios.push(scenario);
// Broad parity is separate from timings so every ratio and edge case is
// checked without multiplying the larger fixture's timed request matrix.
if (baseline && definition.name === 'small') {
const scopes = [{}, { game: 't5' }, { game: 't6', map: 't6:map_8' }, { q: '00042' },
{ q: 'No such player exists' }, { offset: 999999 }, { map: 't5:missing' }];
let checked = 0;
for (const metric of implementations[1].metrics) {
for (const scope of scopes) {
const test = { options: { ...scope, metric } };
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
`${definition.name}/${metric}/${JSON.stringify(scope)}: full-response parity`);
checked++;
}
for (const guid of ['10000000000000042', 'benchmark-low-sample', 'benchmark-missing']) {
const test = { guid, options: { metric } };
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
`${definition.name}/${metric}/${guid}: exact-player parity`);
checked++;
}
}
scenario.parityCases = checked;
console.log(JSON.stringify({ scenario: definition.name, parityCases: checked, parity: true }));
}
for (const test of cases) {
const expected = implementations[0].run(test);
for (const implementation of implementations.slice(1)) {
assert.deepStrictEqual(implementation.run(test), expected, `${definition.name}/${test.name}: baseline parity`);
}
const samples = Object.fromEntries(implementations.map(item => [item.name, []]));
const latestStatements = {};
for (let index = 0; index < options.runs; index++) {
const rotation = index % implementations.length;
for (const implementation of [...implementations.slice(rotation), ...implementations.slice(0, rotation)]) {
const measured = implementation.measure(test);
samples[implementation.name].push(measured.milliseconds);
latestStatements[implementation.name] = measured.statements;
}
}
const result = { ...test, parity: baseline ? true : null, implementations: implementations.map(implementation => ({
name: implementation.name, sourceHashes: implementation.hashes,
medianMs: percentile(samples[implementation.name], 0.5),
p95Ms: percentile(samples[implementation.name], 0.95), samplesMs: samples[implementation.name],
statements: latestStatements[implementation.name].map(statement => ({ ...statement,
plan: handle.prepare(`EXPLAIN QUERY PLAN ${statement.sql}`).all(...statement.parameters)
}))
})) };
scenario.results.push(result);
console.log(JSON.stringify({ scenario: definition.name, case: test.name, parity: result.parity,
medianMs: Object.fromEntries(result.implementations.map(item => [item.name, Math.round(item.medianMs)])) }));
}
} finally { handle.close(); }
}
if (options.json) {
const target = resolve(options.json);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, JSON.stringify(report, null, 2));
console.log(`Detailed results: ${target}`);
}
} finally { rmSync(temporary, { recursive: true, force: true }); }
+82 -32
View File
@@ -8,6 +8,20 @@ import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type Le
const metrics = new Set<string>(leaderboardMetrics.map(metric => metric.key));
export const MAX_LEADERBOARD_OFFSET = 1_000_000;
type RankedEntry = Omit<LeaderboardEntry, 'records'>;
interface LeaderboardRow extends Omit<RankedEntry, 'guid'> {
// A left join keeps metadata available when the requested page has no players.
guid: string | null;
matchingTotal: number;
summaryPlayers: number;
summaryMatches: number;
summaryKills: number;
summaryRounds: number;
summaryHighestRound: number;
summaryFirstTrackedAt: string | null;
summaryUpdatedAt: string | null;
}
export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
return metrics.has(value);
@@ -21,10 +35,19 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
/** Select the exact player after ranking everyone eligible in the requested scope. */
export function getPlayerLeaderboardEntry(guid: string,
options: Pick<LeaderboardOptions, 'game' | 'map' | 'metric'> = {}): LeaderboardEntry | null {
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid).entries[0] ?? null;
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid);
}
function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): LeaderboardResponse {
function addRecordSources(handle: ReturnType<typeof db>, entries: RankedEntry[], options: LeaderboardOptions): LeaderboardEntry[] {
const records = getPersonalBestRecords(handle, entries.map(entry => ({
...entry, game: options.game || undefined, map: options.map || undefined
})));
return entries.map((entry, index) => ({ ...entry, records: records[index] }));
}
function readLeaderboard(options: LeaderboardOptions): LeaderboardResponse;
function readLeaderboard(options: LeaderboardOptions, exactGuid: string): LeaderboardEntry | null;
function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): LeaderboardResponse | LeaderboardEntry | null {
const handle = db();
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50;
@@ -34,21 +57,27 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
const parameters: string[] = [];
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); }
const scoped = `FROM leaderboard_match_players mp
JOIN leaderboard_matches m ON m.id = mp.match_id
WHERE ${conditions.join(' AND ')}`;
const qualification = isEfficiencyMetric(metric)
? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL`
+ (metric === 'headshotRate' ? ` AND measures.kills >= ${EFFICIENCY_MIN_KILLS}` : '')
+ (metric === 'killsPerDown' ? ` AND measures.downs >= ${EFFICIENCY_MIN_DOWNS}` : '')
: '';
const aggregated = `WITH totals AS (
SELECT mp.guid, MAX(mp.highest_round) AS highestRound,
SUM(mp.kills) AS kills, SUM(mp.rounds_survived) AS roundsSurvived,
SUM(mp.headshots) AS headshots, SUM(mp.revives) AS revives, SUM(mp.downs) AS downs,
COUNT(*) AS gamesPlayed, SUM(mp.completed) AS gamesCompleted,
MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore, MAX(mp.last_seen_at) AS lastSeen
${scoped} GROUP BY mp.guid
// A board reuses observations for player totals and distinct match metadata.
// Exact-player requests only consume the totals, so they can stream the join once.
const observationStorage = exactGuid === undefined ? 'MATERIALIZED' : 'NOT MATERIALIZED';
const aggregated = `WITH observations AS ${observationStorage} (
SELECT mp.match_id, mp.guid, mp.highest_round, mp.kills, mp.rounds_survived,
mp.headshots, mp.revives, mp.downs, mp.completed, mp.best_score, mp.last_seen_at,
m.started_at AS matchStartedAt, m.last_received_at AS matchUpdatedAt
FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
WHERE ${conditions.join(' AND ')}
), totals AS MATERIALIZED (
SELECT guid, MAX(highest_round) AS highestRound,
SUM(kills) AS kills, SUM(rounds_survived) AS roundsSurvived,
SUM(headshots) AS headshots, SUM(revives) AS revives, SUM(downs) AS downs,
COUNT(*) AS gamesPlayed, SUM(completed) AS gamesCompleted,
MAX(kills) AS bestKills, MAX(best_score) AS bestScore, MAX(last_seen_at) AS lastSeen
FROM observations GROUP BY guid
), measures AS (
SELECT totals.*, 1.0 * headshots / NULLIF(kills, 0) AS headshotRate,
1.0 * kills / NULLIF(downs, 0) AS killsPerDown,
@@ -62,27 +91,48 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
SELECT measures.*, p.name, RANK() OVER (ORDER BY measures.${metric} DESC) AS rank
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
)`;
if (exactGuid !== undefined) {
const entries = handle.prepare(`${aggregated} SELECT * FROM ranked WHERE guid = ?`)
.all(...parameters, exactGuid) as RankedEntry[];
return addRecordSources(handle, entries, options)[0] ?? null;
}
const query = options.q?.trim() ?? '';
const search = exactGuid !== undefined ? 'WHERE guid = ?'
: query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
const searchedParameters = exactGuid !== undefined ? [...parameters, exactGuid]
: query ? [...parameters, query, query] : parameters;
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 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
COUNT(DISTINCT mp.guid) AS players,
COUNT(DISTINCT m.id) AS matches, COALESCE(SUM(mp.kills), 0) AS kills,
COALESCE(SUM(mp.rounds_survived), 0) AS roundsSurvived,
COALESCE(MAX(mp.highest_round), 0) AS highestRound,
MIN(m.started_at) AS firstTrackedAt, MAX(m.last_received_at) AS updatedAt
${scoped}`).get(...parameters) as LeaderboardResponse['summary'];
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
// Reuse one ranked set for the page and matching count. Summaries deliberately
// cover the full game/map scope, including players below an efficiency threshold.
const rows = handle.prepare(`${aggregated}, searched AS MATERIALIZED (
SELECT * FROM ranked ${search}
), page AS (
SELECT * FROM searched ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?
), playerSummary AS (
SELECT COUNT(*) AS players, COALESCE(SUM(kills), 0) AS kills,
COALESCE(SUM(roundsSurvived), 0) AS roundsSurvived,
COALESCE(MAX(highestRound), 0) AS highestRound FROM totals
), matchSummary AS (
SELECT COUNT(DISTINCT match_id) AS matches, MIN(matchStartedAt) AS firstTrackedAt,
MAX(matchUpdatedAt) AS updatedAt FROM observations
), metadata AS (
SELECT (SELECT COUNT(*) FROM searched) AS total, playerSummary.*, matchSummary.*
FROM playerSummary CROSS JOIN matchSummary
) SELECT page.*, metadata.total AS matchingTotal, metadata.players AS summaryPlayers,
metadata.matches AS summaryMatches, metadata.kills AS summaryKills,
metadata.roundsSurvived AS summaryRounds, metadata.highestRound AS summaryHighestRound,
metadata.firstTrackedAt AS summaryFirstTrackedAt, metadata.updatedAt AS summaryUpdatedAt
FROM metadata LEFT JOIN page ON 1
ORDER BY page.${metric} DESC, page.name COLLATE NOCASE, page.name, page.guid`)
.all(...parameters, ...(query ? [query, query] : []), limit, offset) as LeaderboardRow[];
const rankedEntries = rows.filter((row): row is LeaderboardRow & { guid: string } => row.guid !== null)
.map(({ matchingTotal, summaryPlayers, summaryMatches, summaryKills, summaryRounds,
summaryHighestRound, summaryFirstTrackedAt, summaryUpdatedAt, ...entry }) => entry);
const entries = addRecordSources(handle, rankedEntries, options);
const metadata = rows[0];
const total = metadata.matchingTotal;
const summary: LeaderboardResponse['summary'] = {
players: metadata.summaryPlayers, matches: metadata.summaryMatches, kills: metadata.summaryKills,
roundsSurvived: metadata.summaryRounds, highestRound: metadata.summaryHighestRound,
firstTrackedAt: metadata.summaryFirstTrackedAt, updatedAt: metadata.summaryUpdatedAt
};
// Keep qualifying map variants available, even after their server is removed.
const maps = handle.prepare(`SELECT map_key AS key, game, map, map_name AS name
FROM leaderboard_matches WHERE game IN ('t5', 't6') AND status = 'completed'
+19 -1
View File
@@ -193,6 +193,9 @@ describe('persisted leaderboard queries', () => {
expect(search.entries.map(entry => [entry.name, entry.rank])).toEqual([['Casey', 3]]);
expect(search.total).toBe(1);
expect(search.summary).toEqual(board.summary);
expect(getLeaderboard({ game: 't5', q: 'no matching player' })).toMatchObject({
entries: [], total: 0, summary: board.summary, maps: board.maps
});
expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0])
.toMatchObject({ name: 'Blake', rank: 2 });
expect(getLeaderboard({ q: '303' }).entries[0]).toMatchObject({ name: 'Casey', rank: 4 });
@@ -226,7 +229,22 @@ describe('persisted leaderboard queries', () => {
expect(first.total).toBe(4);
expect(second.total).toBe(4);
expect(second.summary).toEqual(first.summary);
expect(getLeaderboard({ metric: 'kills', limit: 2, offset: 4 }).entries).toEqual([]);
expect(getLeaderboard({ metric: 'kills', limit: 2, offset: 4 })).toMatchObject({
entries: [], total: first.total, summary: first.summary, maps: first.maps
});
});
it('keeps recorded participation in summaries when an identity is unavailable for display', async () => {
const handle = await seedRecords();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const original = getLeaderboard();
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run();
const board = getLeaderboard();
expect(board.total).toBe(original.total - 1);
expect(board.entries.some(entry => entry.guid === '101')).toBe(false);
expect(board.summary).toEqual(original.summary);
expect(board.maps).toEqual(original.maps);
expect(getLeaderboard({ q: '101' })).toMatchObject({ entries: [], total: 0, summary: original.summary });
});
it('ignores unsupported games in identities, totals, rankings and map options', async () => {