Add qualified efficiency leaderboard rankings
This commit is contained in:
@@ -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: '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<LeaderboardMetric, number>;
|
||||
export type PlayerStats = Record<PlayerStatMetric, number>;
|
||||
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<EfficiencyMetric, number | null> {
|
||||
rank: number;
|
||||
guid: string;
|
||||
name: string;
|
||||
|
||||
@@ -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' : '';
|
||||
|
||||
Reference in New Issue
Block a user