Reuse leaderboard aggregates and add repeatable benchmarks
This commit is contained in:
+60
-5
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }); }
|
||||
@@ -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'
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user