Add persistent player leaderboards from game telemetry

This commit is contained in:
2026-09-18 16:43:44 -04:00
parent 0f55fa5f77
commit a182aeb950
16 changed files with 1135 additions and 17 deletions
+66
View File
@@ -0,0 +1,66 @@
export const leaderboardMetrics = [
{ key: 'highestRound', label: 'Highest round' },
{ key: 'kills', label: 'Total kills' },
{ key: 'roundsSurvived', label: 'Total rounds' },
{ key: 'headshots', label: 'Headshots' },
{ key: 'revives', label: 'Revives' },
{ key: 'downs', label: 'Downs' },
{ key: 'gamesPlayed', label: 'Games played' },
{ key: 'gamesCompleted', label: 'Games completed' },
{ key: 'bestKills', label: 'Best game kills' },
{ key: 'bestScore', label: 'Highest reported score' }
] as const;
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
export interface LeaderboardEntry {
rank: number;
game: string;
guid: string;
name: string;
highestRound: number;
kills: number;
roundsSurvived: number;
headshots: number;
revives: number;
downs: number;
gamesPlayed: number;
gamesCompleted: number;
bestKills: number;
bestScore: number;
lastSeen: string;
}
export interface LeaderboardMap {
key: string;
game: string;
map: string;
name: string;
}
export interface LeaderboardOptions {
game?: string;
map?: string;
metric?: LeaderboardMetric;
q?: string;
limit?: number;
offset?: number;
}
export interface LeaderboardResponse {
entries: LeaderboardEntry[];
maps: LeaderboardMap[];
total: number;
limit: number;
offset: number;
metric: LeaderboardMetric;
summary: {
players: number;
matches: number;
kills: number;
roundsSurvived: number;
highestRound: number;
firstTrackedAt: string | null;
updatedAt: string | null;
};
}
+2
View File
@@ -5,6 +5,7 @@ import { config } from '../config.js';
import schemaSql from './schema.sql?raw';
import { requirement, type BetaPlan, type BetaRequirement } from '../../beta-plan.js';
import { emptyMetrics } from '../fleet/beta-evaluation.js';
import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
let _db: Database.Database | null = null;
@@ -259,6 +260,7 @@ export function db(): Database.Database {
backfillMultiScriptJoins(handle);
assertSnapshotSchemeSafe(handle);
migrateGameTelemetry(handle);
backfillLeaderboard(handle);
} catch (err) {
handle.close();
throw err;
+50
View File
@@ -6,6 +6,56 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- Historical gameplay deliberately has no FK to servers: destroying a server
-- must not erase records. Each player's counters are high-water marks per match.
CREATE TABLE IF NOT EXISTS leaderboard_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server TEXT NOT NULL,
game TEXT NOT NULL,
map TEXT NOT NULL,
map_key TEXT NOT NULL,
map_name TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
last_received_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | completed | interrupted
start_observed INTEGER NOT NULL DEFAULT 0,
highest_round INTEGER NOT NULL DEFAULT 0,
last_event TEXT NOT NULL,
last_fingerprint TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_leaderboard_server ON leaderboard_matches(server, id DESC);
CREATE INDEX IF NOT EXISTS idx_leaderboard_map ON leaderboard_matches(game, map_key);
CREATE TABLE IF NOT EXISTS leaderboard_players (
game TEXT NOT NULL,
guid TEXT NOT NULL,
name TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
PRIMARY KEY (game, guid)
);
CREATE TABLE IF NOT EXISTS leaderboard_match_players (
match_id INTEGER NOT NULL REFERENCES leaderboard_matches(id) ON DELETE CASCADE,
guid TEXT NOT NULL,
highest_round INTEGER NOT NULL DEFAULT 0,
rounds_survived INTEGER NOT NULL DEFAULT 0,
kills INTEGER NOT NULL DEFAULT 0,
headshots INTEGER NOT NULL DEFAULT 0,
downs INTEGER NOT NULL DEFAULT 0,
revives INTEGER NOT NULL DEFAULT 0,
best_score INTEGER NOT NULL DEFAULT 0,
last_round INTEGER NOT NULL DEFAULT 0,
present INTEGER NOT NULL DEFAULT 1,
has_stats INTEGER NOT NULL DEFAULT 0,
completed INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
PRIMARY KEY (match_id, guid)
);
CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id);
CREATE TABLE IF NOT EXISTS servers (
name TEXT PRIMARY KEY,
komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container
+137
View File
@@ -0,0 +1,137 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { parseServerName, selectedZombieMap, zombieMaps } from '../../map-aliases.js';
import { parseGameEvent, type GameTelemetry } from '../../telemetry.js';
interface Match {
id: number;
map_key: string;
game: string;
status: 'active' | 'completed' | 'interrupted';
highest_round: number;
last_event: GameTelemetry['event'];
last_fingerprint: string;
}
interface Participation {
guid: string;
last_round: number;
present: number;
}
function mapIdentity(handle: Database.Database, report: GameTelemetry) {
const server = handle.prepare('SELECT game, map, map_slug FROM servers WHERE name = ?').get(report.server) as
{ game: string | null; map: string | null; map_slug: string | null } | undefined;
const parsed = parseServerName(report.server);
const game = (server?.game ?? parsed?.game ?? 'unknown').toLowerCase();
const map = report.map.toLowerCase();
// A shared engine map (notably zm_transit) is not enough to identify T6's
// location/ruleset. Use the registered selection only when it matches.
const parsedSlug = parsed?.mapCanonical === map ? parsed.mapSlug : undefined;
const slug = server?.map === map ? server.map_slug ?? parsedSlug : parsedSlug;
const candidates = zombieMaps(game).filter(entry => entry.canonical === map);
const selected = game === 't6' && candidates.length > 1 && !slug
? undefined : selectedZombieMap(game, map, slug);
const variant = game === 't6' ? selected?.slug ?? slug ?? 'unspecified' : '';
return {
game, map,
key: [game, map, ...(variant ? [variant] : [])].join(':'),
name: selected?.friendly ?? (game === 't6' && candidates.length > 1 ? `${map} (${slug ?? 'unspecified variant'})` : map)
};
}
function fingerprint(report: GameTelemetry): string {
// Receipt timestamps and roster ordering do not identify new gameplay.
const { receivedAt: _receivedAt, ...event } = report;
const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid));
return createHash('sha256').update(JSON.stringify({ ...event, players })).digest('hex');
}
/** Called inside the same transaction as the latest scoreboard snapshot. */
export function recordLeaderboardTelemetry(handle: Database.Database, report: GameTelemetry): void {
const identity = mapIdentity(handle, report);
const signature = fingerprint(report);
let match = handle.prepare('SELECT * FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1')
.get(report.server) as Match | undefined;
if (match?.last_fingerprint === signature && match.map_key === identity.key) return;
const isStart = report.event === 'game_start';
const round = isStart ? 0 : report.round;
const newMatch = !match || match.map_key !== identity.key
|| (isStart && (match.status !== 'active' || match.last_event !== 'game_start'))
|| (!isStart && round < match.highest_round)
|| (report.event === 'round_change' && match.status !== 'active');
if (newMatch) {
if (match?.status === 'active') {
handle.prepare("UPDATE leaderboard_matches SET status = 'interrupted', ended_at = ? WHERE id = ?")
.run(report.receivedAt, match.id);
}
const inserted = handle.prepare(`INSERT INTO leaderboard_matches
(server, game, map, map_key, map_name, started_at, last_received_at, start_observed, last_event, last_fingerprint)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(report.server, identity.game, identity.map, identity.key, identity.name,
report.receivedAt, report.receivedAt, Number(isStart), report.event, signature);
match = { id: Number(inserted.lastInsertRowid), game: identity.game, map_key: identity.key,
status: 'active', highest_round: 0, last_event: report.event, last_fingerprint: signature };
}
if (!match) return;
const previous = new Map((handle.prepare('SELECT guid, last_round, present FROM leaderboard_match_players WHERE match_id = ?')
.all(match.id) as Participation[]).map(player => [player.guid, player]));
handle.prepare('UPDATE leaderboard_match_players SET present = 0 WHERE match_id = ?').run(match.id);
const saveIdentity = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO UPDATE SET
name = CASE WHEN excluded.last_seen_at >= last_seen_at THEN excluded.name ELSE name END,
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, rounds_survived, kills, headshots, downs, revives, best_score,
last_round, present, has_stats, completed, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
ON CONFLICT(match_id, guid) DO UPDATE SET
highest_round = MAX(highest_round, excluded.highest_round),
rounds_survived = rounds_survived + excluded.rounds_survived,
kills = MAX(kills, excluded.kills), headshots = MAX(headshots, excluded.headshots),
downs = MAX(downs, excluded.downs), revives = MAX(revives, excluded.revives),
best_score = MAX(best_score, excluded.best_score), last_round = excluded.last_round,
present = 1, has_stats = MAX(has_stats, excluded.has_stats),
completed = MAX(completed, excluded.completed), last_seen_at = excluded.last_seen_at`);
// Highest round includes late joins; total rounds only credits progression
// while present at both ends of an observed interval. Entering round 8
// completes round 7, but a player's first sighting at round 8 starts at zero.
const completedRounds = Math.max(0, round - 1);
for (const player of report.players) {
const prior = previous.get(player.guid);
const survived = !isStart && prior?.present ? Math.max(0, completedRounds - prior.last_round) : 0;
saveIdentity.run(identity.game, player.guid, player.name, report.receivedAt, report.receivedAt);
const stats = 'kills' in player ? player : null;
savePlayer.run(match.id, player.guid, round, survived,
stats?.kills ?? 0, stats?.headshots ?? 0, stats?.downs ?? 0, stats?.revives ?? 0, stats?.score ?? 0,
completedRounds, Number(!isStart), Number(report.event === 'game_end'), report.receivedAt, report.receivedAt);
}
handle.prepare(`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?),
last_received_at = ?, last_event = ?, last_fingerprint = ?,
status = CASE WHEN ? = 'game_end' THEN 'completed' ELSE status END,
ended_at = CASE WHEN ? = 'game_end' THEN ? ELSE ended_at END WHERE id = ?`)
.run(round, report.receivedAt, report.event, signature, report.event, report.event, report.receivedAt, match.id);
}
/** Seed the single surviving scoreboard per server once; older games are gone. */
export function backfillLeaderboard(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'leaderboard_v1'").get()) return;
handle.transaction(() => {
const reports = handle.prepare(`SELECT payload FROM server_telemetry WHERE payload IS NOT NULL ORDER BY received_at, server`)
.all() as Array<{ payload: string }>;
for (const row of reports) {
let report: GameTelemetry;
try {
const stored = JSON.parse(row.payload);
if (typeof stored.receivedAt !== 'string' || !Number.isFinite(Date.parse(stored.receivedAt))) continue;
report = { ...parseGameEvent(stored), receivedAt: stored.receivedAt };
} catch { continue; } // Legacy malformed snapshots cannot establish player records.
recordLeaderboardTelemetry(handle, report);
}
handle.prepare("INSERT INTO settings (key, value) VALUES ('leaderboard_v1', '1')").run();
})();
}
+58
View File
@@ -0,0 +1,58 @@
import { db } from '../db/index.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;
export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
return metrics.has(value);
}
/** Aggregate persisted per-match records; filtering a player keeps their actual rank. */
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
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 = ['mp.has_stats = 1'];
const parameters: string[] = [];
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); }
const scoped = `FROM leaderboard_match_players mp
JOIN leaderboard_matches m ON m.id = mp.match_id
WHERE ${conditions.join(' AND ')}`;
const aggregated = `WITH totals AS (
SELECT m.game, mp.guid, MAX(mp.highest_round) AS highestRound,
SUM(mp.kills) AS kills, SUM(mp.rounds_survived) AS roundsSurvived,
SUM(mp.headshots) AS headshots, SUM(mp.revives) AS revives, SUM(mp.downs) AS downs,
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 m.game, mp.guid
), ranked AS (
SELECT totals.*, p.name, RANK() OVER (ORDER BY totals.${metric} DESC) AS rank
FROM totals JOIN leaderboard_players p ON p.game = totals.game AND p.guid = totals.guid
)`;
const query = options.q?.trim() ?? '';
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
const searchedParameters = query ? [...parameters, query, query] : parameters;
const entries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search}
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, game, guid LIMIT ? OFFSET ?`)
.all(...searchedParameters, limit, offset) as LeaderboardEntry[];
const { total } = handle.prepare(`${aggregated} SELECT COUNT(*) AS total FROM ranked ${search}`)
.get(...searchedParameters) as { total: number };
const summary = handle.prepare(`SELECT
(SELECT COUNT(*) FROM (SELECT m.game, mp.guid ${scoped} GROUP BY m.game, mp.guid)) AS players,
COUNT(DISTINCT m.id) AS matches, COALESCE(SUM(mp.kills), 0) AS kills,
COALESCE(SUM(mp.rounds_survived), 0) AS roundsSurvived,
COALESCE(MAX(mp.highest_round), 0) AS highestRound,
MIN(m.started_at) AS firstTrackedAt, MAX(m.last_received_at) AS updatedAt
${scoped}`).get(...parameters, ...parameters) as LeaderboardResponse['summary'];
// Keep all recorded 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 ${options.game ? 'WHERE 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[];
return { entries, maps, total, limit, offset, metric, summary };
}
+19 -13
View File
@@ -1,5 +1,6 @@
import { db } from '../db/index.js';
import type { GameTelemetry } from '../../telemetry.js';
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
export function listTelemetry(): GameTelemetry[] {
const rows = db().prepare(`SELECT t.payload FROM server_telemetry t JOIN servers s ON s.name = t.server
@@ -8,17 +9,22 @@ export function listTelemetry(): GameTelemetry[] {
}
export function saveTelemetry(report: GameTelemetry): void {
db().prepare(`INSERT INTO server_telemetry (server, round, players, received_at, payload)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET
round = excluded.round, players = excluded.players, received_at = excluded.received_at, payload = excluded.payload`)
.run(report.server, report.event === 'game_start' ? 0 : report.round, report.players.length, report.receivedAt, JSON.stringify(report));
if (report.event === 'game_start') {
db().prepare('UPDATE servers SET game_started_at = ?, game_ended_at = NULL WHERE name = ?').run(report.receivedAt, report.server);
} else if (report.event === 'game_end') {
db().prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?').run(report.receivedAt, report.server);
// Legacy betas also take match completion directly from the script.
db().prepare(`UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND stage = 'awaiting-match'
AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`)
.run(report.receivedAt, report.server, report.receivedAt);
}
// Keep callers outside the HTTP route atomic too.
const handle = db();
handle.transaction(() => {
recordLeaderboardTelemetry(handle, report);
handle.prepare(`INSERT INTO server_telemetry (server, round, players, received_at, payload)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET
round = excluded.round, players = excluded.players, received_at = excluded.received_at, payload = excluded.payload`)
.run(report.server, report.event === 'game_start' ? 0 : report.round, report.players.length, report.receivedAt, JSON.stringify(report));
if (report.event === 'game_start') {
handle.prepare('UPDATE servers SET game_started_at = ?, game_ended_at = NULL WHERE name = ?').run(report.receivedAt, report.server);
} else if (report.event === 'game_end') {
handle.prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?').run(report.receivedAt, report.server);
// Legacy betas also take match completion directly from the script.
handle.prepare(`UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND stage = 'awaiting-match'
AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`)
.run(report.receivedAt, report.server, report.receivedAt);
}
})();
}
+3
View File
@@ -27,6 +27,7 @@ export function parseGameEvent(value: unknown): GameEvent {
if (typeof body[key] !== 'string' || !body[key].trim() || body[key].length > 255) throw new Error(`${key} must be a nonempty string (up to 255 characters)`);
}
if (!Array.isArray(body.players)) throw new Error('players must be an array');
const guids = new Set<string>();
const players = body.players.map((value, index) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`players[${index}] must be an object`);
const player = value as Record<string, unknown>;
@@ -34,6 +35,8 @@ export function parseGameEvent(value: unknown): GameEvent {
if (typeof player[key] !== 'string' || !player[key].trim()) throw new Error(`players[${index}].${key} must be a nonempty string`);
}
const identity = { name: player.name as string, guid: player.guid as string };
if (guids.has(identity.guid)) throw new Error(`players[${index}].guid must be unique within the report`);
guids.add(identity.guid);
if (body.event === 'game_start') return identity;
const stats = {} as Omit<TelemetryPlayerStats, keyof TelemetryPlayer>;
for (const key of ['kills', 'headshots', 'downs', 'revives', 'score'] as const) {
@@ -93,7 +93,10 @@
</table>
</div>
{/if}
<p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change and game end.'}</p>
<div class="scoreboard-footer">
<p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change and game end.'}</p>
<a href="/leaderboard">View lifetime leaderboard →</a>
</div>
{/if}
<style>
@@ -124,7 +127,10 @@
button span { color: var(--text-tertiary); }
button span.active, button:hover { color: var(--accent-primary-hover); }
button:focus-visible { outline: none; border-radius: var(--radius-sm); box-shadow: var(--focus-ring); }
.note { margin: var(--space-3) 0 0; font-size: var(--text-xs); color: var(--text-tertiary); }
.scoreboard-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-3); font-size: var(--text-xs); }
.scoreboard-footer a { text-decoration: none; }
.scoreboard-footer a:hover { text-decoration: underline; }
.note { margin: 0; font-size: var(--text-xs); color: var(--text-tertiary); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 640px) {
.report-meta { text-align: left; }
+3 -2
View File
@@ -38,7 +38,7 @@
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
const shortcuts: Record<string, string> = {
f: '/', d: '/deployments', a: '/campaigns', b: '/betas',
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins'
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard'
};
let armed = false;
let armTimer: ReturnType<typeof setTimeout> | null = null;
@@ -83,7 +83,8 @@
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
{ href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') }
{ href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') },
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') }
];
const secondary = $derived(tabs[1].match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { getLeaderboard, isLeaderboardMetric, MAX_LEADERBOARD_OFFSET } from '$lib/server/fleet/leaderboard.js';
export function GET({ url }: { url: URL }) {
const params = url.searchParams;
const game = params.get('game');
const map = params.get('map');
const metric = params.get('metric');
const q = params.get('q');
const rawLimit = params.get('limit');
const rawOffset = params.get('offset');
const integer = (value: string | null, fallback: number) => value === null ? fallback
: /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN;
const limit = integer(rawLimit, 50);
const offset = integer(rawOffset, 0);
if ((game !== null && game !== 't5' && game !== 't6')
|| (map !== null && map.length > 1024) || (q !== null && q.length > 100)
|| (metric !== null && !isLeaderboardMetric(metric))
|| !Number.isFinite(limit) || limit < 1 || limit > 100
|| !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) {
return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 });
}
return json(getLeaderboard({ game: game ?? undefined, map: map || undefined,
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
q: q ?? undefined, limit, offset }));
}
+317
View File
@@ -0,0 +1,317 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { leaderboardMetrics, type LeaderboardMetric, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
const number = new Intl.NumberFormat('en-US');
const pageSize = 50;
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 descriptions: Record<LeaderboardMetric, string> = {
highestRound: 'The highest round reached in a recorded game.',
kills: 'Cumulative kills across recorded games.',
roundsSurvived: 'Round progression during observed participation, added across games.',
headshots: 'Cumulative headshots across recorded games.',
revives: 'Cumulative teammates revived across recorded games.',
downs: 'Cumulative downs across recorded games, ranked from most to fewest.',
gamesPlayed: 'Games with recorded player stats, including games in progress.',
gamesCompleted: 'Games where the player appears in the final game-end report.',
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.'
};
let data = $state<LeaderboardResponse | null>(null);
let maps = $state<LeaderboardMap[]>([]);
let loading = $state(true);
let error = $state('');
let search = $state('');
let mounted = $state(false);
let checkedAt = $state<Date | null>(null);
let controller: AbortController | undefined;
let requestId = 0;
let loadedUrl = '';
let pendingUrl: URL | undefined;
let navigationId = 0;
const game = $derived(games.some(item => item.key === $page.url.searchParams.get('game')) ? $page.url.searchParams.get('game')! : '');
const map = $derived($page.url.searchParams.get('map') ?? '');
const query = $derived(($page.url.searchParams.get('q') ?? '').trim().slice(0, 100));
const metric = $derived(leaderboardMetrics.find(item => item.key === $page.url.searchParams.get('metric'))?.key ?? 'highestRound');
const offset = $derived(Math.max(0, Math.min(1_000_000, Math.floor(Number($page.url.searchParams.get('offset')) || 0))));
const filteredMaps = $derived(maps.filter(item => !game || item.game === game));
const mapName = $derived(maps.find(item => item.key === map)?.name);
const scope = $derived(mapName ?? (game ? games.find(item => item.key === game)!.label : 'All games & maps'));
const metricLabel = $derived(leaderboardMetrics.find(item => item.key === metric)!.label);
const columns = $derived([metric, ...defaultColumns.filter(key => key !== metric)]);
const apiUrl = $derived.by(() => {
const params = new URLSearchParams({ metric, limit: String(pageSize), offset: String(offset) });
if (game) params.set('game', game);
if (map) params.set('map', map);
if (query) params.set('q', query);
return `/api/leaderboard?${params}`;
});
$effect(() => { search = query; });
$effect(() => {
const url = apiUrl;
if (mounted) untrack(() => { void refresh(url); });
});
onMount(() => {
mounted = true;
const refreshVisible = () => { if (!document.hidden && !loading) void refresh(apiUrl); };
const interval = setInterval(refreshVisible, 15_000);
document.addEventListener('visibilitychange', refreshVisible);
return () => {
++requestId;
controller?.abort();
clearInterval(interval);
document.removeEventListener('visibilitychange', refreshVisible);
};
});
async function refresh(url: string) {
const id = ++requestId;
controller?.abort();
const activeController = new AbortController();
controller = activeController;
if (url !== loadedUrl) { data = null; checkedAt = null; }
loading = true;
error = '';
const timeout = setTimeout(() => activeController.abort(), 20_000);
try {
const response = await fetch(url, { signal: activeController.signal, cache: 'no-store' });
if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`);
const result: LeaderboardResponse = await response.json();
if (id !== requestId) return;
data = result;
maps = result.maps;
loadedUrl = url;
checkedAt = new Date();
} catch (cause) {
if (id !== requestId) return;
error = activeController.signal.aborted ? 'The leaderboard request timed out.' : cause instanceof Error ? cause.message : 'Unable to load the leaderboard.';
} finally {
clearTimeout(timeout);
if (id === requestId) loading = false;
}
}
function updateFilters(changes: Record<string, string>) {
// Keep rapid changes together while the preceding navigation settles.
const url = new URL(pendingUrl ?? $page.url);
url.searchParams.delete('offset');
for (const [key, value] of Object.entries(changes)) {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
}
pendingUrl = url;
const id = ++navigationId;
void goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true })
.catch(() => { error = 'Unable to update the leaderboard filters. Please try again.'; })
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
}
function clearFilters() {
search = '';
updateFilters({ game: '', map: '', q: '' });
}
</script>
<svelte:head>
<title>Leaderboard · Electric Cherry</title>
<meta name="description" content="Player records and lifetime Zombies stats across your Electric Cherry fleet." />
</svelte:head>
<section class="page">
<SectionHeader title="Leaderboard" eyebrow="Every round counts" subtitle="Personal bests and lifetime stats, across the fleet.">
{#snippet actions()}
<Button size="sm" loading={loading} onclick={() => void refresh(apiUrl)}>Refresh</Button>
{/snippet}
</SectionHeader>
<div class="filters">
<div class="field"><label for="leaderboard-game">Game</label>
<select id="leaderboard-game" value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: '' })}>
<option value="">All games</option>
{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select>
</div>
<div class="field"><label for="leaderboard-map">Map</label>
<select id="leaderboard-map" value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
<option value="">All maps</option>
{#if map && !filteredMaps.some(item => item.key === map)}<option value={map}>{map}</option>{/if}
{#each filteredMaps as item (item.key)}<option value={item.key}>{item.name}{!game ? ` · ${item.game.toUpperCase()}` : ''}</option>{/each}
</select>
</div>
<div class="field"><label for="leaderboard-metric">Rank by</label>
<select id="leaderboard-metric" value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}>
{#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select>
</div>
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); }} role="search">
<label for="leaderboard-search">Find a player</label>
<div><input id="leaderboard-search" type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" /><Button type="submit" size="sm">Search</Button></div>
</form>
</div>
<div class="scope-row">
<div><span class="scope">{scope}</span><span class="all-time">All time</span></div>
{#if game || map || query}<button class="text-button" onclick={clearFilters}>Clear filters</button>{/if}
</div>
{#if error}
<div class="error" role="alert"><span>{error}{data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => void refresh(apiUrl)}>Try again</Button></div>
{/if}
{#if data}
<div class="overview" aria-label="Totals for the selected game and map">
<div class="record"><span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong><small>Fleet record</small></div>
<div><span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small>Across all players</small></div>
<div><span>Total rounds</span><strong>{number.format(data.summary.roundsSurvived)}</strong><small>Total player rounds</small></div>
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Includes active games</small></div>
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Across tracked games</small></div>
</div>
<section class="rankings" aria-labelledby="rankings-title" aria-busy={loading}>
<div class="rankings-header">
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${checkedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' })}` : ''}<span>Refreshes every 15 seconds</span></div>
</div>
{#if data.entries.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
<div class="table-scroll" role="region" aria-label="Player rankings" tabindex="0">
<table>
<caption class="sr-only">{scope}: players ranked by {metricLabel.toLowerCase()}, highest first</caption>
<thead><tr>
<th scope="col" class="rank">Rank</th>
<th scope="col" class="player">Player</th>
{#each columns as key (key)}
<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>
</th>
{/each}
</tr></thead>
<tbody>
{#each data.entries as entry (`${entry.game}:${entry.guid}`)}
<tr>
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
<th scope="row" class="player">
<span class="player-name">{entry.name}</span>
<div class="identity"><span class="game-label">{entry.game.toUpperCase()}</span><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
</th>
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<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>
<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>
{:else if query || game || map || offset}
<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}
</EmptyState>
{:else}
<EmptyState spacious title="Your first records start here" description="Player stats will appear as your servers report games and rounds. Active games count toward the leaderboard." />
{/if}
</section>
{:else if loading}
<div class="loading" role="status">Loading player records…</div>
{/if}
<details class="tracking-notes">
<summary>How these records work</summary>
<div>
<p>Records include active games and completed games. Filters apply to totals and rankings; player search only filters the rankings. Players are identified by their GUID within each game, so name changes keep their records together.</p>
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{new Date(data.summary.firstTrackedAt).toLocaleString()}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{new Date(data.summary.updatedAt).toLocaleString()}</time>.{/if}</p>
</div>
</details>
</section>
<style>
.page { display: flex; flex-direction: column; gap: var(--space-5); }
.filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); padding: var(--space-5); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
label { color: var(--text-secondary); font-size: var(--text-xs); }
input, select, button { font: inherit; }
input, select { min-height: 42px; width: 100%; padding: 9px 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); font-size: var(--text-sm); }
input::placeholder { color: var(--text-tertiary); }
.search > div { display: flex; gap: var(--space-2); }
.search input { flex: 1; min-width: 0; width: 0; }
.search :global(button) { flex-shrink: 0; white-space: nowrap; }
.scope-row, .scope-row > div { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.scope-row { justify-content: space-between; }
.scope { font-weight: var(--weight-semibold); }
.all-time { font-size: var(--text-xs); color: var(--text-tertiary); padding-left: var(--space-3); border-left: 1px solid var(--border-strong); }
.text-button { border: 0; background: transparent; color: var(--accent-primary-hover); cursor: pointer; padding: 4px 0; font-size: var(--text-xs); }
.overview { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.overview > div { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-2); border-right: 1px solid var(--border-subtle); }
.overview > div:last-child { border-right: 0; }
.overview span, .overview small { color: var(--text-tertiary); font-size: var(--text-xs); }
.overview strong { font-size: clamp(21px, 2.4vw, 32px); line-height: 1.2; font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; letter-spacing: -.035em; overflow-wrap: anywhere; }
.overview .record { background: linear-gradient(125deg, color-mix(in srgb, var(--accent-primary) 15%, transparent), transparent); }
.record strong { color: var(--accent-primary-hover); }
.rankings { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.rankings-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); padding: var(--space-5); }
h2 { margin: 0 0 var(--space-1); font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
.rankings-header p { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); max-width: 70ch; }
.refresh-status { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; text-align: right; }
.refresh-status span { display: block; margin-top: 4px; color: var(--text-tertiary); }
.table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); }
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
thead th { background: var(--surface-sunken); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
.rank { width: 65px; text-align: center; color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.rank span { display: inline-grid; min-width: 30px; height: 30px; place-items: center; }
.rank .podium { border-radius: var(--radius-sm); color: var(--accent-primary-hover); background: color-mix(in srgb, var(--accent-primary) 12%, transparent); font-weight: var(--weight-semibold); }
.player { position: sticky; left: 0; z-index: 1; min-width: 225px; max-width: 320px; text-align: left; }
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
.player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; }
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
.game-label { padding-right: var(--space-2); border-right: 1px solid var(--border-strong); }
.stat { text-align: right; white-space: nowrap; }
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.stat.selected { color: var(--accent-primary-hover); }
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:hover { color: var(--accent-primary-hover); }
tbody tr:hover > * { background: var(--surface-raised); }
tbody tr:last-child > * { border-bottom: 0; }
.pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); color: var(--text-tertiary); font-size: var(--text-xs); }
.pagination > div { display: flex; gap: var(--space-2); }
.error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--state-warning) 45%, transparent); border-radius: var(--radius-md); font-size: var(--text-sm); }
.loading { padding: var(--space-12) var(--space-5); background: var(--surface); border-radius: var(--radius-lg); text-align: center; color: var(--text-secondary); }
.tracking-notes { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
.tracking-notes summary { display: list-item; width: fit-content; cursor: pointer; color: var(--text-secondary); }
.tracking-notes > div { max-width: 100ch; }
.tracking-notes p { margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .overview > div { padding: var(--space-4); } .overview small { display: none; } }
@media (max-width: 640px) {
.page { gap: var(--space-4); }
.filters { padding: var(--space-4); gap: var(--space-3); }
.search { grid-column: 1 / -1; }
.filters > .field:nth-child(3) { grid-column: 1 / -1; }
.overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.overview > div { border-bottom: 1px solid var(--border-subtle); }
.overview > div:nth-child(even) { border-right: 0; }
.overview > div:last-child { grid-column: 1 / -1; border-bottom: 0; }
.overview strong { font-size: var(--text-xl); }
.rankings-header { flex-direction: column; align-items: flex-start; padding: var(--space-4); }
.refresh-status { text-align: left; }
.refresh-status span { display: inline; margin-left: var(--space-2); }
.player { min-width: 180px; max-width: 220px; }
th, td { padding: var(--space-3); }
.pagination { padding: var(--space-4); }
}
</style>