Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -2,19 +2,34 @@ import { db } from '../db/index.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||
import { countsAsGamePlayed } from './leaderboard-participation.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';
|
||||
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';
|
||||
|
||||
const metrics = new Set<string>(leaderboardMetrics.map(metric => metric.key));
|
||||
const metrics = new Set<string>(leaderboardMetrics.map((metric) => metric.key));
|
||||
export const MAX_LEADERBOARD_OFFSET = 1_000_000;
|
||||
type RankedEntry = Omit<LeaderboardEntry, 'records'>;
|
||||
|
||||
interface RankedScope { revision: number; entries: RankedEntry[]; maps: LeaderboardMap[]; summary: LeaderboardResponse['summary'] }
|
||||
interface RankedScope {
|
||||
revision: number;
|
||||
entries: RankedEntry[];
|
||||
maps: LeaderboardMap[];
|
||||
summary: LeaderboardResponse['summary'];
|
||||
}
|
||||
const scopeCaches = new WeakMap<object, Map<string, RankedScope>>();
|
||||
// Match SQLite's built-in lower(), which folds ASCII rather than Unicode.
|
||||
const searchText = (value: string) => value.replace(/[A-Z]/g, ch => ch.toLowerCase());
|
||||
const searchText = (value: string) => value.replace(/[A-Z]/g, (ch) => ch.toLowerCase());
|
||||
|
||||
interface LeaderboardRow extends Omit<RankedEntry, 'guid'> {
|
||||
// A left join keeps metadata available when the requested page has no players.
|
||||
@@ -39,35 +54,78 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
|
||||
}
|
||||
|
||||
/** Select the exact player after ranking everyone eligible in the requested scope. */
|
||||
export function getPlayerLeaderboardEntry(guid: string,
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map' | 'players' | 'metric'> = {}): LeaderboardEntry | null {
|
||||
return readLeaderboard({ game: options.game, map: options.map, players: options.players, metric: options.metric, limit: 1 }, guid);
|
||||
export function getPlayerLeaderboardEntry(
|
||||
guid: string,
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map' | 'players' | 'metric'> = {}
|
||||
): LeaderboardEntry | null {
|
||||
return readLeaderboard(
|
||||
{
|
||||
game: options.game,
|
||||
map: options.map,
|
||||
players: options.players,
|
||||
metric: options.metric,
|
||||
limit: 1
|
||||
},
|
||||
guid
|
||||
);
|
||||
}
|
||||
|
||||
function addRecordSources(handle: ReturnType<typeof db>, entries: RankedEntry[], options: LeaderboardOptions): LeaderboardEntry[] {
|
||||
const records = getPersonalBestRecords(handle, entries.map(entry => ({
|
||||
...entry, game: options.game || undefined, map: options.map || undefined, players: options.players
|
||||
})));
|
||||
function addRecordSources(
|
||||
handle: ReturnType<typeof db>,
|
||||
entries: RankedEntry[],
|
||||
options: LeaderboardOptions
|
||||
): LeaderboardEntry[] {
|
||||
const records = getPersonalBestRecords(
|
||||
handle,
|
||||
entries.map((entry) => ({
|
||||
...entry,
|
||||
game: options.game || undefined,
|
||||
map: options.map || undefined,
|
||||
players: options.players
|
||||
}))
|
||||
);
|
||||
return entries.map((entry, index) => ({ ...entry, records: records[index] }));
|
||||
}
|
||||
|
||||
function readLeaderboard(options: LeaderboardOptions): LeaderboardResponse;
|
||||
function readLeaderboard(options: LeaderboardOptions, exactGuid: string): LeaderboardEntry | null;
|
||||
function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): LeaderboardResponse | LeaderboardEntry | null {
|
||||
function readLeaderboard(
|
||||
options: LeaderboardOptions,
|
||||
exactGuid?: string
|
||||
): LeaderboardResponse | LeaderboardEntry | null {
|
||||
const handle = db();
|
||||
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
|
||||
const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50;
|
||||
const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0;
|
||||
const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", 'mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`,
|
||||
`mp.highest_round >= ${MIN_RECORDED_ROUND}`];
|
||||
const metric =
|
||||
options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
|
||||
const limit = Number.isSafeInteger(options.limit)
|
||||
? Math.min(100, Math.max(1, options.limit!))
|
||||
: 50;
|
||||
const offset = Number.isSafeInteger(options.offset)
|
||||
? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!))
|
||||
: 0;
|
||||
const conditions = [
|
||||
"m.game IN ('t5', 't6')",
|
||||
"m.status = 'completed'",
|
||||
'mp.has_stats = 1',
|
||||
`m.highest_round >= ${MIN_RECORDED_ROUND}`,
|
||||
`mp.highest_round >= ${MIN_RECORDED_ROUND}`
|
||||
];
|
||||
const parameters: Array<string | number> = [];
|
||||
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
|
||||
if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); }
|
||||
if (options.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); }
|
||||
if (options.game) {
|
||||
conditions.push('m.game = ?');
|
||||
parameters.push(options.game);
|
||||
}
|
||||
if (options.map) {
|
||||
conditions.push('m.map_key = ?');
|
||||
parameters.push(options.map);
|
||||
}
|
||||
if (options.players) {
|
||||
conditions.push('m.start_players = ?');
|
||||
parameters.push(options.players);
|
||||
}
|
||||
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}` : '')
|
||||
? `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}` : '')
|
||||
: '';
|
||||
// A board reuses observations for player totals and distinct match metadata.
|
||||
// Exact-player requests only consume the totals, so they can stream the join once.
|
||||
@@ -100,14 +158,31 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
|
||||
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
|
||||
)`;
|
||||
let revision = -1;
|
||||
try { revision = (handle.prepare("SELECT revision FROM report_revisions WHERE topic='leaderboard'").get() as { revision: number }).revision; }
|
||||
catch { /* Standalone history tools can operate without cache metadata. */ }
|
||||
try {
|
||||
revision = (
|
||||
handle
|
||||
.prepare("SELECT revision FROM report_revisions WHERE topic='leaderboard'")
|
||||
.get() as { revision: number }
|
||||
).revision;
|
||||
} catch {
|
||||
/* Standalone history tools can operate without cache metadata. */
|
||||
}
|
||||
let cache = scopeCaches.get(handle);
|
||||
if (!cache) { cache = new Map(); scopeCaches.set(handle, cache); }
|
||||
const cacheKey = JSON.stringify([options.game ?? '', options.map ?? '', options.players ?? '', metric]);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
scopeCaches.set(handle, cache);
|
||||
}
|
||||
const cacheKey = JSON.stringify([
|
||||
options.game ?? '',
|
||||
options.map ?? '',
|
||||
options.players ?? '',
|
||||
metric
|
||||
]);
|
||||
let saved = cache.get(cacheKey);
|
||||
if (!saved || revision < 0 || saved.revision !== revision) {
|
||||
const rows = handle.prepare(`${aggregated}, playerSummary AS (
|
||||
const rows = handle
|
||||
.prepare(
|
||||
`${aggregated}, playerSummary AS (
|
||||
SELECT COUNT(*) AS players, COALESCE(SUM(kills), 0) AS kills,
|
||||
COALESCE(SUM(roundsSurvived), 0) AS roundsSurvived,
|
||||
COALESCE(MAX(highestRound), 0) AS highestRound FROM totals
|
||||
@@ -121,36 +196,73 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
|
||||
metadata.roundsSurvived AS summaryRounds, metadata.highestRound AS summaryHighestRound,
|
||||
metadata.firstTrackedAt AS summaryFirstTrackedAt, metadata.updatedAt AS summaryUpdatedAt
|
||||
FROM metadata LEFT JOIN ranked ON 1
|
||||
ORDER BY ranked.${metric} DESC, ranked.name COLLATE NOCASE, ranked.name, ranked.guid`)
|
||||
.all(...parameters) as LeaderboardRow[];
|
||||
const rankedEntries = rows.filter((row): row is LeaderboardRow & { guid: string } => row.guid !== null)
|
||||
.map(({ matchingTotal, summaryPlayers, summaryMatches, summaryKills, summaryRounds,
|
||||
summaryHighestRound, summaryFirstTrackedAt, summaryUpdatedAt, ...entry }) => entry);
|
||||
const metadata = rows[0];
|
||||
const summary: LeaderboardResponse['summary'] = {
|
||||
players: metadata.summaryPlayers, matches: metadata.summaryMatches, kills: metadata.summaryKills,
|
||||
roundsSurvived: metadata.summaryRounds, highestRound: metadata.summaryHighestRound,
|
||||
firstTrackedAt: metadata.summaryFirstTrackedAt, updatedAt: metadata.summaryUpdatedAt
|
||||
};
|
||||
// Keep qualifying map variants available, even after their server is removed.
|
||||
const maps = handle.prepare(`SELECT map_key AS key, game, map, map_name AS name
|
||||
ORDER BY ranked.${metric} DESC, ranked.name COLLATE NOCASE, ranked.name, ranked.guid`
|
||||
)
|
||||
.all(...parameters) as LeaderboardRow[];
|
||||
const rankedEntries = rows
|
||||
.filter((row): row is LeaderboardRow & { guid: string } => row.guid !== null)
|
||||
.map(
|
||||
({
|
||||
matchingTotal,
|
||||
summaryPlayers,
|
||||
summaryMatches,
|
||||
summaryKills,
|
||||
summaryRounds,
|
||||
summaryHighestRound,
|
||||
summaryFirstTrackedAt,
|
||||
summaryUpdatedAt,
|
||||
...entry
|
||||
}) => entry
|
||||
);
|
||||
const metadata = rows[0];
|
||||
const summary: LeaderboardResponse['summary'] = {
|
||||
players: metadata.summaryPlayers,
|
||||
matches: metadata.summaryMatches,
|
||||
kills: metadata.summaryKills,
|
||||
roundsSurvived: metadata.summaryRounds,
|
||||
highestRound: metadata.summaryHighestRound,
|
||||
firstTrackedAt: metadata.summaryFirstTrackedAt,
|
||||
updatedAt: metadata.summaryUpdatedAt
|
||||
};
|
||||
// Keep qualifying map variants available, even after their server is removed.
|
||||
const maps = handle
|
||||
.prepare(
|
||||
`SELECT map_key AS key, game, map, map_name AS name
|
||||
FROM leaderboard_matches WHERE game IN ('t5', 't6') AND status = 'completed'
|
||||
AND highest_round >= ${MIN_RECORDED_ROUND}${options.game ? ' AND game = ?' : ''}
|
||||
GROUP BY game, map_key HAVING id = MAX(id)
|
||||
ORDER BY game, name COLLATE NOCASE, map_key`)
|
||||
.all(...(options.game ? [options.game] : [])) as LeaderboardMap[];
|
||||
saved = { revision, entries: rankedEntries, maps, summary };
|
||||
cache.delete(cacheKey);
|
||||
if (rankedEntries.length <= 100_000) cache.set(cacheKey, saved);
|
||||
// Bound total retained players as well as the number of scopes.
|
||||
while (cache.size > 8 || [...cache.values()].reduce((n, scope) => n + scope.entries.length, 0) > 100_000) cache.delete(cache.keys().next().value!);
|
||||
ORDER BY game, name COLLATE NOCASE, map_key`
|
||||
)
|
||||
.all(...(options.game ? [options.game] : [])) as LeaderboardMap[];
|
||||
saved = { revision, entries: rankedEntries, maps, summary };
|
||||
cache.delete(cacheKey);
|
||||
if (rankedEntries.length <= 100_000) cache.set(cacheKey, saved);
|
||||
// Bound total retained players as well as the number of scopes.
|
||||
while (
|
||||
cache.size > 8 ||
|
||||
[...cache.values()].reduce((n, scope) => n + scope.entries.length, 0) > 100_000
|
||||
)
|
||||
cache.delete(cache.keys().next().value!);
|
||||
}
|
||||
if (exactGuid !== undefined) {
|
||||
const entry = saved.entries.find(entry => entry.guid === exactGuid);
|
||||
const entry = saved.entries.find((entry) => entry.guid === exactGuid);
|
||||
return entry ? addRecordSources(handle, [entry], options)[0] : null;
|
||||
}
|
||||
const query = searchText(options.q?.trim() ?? '');
|
||||
const matching = query ? saved.entries.filter(entry => searchText(entry.name).includes(query) || searchText(entry.guid).includes(query)) : saved.entries;
|
||||
const matching = query
|
||||
? saved.entries.filter(
|
||||
(entry) =>
|
||||
searchText(entry.name).includes(query) || searchText(entry.guid).includes(query)
|
||||
)
|
||||
: saved.entries;
|
||||
const entries = addRecordSources(handle, matching.slice(offset, offset + limit), options);
|
||||
return { entries, maps: saved.maps, total: matching.length, limit, offset, metric, summary: saved.summary };
|
||||
return {
|
||||
entries,
|
||||
maps: saved.maps,
|
||||
total: matching.length,
|
||||
limit,
|
||||
offset,
|
||||
metric,
|
||||
summary: saved.summary
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user