From 47ebe83106e34599b9295a3cb4c1b9ce26466487 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 1 Oct 2026 12:16:27 -0400 Subject: [PATCH] Add qualified efficiency leaderboard rankings --- docs/leaderboard.md | 20 ++- src/lib/leaderboard-efficiency.ts | 76 ++++++++++ src/lib/leaderboard.ts | 10 +- src/lib/server/fleet/leaderboard.ts | 17 ++- src/routes/leaderboard/+page.svelte | 24 ++- .../leaderboard/players/[guid]/+page.svelte | 22 ++- .../unit/leaderboard-efficiency-query.test.ts | 143 ++++++++++++++++++ tests/unit/leaderboard-efficiency.test.ts | 92 +++++++++++ tests/unit/leaderboard-query.test.ts | 6 + 9 files changed, 385 insertions(+), 25 deletions(-) create mode 100644 src/lib/leaderboard-efficiency.ts create mode 100644 tests/unit/leaderboard-efficiency-query.test.ts create mode 100644 tests/unit/leaderboard-efficiency.test.ts diff --git a/docs/leaderboard.md b/docs/leaderboard.md index f910b99..d55854e 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -72,6 +72,21 @@ player-rounds: four players progressing from game start to round 8 contribute 28 Highest-round records always use the round reached in a completed match, including the final round and players who joined mid-game. +Efficiency rankings are also available for **Headshots / kills**, **Kills per +down**, **Average kills**, **Average rounds**, and **Completion rate**. Each +requires at least five completed games in the selected game/map scope. Headshot +rate also requires 100 kills; kills per down requires five downs. The board shows +the numerator, denominator and game count beside each ratio. Ratios are computed +from aggregate counters, not an average of individual match percentages. +Completion rate is the share of those ended games whose final report contained +the player; it is not a win rate. Active and interrupted games do not enter any +denominator. Zero-denominator ratios remain unknown and are not ranked. + +Qualification is applied before ranking, search and pagination. Summary cards +still describe all players in the game/map selection, including those below the +threshold. Profile metrics show their values and whether the player qualifies +for the combined lifetime board; a scoped board can have a smaller sample. + Round totals require the player to appear in consecutive observations. If a report omits them, their return starts a new participation interval and the gap is not credited. The payload cannot tell whether a present player was alive, @@ -124,4 +139,7 @@ identity. Both `total` and `summary.players` count unique GUIDs; only `total` is limited by the player search. Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`, -`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, and `bestScore`. +`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. diff --git a/src/lib/leaderboard-efficiency.ts b/src/lib/leaderboard-efficiency.ts new file mode 100644 index 0000000..fe68e3d --- /dev/null +++ b/src/lib/leaderboard-efficiency.ts @@ -0,0 +1,76 @@ +import type { PlayerStats } from './leaderboard.js'; + +export const EFFICIENCY_MIN_GAMES = 5; +export const EFFICIENCY_MIN_KILLS = 100; +export const EFFICIENCY_MIN_DOWNS = 5; + +export const efficiencyMetrics = [ + { key: 'headshotRate', label: 'Headshots / kills', format: 'percent', + description: `Total headshots divided by total kills in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_KILLS} kills.` }, + { key: 'killsPerDown', label: 'Kills per down', format: 'number', + description: `Total kills divided by total downs in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_DOWNS} downs.` }, + { key: 'averageKills', label: 'Average kills', format: 'number', + description: `Total kills divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.` }, + { key: 'averageRounds', label: 'Average rounds', format: 'number', + description: `Total observed rounds divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.` }, + { key: 'completionRate', label: 'Completion rate', format: 'percent', + description: `Games with the player in the final report divided by games played. Only ended games count, including games the player left early. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games.` } +] as const; + +export type EfficiencyMetric = typeof efficiencyMetrics[number]['key']; +const metrics = new Set(efficiencyMetrics.map(metric => metric.key)); + +export function isEfficiencyMetric(metric: string): metric is EfficiencyMetric { + return metrics.has(metric); +} + +function sample(stats: PlayerStats, metric: EfficiencyMetric): [number, number] { + switch (metric) { + case 'headshotRate': return [stats.headshots, stats.kills]; + case 'killsPerDown': return [stats.kills, stats.downs]; + case 'averageKills': return [stats.kills, stats.gamesPlayed]; + case 'averageRounds': return [stats.roundsSurvived, stats.gamesPlayed]; + case 'completionRate': return [stats.gamesCompleted, stats.gamesPlayed]; + } +} + +/** Calculate from aggregate counters; qualification never changes a recorded value. */ +export function efficiencyValue(stats: PlayerStats, metric: EfficiencyMetric): number | null { + const [numerator, denominator] = sample(stats, metric); + return denominator === 0 ? null : numerator / denominator; +} + +export function efficiencyEligible(stats: PlayerStats, metric: EfficiencyMetric): boolean { + if (stats.gamesPlayed < EFFICIENCY_MIN_GAMES) return false; + if (metric === 'headshotRate') return stats.kills >= EFFICIENCY_MIN_KILLS; + if (metric === 'killsPerDown') return stats.downs >= EFFICIENCY_MIN_DOWNS; + return true; +} + +export function efficiencyRequirement(metric: EfficiencyMetric): string { + const games = `At least ${EFFICIENCY_MIN_GAMES} ended games`; + if (metric === 'headshotRate') return `${games} and ${EFFICIENCY_MIN_KILLS} kills`; + if (metric === 'killsPerDown') return `${games} and ${EFFICIENCY_MIN_DOWNS} downs`; + return games; +} + +const number = new Intl.NumberFormat('en-US'); +const count = (value: number, singular: string, plural = `${singular}s`) => + `${number.format(value)} ${value === 1 ? singular : plural}`; + +export function efficiencySample(stats: PlayerStats, metric: EfficiencyMetric): string { + switch (metric) { + case 'headshotRate': return `${count(stats.headshots, 'headshot')} / ${count(stats.kills, 'kill')}`; + case 'killsPerDown': return `${count(stats.kills, 'kill')} / ${count(stats.downs, 'down')}`; + case 'averageKills': return `${count(stats.kills, 'kill')} / ${count(stats.gamesPlayed, 'ended game')}`; + case 'averageRounds': return `${count(stats.roundsSurvived, 'observed round')} / ${count(stats.gamesPlayed, 'ended game')}`; + case 'completionRate': return `${count(stats.gamesCompleted, 'finish', 'finishes')} / ${count(stats.gamesPlayed, 'ended game')}`; + } +} + +const decimal = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }); +export function formatEfficiencyValue(value: number | null, metric: EfficiencyMetric): string { + if (value === null) return '—'; + const percent = metric === 'headshotRate' || metric === 'completionRate'; + return `${decimal.format(value * (percent ? 100 : 1))}${percent ? '%' : ''}`; +} diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts index 13c6347..5492f68 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -1,4 +1,6 @@ -export const leaderboardMetrics = [ +import { efficiencyMetrics, type EfficiencyMetric } from './leaderboard-efficiency.js'; + +export const playerStatMetrics = [ { key: 'highestRound', label: 'Highest round' }, { key: 'kills', label: 'Total kills' }, { key: 'roundsSurvived', label: 'Total rounds' }, @@ -11,9 +13,11 @@ export const leaderboardMetrics = [ { key: 'bestScore', label: 'Highest reported score' } ] as const; +export const leaderboardMetrics = [...playerStatMetrics, ...efficiencyMetrics] as const; export type LeaderboardMetric = typeof leaderboardMetrics[number]['key']; +export type PlayerStatMetric = typeof playerStatMetrics[number]['key']; -export type PlayerStats = Record; +export type PlayerStats = Record; export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore'; export interface RecordEvidence { matchId: number; @@ -71,7 +75,7 @@ export function playerProfileHref(guid: string): string { return `/leaderboard/players/${encodeURIComponent(guid)}`; } -export interface LeaderboardEntry { +export interface LeaderboardEntry extends Record { rank: number; guid: string; name: string; diff --git a/src/lib/server/fleet/leaderboard.ts b/src/lib/server/fleet/leaderboard.ts index 4fd2b13..5c2dccd 100644 --- a/src/lib/server/fleet/leaderboard.ts +++ b/src/lib/server/fleet/leaderboard.ts @@ -1,6 +1,8 @@ import { db } from '../db/index.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { getPersonalBestRecords } from './leaderboard-records.js'; +import { EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS, EFFICIENCY_MIN_DOWNS, + isEfficiencyMetric } from '../../leaderboard-efficiency.js'; import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric, type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js'; @@ -25,6 +27,11 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes 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, @@ -32,12 +39,18 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes 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 + ), measures AS ( + SELECT totals.*, 1.0 * headshots / NULLIF(kills, 0) AS headshotRate, + 1.0 * kills / NULLIF(downs, 0) AS killsPerDown, + 1.0 * kills / NULLIF(gamesPlayed, 0) AS averageKills, + 1.0 * roundsSurvived / NULLIF(gamesPlayed, 0) AS averageRounds, + 1.0 * gamesCompleted / NULLIF(gamesPlayed, 0) AS completionRate FROM totals ), identities AS ( SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS recency FROM leaderboard_players WHERE game IN ('t5', 't6') ), ranked AS ( - SELECT totals.*, p.name, RANK() OVER (ORDER BY totals.${metric} DESC) AS rank - FROM totals JOIN identities p ON p.guid = totals.guid AND p.recency = 1 + 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} )`; const query = options.q?.trim() ?? ''; const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : ''; diff --git a/src/routes/leaderboard/+page.svelte b/src/routes/leaderboard/+page.svelte index 708cb1b..cf1d38a 100644 --- a/src/routes/leaderboard/+page.svelte +++ b/src/routes/leaderboard/+page.svelte @@ -3,7 +3,8 @@ import { onMount, untrack } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; - import { leaderboardMetrics, playerProfileHref, type LeaderboardMetric, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js'; + import { leaderboardMetrics, playerProfileHref, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js'; + import { efficiencyMetrics, isEfficiencyMetric, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Button from '$ui/components/primitives/Button.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte'; @@ -13,8 +14,8 @@ const number = new Intl.NumberFormat('en-US'); const pageSize = 50; const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }]; - const defaultColumns: LeaderboardMetric[] = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed']; - const descriptions: Record = { + const defaultColumns: PlayerStatMetric[] = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed']; + const descriptions: Record = { highestRound: 'The highest round reached in a recorded game.', kills: 'Cumulative kills across recorded games.', roundsSurvived: 'Round progression during observed participation, added across games.', @@ -26,6 +27,10 @@ bestKills: 'The most kills reported for a player in a single game.', bestScore: 'The highest reported points balance. Spending points reduces this balance; it is not total points earned.' }; + const description = (key: LeaderboardMetric) => isEfficiencyMetric(key) + ? efficiencyMetrics.find(item => item.key === key)!.description : descriptions[key]; + const formatValue = (entry: LeaderboardEntry, key: LeaderboardMetric) => isEfficiencyMetric(key) + ? formatEfficiencyValue(entry[key], key) : number.format(entry[key]); let data = $state(null); let maps = $state([]); @@ -187,7 +192,7 @@
-

