diff --git a/docs/leaderboard.md b/docs/leaderboard.md index 1259a21..66f8f6c 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -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. diff --git a/package.json b/package.json index 9d92efe..83199aa 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/benchmark-leaderboard.mjs b/scripts/benchmark-leaderboard.mjs new file mode 100644 index 0000000..3ff24ed --- /dev/null +++ b/scripts/benchmark-leaderboard.mjs @@ -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 --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 }); } diff --git a/src/lib/server/fleet/leaderboard.ts b/src/lib/server/fleet/leaderboard.ts index be5dd9d..da4d90a 100644 --- a/src/lib/server/fleet/leaderboard.ts +++ b/src/lib/server/fleet/leaderboard.ts @@ -8,6 +8,20 @@ import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type Le const metrics = new Set(leaderboardMetrics.map(metric => metric.key)); export const MAX_LEADERBOARD_OFFSET = 1_000_000; +type RankedEntry = Omit; + +interface LeaderboardRow extends Omit { + // 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 = {}): 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, 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>; - 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' diff --git a/tests/unit/leaderboard-query.test.ts b/tests/unit/leaderboard-query.test.ts index f898352..9d9a850 100644 --- a/tests/unit/leaderboard-query.test.ts +++ b/tests/unit/leaderboard-query.test.ts @@ -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 () => {