Add qualified efficiency leaderboard rankings
This commit is contained in:
+19
-1
@@ -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,
|
Highest-round records always use the round reached in a completed match,
|
||||||
including the final round and players who joined mid-game.
|
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
|
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
|
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,
|
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.
|
limited by the player search.
|
||||||
|
|
||||||
Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`,
|
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.
|
||||||
|
|||||||
@@ -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<string>(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 ? '%' : ''}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export const leaderboardMetrics = [
|
import { efficiencyMetrics, type EfficiencyMetric } from './leaderboard-efficiency.js';
|
||||||
|
|
||||||
|
export const playerStatMetrics = [
|
||||||
{ key: 'highestRound', label: 'Highest round' },
|
{ key: 'highestRound', label: 'Highest round' },
|
||||||
{ key: 'kills', label: 'Total kills' },
|
{ key: 'kills', label: 'Total kills' },
|
||||||
{ key: 'roundsSurvived', label: 'Total rounds' },
|
{ key: 'roundsSurvived', label: 'Total rounds' },
|
||||||
@@ -11,9 +13,11 @@ export const leaderboardMetrics = [
|
|||||||
{ key: 'bestScore', label: 'Highest reported score' }
|
{ key: 'bestScore', label: 'Highest reported score' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const leaderboardMetrics = [...playerStatMetrics, ...efficiencyMetrics] as const;
|
||||||
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
||||||
|
export type PlayerStatMetric = typeof playerStatMetrics[number]['key'];
|
||||||
|
|
||||||
export type PlayerStats = Record<LeaderboardMetric, number>;
|
export type PlayerStats = Record<PlayerStatMetric, number>;
|
||||||
export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore';
|
export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore';
|
||||||
export interface RecordEvidence {
|
export interface RecordEvidence {
|
||||||
matchId: number;
|
matchId: number;
|
||||||
@@ -71,7 +75,7 @@ export function playerProfileHref(guid: string): string {
|
|||||||
return `/leaderboard/players/${encodeURIComponent(guid)}`;
|
return `/leaderboard/players/${encodeURIComponent(guid)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardEntry {
|
export interface LeaderboardEntry extends Record<EfficiencyMetric, number | null> {
|
||||||
rank: number;
|
rank: number;
|
||||||
guid: string;
|
guid: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
import { getPersonalBestRecords } from './leaderboard-records.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,
|
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
|
||||||
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
|
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
|
||||||
|
|
||||||
@@ -25,6 +27,11 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
|
|||||||
const scoped = `FROM leaderboard_match_players mp
|
const scoped = `FROM leaderboard_match_players mp
|
||||||
JOIN leaderboard_matches m ON m.id = mp.match_id
|
JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||||
WHERE ${conditions.join(' AND ')}`;
|
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 (
|
const aggregated = `WITH totals AS (
|
||||||
SELECT mp.guid, MAX(mp.highest_round) AS highestRound,
|
SELECT mp.guid, MAX(mp.highest_round) AS highestRound,
|
||||||
SUM(mp.kills) AS kills, SUM(mp.rounds_survived) AS roundsSurvived,
|
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,
|
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
|
MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore, MAX(mp.last_seen_at) AS lastSeen
|
||||||
${scoped} GROUP BY mp.guid
|
${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 (
|
), identities AS (
|
||||||
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS recency
|
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')
|
FROM leaderboard_players WHERE game IN ('t5', 't6')
|
||||||
), ranked AS (
|
), ranked AS (
|
||||||
SELECT totals.*, p.name, RANK() OVER (ORDER BY totals.${metric} DESC) AS rank
|
SELECT measures.*, p.name, RANK() OVER (ORDER BY measures.${metric} DESC) AS rank
|
||||||
FROM totals JOIN identities p ON p.guid = totals.guid AND p.recency = 1
|
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
|
||||||
)`;
|
)`;
|
||||||
const query = options.q?.trim() ?? '';
|
const query = options.q?.trim() ?? '';
|
||||||
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
@@ -13,8 +14,8 @@
|
|||||||
const number = new Intl.NumberFormat('en-US');
|
const number = new Intl.NumberFormat('en-US');
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }];
|
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 defaultColumns: PlayerStatMetric[] = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed'];
|
||||||
const descriptions: Record<LeaderboardMetric, string> = {
|
const descriptions: Record<PlayerStatMetric, string> = {
|
||||||
highestRound: 'The highest round reached in a recorded game.',
|
highestRound: 'The highest round reached in a recorded game.',
|
||||||
kills: 'Cumulative kills across recorded games.',
|
kills: 'Cumulative kills across recorded games.',
|
||||||
roundsSurvived: 'Round progression during observed participation, added across 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.',
|
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.'
|
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<LeaderboardResponse | null>(null);
|
let data = $state<LeaderboardResponse | null>(null);
|
||||||
let maps = $state<LeaderboardMap[]>([]);
|
let maps = $state<LeaderboardMap[]>([]);
|
||||||
@@ -187,7 +192,7 @@
|
|||||||
|
|
||||||
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
|
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
|
||||||
<div class="rankings-header">
|
<div class="rankings-header">
|
||||||
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
|
<div><h2 id="rankings-title">{metricLabel}</h2><p>{description(metric)}</p>{#if isEfficiencyMetric(metric)}<p>Qualification is measured within the selected game and map. Fleet totals include all recorded players.</p>{/if}</div>
|
||||||
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
|
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
|
||||||
</div>
|
</div>
|
||||||
{#if data.entries.length}
|
{#if data.entries.length}
|
||||||
@@ -201,7 +206,7 @@
|
|||||||
<th scope="col" class="player">Player</th>
|
<th scope="col" class="player">Player</th>
|
||||||
{#each columns as key (key)}
|
{#each columns as key (key)}
|
||||||
<th scope="col" class="stat" class:selected={key === metric} aria-sort={key === metric ? 'descending' : 'none'}>
|
<th scope="col" class="stat" class:selected={key === metric} aria-sort={key === metric ? 'descending' : 'none'}>
|
||||||
<button onclick={() => updateFilters({ metric: key })} title={descriptions[key]}>{leaderboardMetrics.find(item => item.key === key)!.label}{#if key === metric}<span aria-hidden="true"> ↓</span>{/if}</button>
|
<button onclick={() => updateFilters({ metric: key })} title={description(key)}>{leaderboardMetrics.find(item => item.key === key)!.label}{#if key === metric}<span aria-hidden="true"> ↓</span>{/if}</button>
|
||||||
</th>
|
</th>
|
||||||
{/each}
|
{/each}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
@@ -213,16 +218,20 @@
|
|||||||
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
|
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
|
||||||
<div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
|
<div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
|
||||||
</th>
|
</th>
|
||||||
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}<RecordSource evidence={entry.records[key]} />{/if}</td>{/each}
|
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{formatValue(entry, key)}{#if isEfficiencyMetric(key)}<small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small>{/if}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}<RecordSource evidence={entry.records[key]} />{/if}</td>{/each}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<span>{number.format(data.offset + 1)}–{number.format(data.offset + data.entries.length)} of {number.format(data.total)} {data.total === 1 ? 'player' : 'players'}{query ? ` matching “${query}”` : ''}</span>
|
<span>{number.format(data.offset + 1)}–{number.format(data.offset + data.entries.length)} of {number.format(data.total)} {isEfficiencyMetric(metric) ? 'qualifying ' : ''}{data.total === 1 ? 'player' : 'players'}{query ? ` matching “${query}”` : ''}</span>
|
||||||
<div><Button size="sm" disabled={data.offset === 0 || loading} onclick={() => updateFilters({ offset: String(Math.max(0, offset - pageSize)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.entries.length >= data.total || loading} onclick={() => updateFilters({ offset: String(offset + pageSize) })}>Next</Button></div>
|
<div><Button size="sm" disabled={data.offset === 0 || loading} onclick={() => updateFilters({ offset: String(Math.max(0, offset - pageSize)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.entries.length >= data.total || loading} onclick={() => updateFilters({ offset: String(offset + pageSize) })}>Next</Button></div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if isEfficiencyMetric(metric)}
|
||||||
|
<EmptyState spacious title="No qualifying players found" description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}>
|
||||||
|
{#snippet actions()}<Button size="sm" onclick={() => updateFilters({ metric: 'highestRound' })}>Rank by highest round</Button>{/snippet}
|
||||||
|
</EmptyState>
|
||||||
{:else if query || game || map || offset}
|
{:else if query || game || map || offset}
|
||||||
<EmptyState spacious title="No players found" description="Try a different game, map, or player name.">
|
<EmptyState spacious title="No players found" description="Try a different game, map, or player name.">
|
||||||
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet}
|
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet}
|
||||||
@@ -292,6 +301,7 @@
|
|||||||
.stat { text-align: right; white-space: nowrap; }
|
.stat { text-align: right; white-space: nowrap; }
|
||||||
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
.stat.selected { color: var(--accent-primary-hover); }
|
.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); }
|
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 { 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); }
|
th button:hover { color: var(--accent-primary-hover); }
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js';
|
import { playerStatMetrics, type PlayerProfile, type PlayerStatMetric } from '$lib/leaderboard.js';
|
||||||
|
import { efficiencyMetrics, efficiencyValue, efficiencyEligible, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||||
@@ -14,8 +15,7 @@
|
|||||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||||
const date = (value: string) => $timeFormat.dateTime(value);
|
const date = (value: string) => $timeFormat.dateTime(value);
|
||||||
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||||
const ratio = (value: number, total: number, percent = false) => total ? `${number.format(value / total * (percent ? 100 : 1))}${percent ? '%' : ''}` : '—';
|
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly PlayerStatMetric[];
|
||||||
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[];
|
|
||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let refreshError = $state('');
|
let refreshError = $state('');
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<section aria-labelledby="totals-title">
|
<section aria-labelledby="totals-title">
|
||||||
<h2 id="totals-title">Lifetime stats</h2>
|
<h2 id="totals-title">Lifetime stats</h2>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
{#each leaderboardMetrics as metric (metric.key)}
|
{#each playerStatMetrics as metric (metric.key)}
|
||||||
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong>{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={profile.records[metric.key]} />{/if}</div>
|
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong>{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={profile.records[metric.key]} />{/if}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -49,11 +49,9 @@
|
|||||||
<section aria-labelledby="metrics-title">
|
<section aria-labelledby="metrics-title">
|
||||||
<h2 id="metrics-title">Player metrics</h2>
|
<h2 id="metrics-title">Player metrics</h2>
|
||||||
<div class="stats metrics">
|
<div class="stats metrics">
|
||||||
<div class="stat"><span>Headshots / kills</span><strong>{ratio(stats.headshots, stats.kills, true)}</strong></div>
|
{#each efficiencyMetrics as metric (metric.key)}
|
||||||
<div class="stat"><span>Kills per down</span><strong>{ratio(stats.kills, stats.downs)}</strong></div>
|
<div class="stat"><span>{metric.label}</span><strong>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</strong><small>{efficiencySample(stats, metric.key)}</small><small>{efficiencyEligible(stats, metric.key) ? 'Qualifies for lifetime ranking' : `${efficiencyRequirement(metric.key)} to rank`}</small></div>
|
||||||
<div class="stat"><span>Average kills per game</span><strong>{ratio(stats.kills, stats.gamesPlayed)}</strong></div>
|
{/each}
|
||||||
<div class="stat"><span>Average rounds per game</span><strong>{ratio(stats.roundsSurvived, stats.gamesPlayed)}</strong></div>
|
|
||||||
<div class="stat"><span>Completion rate</span><strong>{ratio(stats.gamesCompleted, stats.gamesPlayed, true)}</strong></div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="note">Only completed games contribute. Completion rate measures how often the player appeared in the final report. A dash means there is no denominator for that metric.</p>
|
<p class="note">Only completed games contribute. Completion rate measures how often the player appeared in the final report. A dash means there is no denominator for that metric.</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -63,10 +61,10 @@
|
|||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||||
<div class="table-scroll" role="region" aria-label="Per-map player records" tabindex="0">
|
<div class="table-scroll" role="region" aria-label="Per-map player records" tabindex="0">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th scope="col">Map</th>{#each leaderboardMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
|
<thead><tr><th scope="col">Map</th>{#each playerStatMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
|
||||||
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}
|
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}
|
||||||
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
|
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
|
||||||
{#each leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
|
{#each playerStatMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
|
||||||
{/each}</tbody>
|
{/each}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +75,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||||
<div class="table-scroll" role="region" aria-label="Player match history" tabindex="0">
|
<div class="table-scroll" role="region" aria-label="Player match history" tabindex="0">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{leaderboardMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
|
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{playerStatMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
|
||||||
<tbody>{#each profile.matches as match (match.id)}
|
<tbody>{#each profile.matches as match (match.id)}
|
||||||
<tr><th scope="row">{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
|
<tr><th scope="row">{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
|
||||||
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
|
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import type { LeaderboardMetric } from '../../src/lib/leaderboard.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
|
||||||
|
interface Sample {
|
||||||
|
kills?: number; headshots?: number; downs?: number; rounds?: number; completed?: number;
|
||||||
|
game?: string; map?: string; status?: 'active' | 'completed' | 'interrupted';
|
||||||
|
}
|
||||||
|
function addSamples(handle: Database.Database, guid: string, samples: Sample[]) {
|
||||||
|
for (const sample of samples) {
|
||||||
|
const { kills = 20, headshots = 10, downs = 1, rounds = 2, completed = 1,
|
||||||
|
game = 't5', map = 'theater', status = 'completed' } = sample;
|
||||||
|
const inserted = handle.prepare(`INSERT INTO leaderboard_matches
|
||||||
|
(server, game, map, map_key, map_name, started_at, ended_at, last_received_at, status,
|
||||||
|
highest_round, last_event, last_fingerprint)
|
||||||
|
VALUES ('removed-server', ?, ?, ?, ?, '2026-09-01', '2026-09-02', '2026-09-02', ?, 20, 'game_end', 'test')`)
|
||||||
|
.run(game, map, `${game}:${map}`, map, status);
|
||||||
|
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, '2026-09-01', '2026-09-02')`).run(game, guid, guid);
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, kills, headshots,
|
||||||
|
downs, rounds_survived, completed, has_stats, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, 20, ?, ?, ?, ?, ?, 1, '2026-09-01', '2026-09-02')`)
|
||||||
|
.run(inserted.lastInsertRowid, guid, kills, headshots, downs, rounds, completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const samples = (count: number, sample: Sample = {}) => Array.from({ length: count }, () => ({ ...sample }));
|
||||||
|
|
||||||
|
it('calculates every efficiency measure from aggregate counts and preserves raw ratios', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
addSamples(db(), 'weighted', [
|
||||||
|
{ kills: 1, headshots: 1, downs: 1, rounds: 1, completed: 0 },
|
||||||
|
{ kills: 99, headshots: 9, downs: 9, rounds: 9 },
|
||||||
|
...samples(3, { kills: 0, headshots: 0, downs: 0, rounds: 0, completed: 0 })
|
||||||
|
]);
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const entry = getLeaderboard().entries[0];
|
||||||
|
expect(entry).toMatchObject({ kills: 100, headshots: 10, downs: 10, roundsSurvived: 10,
|
||||||
|
gamesPlayed: 5, gamesCompleted: 1, headshotRate: 0.1, killsPerDown: 10,
|
||||||
|
averageKills: 20, averageRounds: 2, completionRate: 0.2 });
|
||||||
|
const { efficiencyMetrics, efficiencyValue } = await import('../../src/lib/leaderboard-efficiency.js');
|
||||||
|
for (const { key } of efficiencyMetrics) {
|
||||||
|
expect(entry[key]).toBe(efficiencyValue(entry, key));
|
||||||
|
expect(getLeaderboard({ metric: key }).entries).toEqual([entry]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('qualifies at five games, one hundred kills and five downs, with each metric enforcing its own sample', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
addSamples(db(), 'exact', samples(5));
|
||||||
|
addSamples(db(), 'four-games', samples(4, { kills: 1000, headshots: 1000, downs: 5 }));
|
||||||
|
addSamples(db(), 'ninety-nine-kills', [{ kills: 19 }, ...samples(4)]);
|
||||||
|
addSamples(db(), 'four-downs', [{ downs: 0 }, ...samples(4)]);
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const ids = (metric: LeaderboardMetric) =>
|
||||||
|
getLeaderboard({ metric }).entries.map(entry => entry.guid).sort();
|
||||||
|
expect(ids('headshotRate')).toEqual(['exact', 'four-downs']);
|
||||||
|
expect(ids('killsPerDown')).toEqual(['exact', 'ninety-nine-kills']);
|
||||||
|
for (const metric of ['averageKills', 'averageRounds', 'completionRate'] as const) {
|
||||||
|
expect(ids(metric)).toEqual(['exact', 'four-downs', 'ninety-nine-kills']);
|
||||||
|
}
|
||||||
|
expect(getLeaderboard({ metric: 'kills' }).total).toBe(4);
|
||||||
|
expect(getLeaderboard({ metric: 'headshotRate' }).summary).toEqual(getLeaderboard().summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps undefined ratios null and unranked while ranking legitimate zero efficiencies', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
addSamples(db(), 'zero', samples(5, { kills: 0, headshots: 0, downs: 0, rounds: 0, completed: 0 }));
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
expect(getLeaderboard().entries[0]).toMatchObject({ headshotRate: null, killsPerDown: null,
|
||||||
|
averageKills: 0, averageRounds: 0, completionRate: 0 });
|
||||||
|
for (const metric of ['headshotRate', 'killsPerDown'] as const) {
|
||||||
|
expect(getLeaderboard({ metric })).toMatchObject({ entries: [], total: 0,
|
||||||
|
summary: { players: 1, matches: 5, kills: 0 } });
|
||||||
|
}
|
||||||
|
for (const metric of ['averageKills', 'averageRounds', 'completionRate'] as const) {
|
||||||
|
expect(getLeaderboard({ metric })).toMatchObject({ total: 1, entries: [{ guid: 'zero', rank: 1 }] });
|
||||||
|
}
|
||||||
|
addSamples(db(), 'no-kills', samples(5, { kills: 0, headshots: 0, downs: 1 }));
|
||||||
|
expect(getLeaderboard({ metric: 'killsPerDown' }).entries).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'no-kills', killsPerDown: 0, rank: 1 })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recomputes qualification within selected games and maps using completed matches only', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
addSamples(db(), 'split', [...samples(3), ...samples(2, { game: 't6', map: 'town' })]);
|
||||||
|
addSamples(db(), 'unfinished', [...samples(4), { status: 'active', kills: 10000 },
|
||||||
|
{ status: 'interrupted', kills: 10000 }]);
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
expect(getLeaderboard({ metric: 'averageKills' })).toMatchObject({ total: 1,
|
||||||
|
entries: [{ guid: 'split', gamesPlayed: 5, averageKills: 20 }], summary: { players: 2, matches: 9, kills: 180 } });
|
||||||
|
expect(getLeaderboard({ metric: 'averageKills', game: 't5' })).toMatchObject({ total: 0, entries: [],
|
||||||
|
summary: { players: 2, matches: 7, kills: 140 } });
|
||||||
|
expect(getLeaderboard({ metric: 'averageKills', map: 't6:town' })).toMatchObject({ total: 0, entries: [],
|
||||||
|
summary: { players: 1, matches: 2, kills: 40 } });
|
||||||
|
addSamples(db(), 'split', samples(2));
|
||||||
|
expect(getLeaderboard({ metric: 'averageKills', game: 't5', map: 't5:theater' }).entries).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'split', gamesPlayed: 5, rank: 1 })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('qualifies before competition ranking, then searches and paginates without changing scope summaries', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const handle = db();
|
||||||
|
addSamples(handle, 'Ace', samples(5, { kills: 50 }));
|
||||||
|
addSamples(handle, 'Blake', samples(5, { kills: 50 }));
|
||||||
|
addSamples(handle, 'Casey', samples(5, { kills: 10 }));
|
||||||
|
addSamples(handle, 'Small sample', samples(4, { kills: 1000 }));
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||||
|
VALUES ('t6', 'Casey', 'Current name', '2026-09-01', '2026-09-03')`).run();
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const full = getLeaderboard({ metric: 'averageKills', game: 't5' });
|
||||||
|
expect(full.entries.map(entry => [entry.name, entry.rank])).toEqual([['Ace', 1], ['Blake', 1], ['Current name', 3]]);
|
||||||
|
const searched = getLeaderboard({ metric: 'averageKills', game: 't5', q: 'current NAME', limit: 1 });
|
||||||
|
expect(searched).toMatchObject({ total: 1, entries: [{ guid: 'Casey', rank: 3 }], summary: full.summary });
|
||||||
|
expect(getLeaderboard({ metric: 'averageKills', game: 't5', q: 'Small' })).toMatchObject({ total: 0, entries: [], summary: full.summary });
|
||||||
|
const page = getLeaderboard({ metric: 'averageKills', game: 't5', limit: 1, offset: 1 });
|
||||||
|
expect(page).toMatchObject({ total: 3, entries: [{ guid: 'Blake', rank: 1 }], summary: full.summary });
|
||||||
|
expect(getLeaderboard({ metric: 'averageKills', offset: 3 })).toMatchObject({ total: 3, entries: [], summary: full.summary });
|
||||||
|
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||||
|
const response = GET({ url: new URL('http://localhost/api/leaderboard?metric=averageKills&game=t5&q=current&limit=1') });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({ metric: 'averageKills', total: 1,
|
||||||
|
entries: [{ guid: 'Casey', rank: 3, averageKills: 10, gamesPlayed: 5 }], summary: full.summary });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts each efficiency metric through the API when the qualified group is empty', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
addSamples(db(), 'small', samples(4));
|
||||||
|
const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js');
|
||||||
|
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||||
|
for (const { key } of efficiencyMetrics) {
|
||||||
|
const response = GET({ url: new URL(`http://localhost/api/leaderboard?metric=${key}`) });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({ metric: key, entries: [], total: 0,
|
||||||
|
summary: { players: 1, matches: 4, kills: 80 } });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { PlayerStats } from '../../src/lib/leaderboard.js';
|
||||||
|
import {
|
||||||
|
EFFICIENCY_MIN_DOWNS, EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS,
|
||||||
|
efficiencyEligible, efficiencyMetrics, efficiencyRequirement, efficiencySample, efficiencyValue,
|
||||||
|
isEfficiencyMetric, type EfficiencyMetric
|
||||||
|
} from '../../src/lib/leaderboard-efficiency.js';
|
||||||
|
|
||||||
|
const stats = (overrides: Partial<PlayerStats> = {}): PlayerStats => ({
|
||||||
|
highestRound: 20, kills: 100, roundsSurvived: 47, headshots: 31, revives: 7, downs: 5,
|
||||||
|
gamesPlayed: 5, gamesCompleted: 3, bestKills: 50, bestScore: 12000, ...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('efficiency ranking qualification', () => {
|
||||||
|
it.each(efficiencyMetrics)('requires five ended games for $key even when other counters are large', ({ key }) => {
|
||||||
|
expect(efficiencyEligible(stats({ gamesPlayed: EFFICIENCY_MIN_GAMES - 1, kills: 10000, downs: 100 }), key)).toBe(false);
|
||||||
|
expect(efficiencyEligible(stats({ gamesPlayed: EFFICIENCY_MIN_GAMES }), key)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires enough kills for headshot rate and enough downs for kills per down', () => {
|
||||||
|
expect(efficiencyEligible(stats({ kills: EFFICIENCY_MIN_KILLS - 1 }), 'headshotRate')).toBe(false);
|
||||||
|
expect(efficiencyEligible(stats({ kills: EFFICIENCY_MIN_KILLS }), 'headshotRate')).toBe(true);
|
||||||
|
expect(efficiencyEligible(stats({ downs: EFFICIENCY_MIN_DOWNS - 1 }), 'killsPerDown')).toBe(false);
|
||||||
|
expect(efficiencyEligible(stats({ downs: EFFICIENCY_MIN_DOWNS }), 'killsPerDown')).toBe(true);
|
||||||
|
expect(efficiencyEligible(stats({ kills: 0 }), 'killsPerDown')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts departed players in ended-game samples and applies no extra counter minimum to game averages', () => {
|
||||||
|
const departed = stats({ kills: 0, headshots: 0, downs: 0, roundsSurvived: 0, gamesCompleted: 0 });
|
||||||
|
for (const metric of ['averageKills', 'averageRounds', 'completionRate'] as const) {
|
||||||
|
expect(efficiencyEligible(departed, metric)).toBe(true);
|
||||||
|
expect(efficiencyValue(departed, metric)).toBe(0);
|
||||||
|
}
|
||||||
|
expect(efficiencyValue(stats({ gamesPlayed: 5, gamesCompleted: 3 }), 'completionRate')).toBe(3 / 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('efficiency values', () => {
|
||||||
|
it.each([
|
||||||
|
['headshotRate', { kills: 0 }], ['killsPerDown', { downs: 0 }],
|
||||||
|
['averageKills', { gamesPlayed: 0 }], ['averageRounds', { gamesPlayed: 0 }],
|
||||||
|
['completionRate', { gamesPlayed: 0 }]
|
||||||
|
] as Array<[EfficiencyMetric, Partial<PlayerStats>]>)('leaves %s undefined with no denominator', (metric, zeroDenominator) => {
|
||||||
|
expect(efficiencyValue(stats(zeroDenominator), metric)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ratios of totals instead of equally weighting games with different sample sizes', () => {
|
||||||
|
// One game has 1/1 headshots and 1 kill/down; another has 9/99 and 99/9.
|
||||||
|
const aggregate = stats({ gamesPlayed: 2, headshots: 1 + 9, kills: 1 + 99, downs: 1 + 9 });
|
||||||
|
expect(efficiencyValue(aggregate, 'headshotRate')).toBe((1 + 9) / (1 + 99));
|
||||||
|
expect(efficiencyValue(aggregate, 'headshotRate')).not.toBe((1 / 1 + 9 / 99) / 2);
|
||||||
|
expect(efficiencyValue(aggregate, 'killsPerDown')).toBe((1 + 99) / (1 + 9));
|
||||||
|
expect(efficiencyValue(aggregate, 'killsPerDown')).not.toBe((1 / 1 + 99 / 9) / 2);
|
||||||
|
expect(efficiencyEligible(aggregate, 'headshotRate')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves fractional ratios and low samples without rounding, capping or inventing a denominator', () => {
|
||||||
|
const small = stats({ gamesPlayed: 3, gamesCompleted: 1, kills: 2, headshots: 1, downs: 3, roundsSurvived: 7 });
|
||||||
|
expect(efficiencyValue(small, 'headshotRate')).toBe(1 / 2);
|
||||||
|
expect(efficiencyValue(small, 'killsPerDown')).toBe(2 / 3);
|
||||||
|
expect(efficiencyValue(small, 'averageKills')).toBe(2 / 3);
|
||||||
|
expect(efficiencyValue(small, 'averageRounds')).toBe(7 / 3);
|
||||||
|
expect(efficiencyValue(small, 'completionRate')).toBe(1 / 3);
|
||||||
|
expect(efficiencyValue(stats({ kills: 1000000, downs: 1 }), 'killsPerDown')).toBe(1000000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('efficiency explanations', () => {
|
||||||
|
it('shows the actual numerator and denominator with their units', () => {
|
||||||
|
const player = stats({ kills: 1200 });
|
||||||
|
expect(efficiencySample(player, 'headshotRate')).toBe('31 headshots / 1,200 kills');
|
||||||
|
expect(efficiencySample(player, 'killsPerDown')).toBe('1,200 kills / 5 downs');
|
||||||
|
expect(efficiencySample(player, 'averageKills')).toBe('1,200 kills / 5 ended games');
|
||||||
|
expect(efficiencySample(player, 'averageRounds')).toBe('47 observed rounds / 5 ended games');
|
||||||
|
expect(efficiencySample(player, 'completionRate')).toBe('3 finishes / 5 ended games');
|
||||||
|
expect(efficiencySample(stats({ kills: 1, downs: 0 }), 'killsPerDown')).toBe('1 kill / 0 downs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies ratio metrics and publishes the thresholds used to qualify them', () => {
|
||||||
|
for (const { key, description } of efficiencyMetrics) {
|
||||||
|
expect(isEfficiencyMetric(key)).toBe(true);
|
||||||
|
expect(description).toContain('divided by');
|
||||||
|
expect(efficiencyRequirement(key)).toContain(`${EFFICIENCY_MIN_GAMES} ended games`);
|
||||||
|
}
|
||||||
|
expect(efficiencyRequirement('headshotRate')).toContain(`${EFFICIENCY_MIN_KILLS} kills`);
|
||||||
|
expect(efficiencyRequirement('killsPerDown')).toContain(`${EFFICIENCY_MIN_DOWNS} downs`);
|
||||||
|
expect(efficiencyMetrics.filter(metric => metric.format === 'percent').map(metric => metric.key))
|
||||||
|
.toEqual(['headshotRate', 'completionRate']);
|
||||||
|
expect(isEfficiencyMetric('kills')).toBe(false);
|
||||||
|
expect(isEfficiencyMetric('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -123,6 +123,8 @@ describe('persisted leaderboard queries', () => {
|
|||||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
|
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
|
||||||
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
|
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
|
||||||
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
|
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
|
||||||
|
headshotRate: 320 / 770, killsPerDown: 770 / 4, averageKills: 770 / 4,
|
||||||
|
averageRounds: 38 / 4, completionRate: 3 / 4,
|
||||||
lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
||||||
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||||
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||||
@@ -145,6 +147,8 @@ describe('persisted leaderboard queries', () => {
|
|||||||
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
|
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
|
||||||
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
|
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
|
||||||
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3,
|
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3,
|
||||||
|
headshotRate: 120 / 270, killsPerDown: 270 / 3, averageKills: 270 / 3,
|
||||||
|
averageRounds: 19 / 3, completionRate: 2 / 3,
|
||||||
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z', records: {
|
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z', records: {
|
||||||
highestRound: expect.objectContaining({ matchId: 1, value: 10 }),
|
highestRound: expect.objectContaining({ matchId: 1, value: 10 }),
|
||||||
bestKills: expect.objectContaining({ matchId: 2, value: 150 }),
|
bestKills: expect.objectContaining({ matchId: 2, value: 150 }),
|
||||||
@@ -156,6 +160,8 @@ describe('persisted leaderboard queries', () => {
|
|||||||
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
|
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
|
||||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
|
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
|
||||||
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1,
|
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1,
|
||||||
|
headshotRate: 200 / 500, killsPerDown: 500, averageKills: 500,
|
||||||
|
averageRounds: 19, completionRate: 1,
|
||||||
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
||||||
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||||
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user