{metricLabel}

{descriptions[metric]}

+

{metricLabel}

{description(metric)}

{#if isEfficiencyMetric(metric)}

Qualification is measured within the selected game and map. Fleet totals include all recorded players.

{/if}
{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}Refreshes every 15 seconds
{#if data.entries.length} @@ -201,7 +206,7 @@ Player {#each columns as key (key)} - + {/each} @@ -213,16 +218,20 @@ {entry.name}
- {#each columns as key (key)}{number.format(entry[key])}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}{/if}{/each} + {#each columns as key (key)}{formatValue(entry, key)}{#if isEfficiencyMetric(key)}{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games{/if}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}{/if}{/each} {/each} + {:else if isEfficiencyMetric(metric)} + + {#snippet actions()}{/snippet} + {:else if query || game || map || offset} {#snippet actions()}{/snippet} @@ -292,6 +301,7 @@ .stat { text-align: right; white-space: nowrap; } td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } .stat.selected { color: var(--accent-primary-hover); } + .sample { display: block; max-width: 22rem; margin-top: var(--space-2); white-space: normal; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; color: var(--text-secondary); } td.selected { font-weight: var(--weight-semibold); } th button { display: inline-flex; gap: 6px; align-items: center; background: transparent; border: 0; padding: 3px 0; cursor: pointer; color: inherit; white-space: nowrap; } th button:hover { color: var(--accent-primary-hover); } diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index 9407318..e015d72 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -1,7 +1,8 @@