Files
electric-cherry/src/lib/server/fleet/leaderboard.ts
T

269 lines
11 KiB
TypeScript

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';
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'];
}
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());
interface LeaderboardRow extends Omit<RankedEntry, 'guid'> {
// A left join keeps metadata available when the requested page has no players.
guid: string | null;
matchingTotal: number;
summaryPlayers: number;
summaryMatches: number;
summaryKills: number;
summaryRounds: number;
summaryHighestRound: number;
summaryFirstTrackedAt: string | null;
summaryUpdatedAt: string | null;
}
export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
return metrics.has(value);
}
/** Rank each GUID once within the selected matches; player search keeps their actual rank. */
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
return readLeaderboard(options);
}
/** 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
);
}
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 {
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 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);
}
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}` : '')
: '';
// 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.
const observationStorage = exactGuid === undefined ? 'MATERIALIZED' : 'NOT MATERIALIZED';
const aggregated = `WITH observations AS ${observationStorage} (
SELECT mp.match_id, mp.guid, mp.highest_round, mp.kills, mp.rounds_survived,
mp.headshots, mp.revives, mp.downs, mp.completed, mp.best_score, mp.last_seen_at,
${countsAsGamePlayed} AS counts_as_game,
m.started_at AS matchStartedAt, m.last_received_at AS matchUpdatedAt
FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
WHERE ${conditions.join(' AND ')}
), totals AS MATERIALIZED (
SELECT guid, MAX(highest_round) AS highestRound,
SUM(kills) AS kills, SUM(rounds_survived) AS roundsSurvived,
SUM(headshots) AS headshots, SUM(revives) AS revives, SUM(downs) AS downs,
SUM(counts_as_game) AS gamesPlayed, SUM(completed) AS gamesCompleted,
MAX(kills) AS bestKills, MAX(best_score) AS bestScore, MAX(last_seen_at) AS lastSeen
FROM observations GROUP BY 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 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}
)`;
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. */
}
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
]);
let saved = cache.get(cacheKey);
if (!saved || revision < 0 || saved.revision !== revision) {
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
), matchSummary AS (
SELECT COUNT(DISTINCT match_id) AS matches, MIN(matchStartedAt) AS firstTrackedAt,
MAX(matchUpdatedAt) AS updatedAt FROM observations
), metadata AS (
SELECT playerSummary.*, matchSummary.* FROM playerSummary CROSS JOIN matchSummary
) SELECT ranked.*, metadata.players AS summaryPlayers,
metadata.matches AS summaryMatches, metadata.kills AS summaryKills,
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
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!);
}
if (exactGuid !== undefined) {
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 entries = addRecordSources(handle, matching.slice(offset, offset + limit), options);
return {
entries,
maps: saved.maps,
total: matching.length,
limit,
offset,
metric,
summary: saved.summary
};
}