Add qualified efficiency leaderboard rankings

This commit is contained in:
2026-10-01 12:16:27 -04:00
parent c7d71c63ba
commit 47ebe83106
9 changed files with 385 additions and 25 deletions
@@ -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 } });
}
});
+92
View File
@@ -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);
});
});
+6
View File
@@ -123,6 +123,8 @@ describe('persisted leaderboard queries', () => {
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
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: {
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
@@ -145,6 +147,8 @@ describe('persisted leaderboard queries', () => {
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
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: {
highestRound: expect.objectContaining({ matchId: 1, value: 10 }),
bestKills: expect.objectContaining({ matchId: 2, value: 150 }),
@@ -156,6 +160,8 @@ describe('persisted leaderboard queries', () => {
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
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: {
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),