From a182aeb9507b747d31143812af35b88dd41f566e Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 18 Sep 2026 16:43:44 -0400 Subject: [PATCH] Add persistent player leaderboards from game telemetry --- README.md | 5 + docs/beta-test-plans.md | 5 + docs/leaderboard.md | 81 +++++ src/lib/leaderboard.ts | 66 ++++ src/lib/server/db/index.ts | 2 + src/lib/server/db/schema.sql | 50 +++ src/lib/server/fleet/leaderboard-ingest.ts | 137 ++++++++ src/lib/server/fleet/leaderboard.ts | 58 ++++ src/lib/server/fleet/telemetry.ts | 32 +- src/lib/telemetry.ts | 3 + src/lib/ui/components/PlayerScoreboard.svelte | 10 +- src/routes/+layout.svelte | 5 +- src/routes/api/leaderboard/+server.ts | 26 ++ src/routes/leaderboard/+page.svelte | 317 ++++++++++++++++++ tests/unit/leaderboard-ingest.test.ts | 209 ++++++++++++ tests/unit/leaderboard-query.test.ts | 146 ++++++++ 16 files changed, 1135 insertions(+), 17 deletions(-) create mode 100644 docs/leaderboard.md create mode 100644 src/lib/leaderboard.ts create mode 100644 src/lib/server/fleet/leaderboard-ingest.ts create mode 100644 src/lib/server/fleet/leaderboard.ts create mode 100644 src/routes/api/leaderboard/+server.ts create mode 100644 src/routes/leaderboard/+page.svelte create mode 100644 tests/unit/leaderboard-ingest.test.ts create mode 100644 tests/unit/leaderboard-query.test.ts diff --git a/README.md b/README.md index a0cc1b8..156b739 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,11 @@ See [Game reporter protocol](docs/beta-test-plans.md#game-reporter-protocol) for complete payload examples. Telemetry does not replace RCON's empty-server check before an automated restart. +The **Leaderboard** records persistent player totals and map records from these +same reports, including highest round, cumulative kills, rounds survived, +headshots, revives, downs, and games played. See [Leaderboard](docs/leaderboard.md) +for counting rules, history import, and the read API. + ## Running tests ```bash diff --git a/docs/beta-test-plans.md b/docs/beta-test-plans.md index 5eb8bf0..64e112c 100644 --- a/docs/beta-test-plans.md +++ b/docs/beta-test-plans.md @@ -19,6 +19,7 @@ There are exactly three events. Send them whether or not a beta is active. current map. `players` is the full player array, including an empty array when there are no players. Names and GUIDs are strings; keep GUIDs as strings to preserve their precision. Round and stat values are nonnegative JSON integers. +Each GUID must appear only once within a report's player array. ### Game start @@ -86,6 +87,10 @@ an end closes the match. Receipt order determines the latest report. This small protocol does not attempt to identify delayed events from earlier games or provide exactly-once delivery across games. +Reports also feed the persistent [Leaderboard](leaderboard.md), independently +of beta activity. That page documents cumulative stat accounting, round credit, +and the limits of identifying matches from this protocol. + The game scripts are uploaded artifacts, not source maintained in this repository. Their reporter should emit the three payloads above. diff --git a/docs/leaderboard.md b/docs/leaderboard.md new file mode 100644 index 0000000..f409d13 --- /dev/null +++ b/docs/leaderboard.md @@ -0,0 +1,81 @@ +# Leaderboard + +Open **Leaderboard** in the main navigation to compare players across the fleet, +or filter by game and map. Choose a ranking, search a name or GUID, and share the +page URL with its filters intact. Results refresh while the page is visible. +Active games contribute as soon as a round report arrives. + +The leaderboard uses the existing three telemetry events; no reporter change is +required. Each player is identified by **game + GUID**, with their latest reported +name displayed. Different names for the same GUID do not split their records; +identical names with different GUIDs remain separate. GUIDs stay strings. + +## Recorded statistics + +| Statistic | Meaning | +| --- | --- | +| Highest round | Highest round reported with that player present, including players who joined mid-game. | +| Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. | +| Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. | +| Games played | Games in which at least one stats report contains the player. A start-only roster does not count yet. | +| Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. | +| Best game kills | Highest kill count recorded in any one game. | +| Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. | + +Every ranking sorts highest first, including downs (a count, not an efficiency +rating). Equal values share a rank; name and GUID provide a stable display order. +Searching preserves the player's rank within the selected game/map. Summary +cards describe that game/map, regardless of the search text. Summary rounds are +player-rounds: four players progressing from game start to round 8 contribute 28. +Highest-round records always use the round reached, including the final round +and players who joined mid-game. + +Round totals require the player to appear in consecutive observations. If a +report omits them, their return starts a new participation interval and the gap +is not credited. The payload cannot tell whether a present player was alive, +spectating, or absent between reports. Mid-game snapshots still contribute their +reported kills and other counters, even when earlier participation is unknown. + +## Persistence and match boundaries + +Match and player records live in SQLite alongside the rest of Electric Cherry. +They persist across app restarts and server deletion, and are not subject to +rollout history retention. The latest scoreboard and leaderboard update in the +same transaction. Each match keeps player counters even after someone leaves. + +A `game_start` opens a match; `game_end` completes it. A changed map, a lower round, +or a round report after a completed game can also establish a new match when the +start was missed. Superseded active matches remain in history as interrupted. +Repeated starts before any stats belong to the same match. Identical consecutive +payloads (ignoring receipt time and roster order) do not add records. Repeated +snapshots in a match cannot increase counters or rounds twice. + +The first upgrade imports the latest stored full scoreboard from each server +once. That provides the available counters and highest round, but not discarded +earlier games or unobserved round progression. Old round-only snapshots have no +player identity and cannot be imported. Tracking continues from future reports. + +T6 selections sharing an engine map, such as Town Survival and TranZit, remain +separate using the server's registered map variant. Unknown variants remain +explicitly identified instead of being labeled as a known selection. A match +retains the game/map identity recorded at ingestion even if the server is later +reconfigured or removed. + +The current protocol sends events in gameplay order and has no match ID, event +ID, or source sequence. A late report from an older game cannot be reliably +distinguished from a new game. Likewise, identical starts before any stats are +indistinguishable from retries. Send reports once in order. Within a match, +counter decreases use the previous high-water mark; if a reconnect resets a +player's counters, subsequent activity may be undercounted until those counters +pass their earlier maxima. Exact replay handling and counter-reset accounting +would require additional reporter identity/ordering information. + +## Read API + +`GET /api/leaderboard` accepts optional `game` (`t5` or `t6`), `map` (the opaque +key returned in `maps`), `metric` (default `highestRound`), `q` (name/GUID search), +`limit` (1–100, default 50), and `offset` (default 0). It returns ranked `entries`, +available `maps`, the matching player `total`, pagination values, and `summary`. + +Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`, +`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, and `bestScore`. diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts new file mode 100644 index 0000000..7b770fe --- /dev/null +++ b/src/lib/leaderboard.ts @@ -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; + }; +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index ff33da7..10123a8 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index ed78c2e..c0bbe1a 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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 diff --git a/src/lib/server/fleet/leaderboard-ingest.ts b/src/lib/server/fleet/leaderboard-ingest.ts new file mode 100644 index 0000000..b4cf413 --- /dev/null +++ b/src/lib/server/fleet/leaderboard-ingest.ts @@ -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(); + })(); +} diff --git a/src/lib/server/fleet/leaderboard.ts b/src/lib/server/fleet/leaderboard.ts new file mode 100644 index 0000000..01c642a --- /dev/null +++ b/src/lib/server/fleet/leaderboard.ts @@ -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(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 }; +} diff --git a/src/lib/server/fleet/telemetry.ts b/src/lib/server/fleet/telemetry.ts index 26e8099..3315b51 100644 --- a/src/lib/server/fleet/telemetry.ts +++ b/src/lib/server/fleet/telemetry.ts @@ -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); + } + })(); } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 90a707f..58cc6b8 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -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(); 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; @@ -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; for (const key of ['kills', 'headshots', 'downs', 'revives', 'score'] as const) { diff --git a/src/lib/ui/components/PlayerScoreboard.svelte b/src/lib/ui/components/PlayerScoreboard.svelte index 91f430c..7632719 100644 --- a/src/lib/ui/components/PlayerScoreboard.svelte +++ b/src/lib/ui/components/PlayerScoreboard.svelte @@ -93,7 +93,10 @@ {/if} -

{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.'}

+ {/if} diff --git a/tests/unit/leaderboard-ingest.test.ts b/tests/unit/leaderboard-ingest.test.ts new file mode 100644 index 0000000..ed092ed --- /dev/null +++ b/tests/unit/leaderboard-ingest.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +let received = 0; +const server = 't5_zombie_theater_1'; +const player = (guid = '2741626', stats: Partial = {}): TelemetryPlayerStats => ({ + guid, name: 'JoshWright', kills: 0, headshots: 0, downs: 0, revives: 0, score: 0, ...stats +}); + +beforeEach(async () => { + vi.resetModules(); + received = 0; + fixture = await newFixture([server, 't6_zombie_town_1', 't6_zombie_farm_1']); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); +}); +afterEach(async () => fixture.cleanup()); + +async function report(event: GameEvent['event'], round = 0, players = [player()], source = server, map = 'zombie_theater') { + const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); + const receivedAt = new Date(Date.UTC(2026, 8, 18, 20, 0, received++)).toISOString(); + saveTelemetry(event === 'game_start' + ? { event, server: source, map, players: players.map(({ name, guid }) => ({ name, guid })), receivedAt } + : { event, server: source, map, round, players, receivedAt }); +} + +async function matches() { + const { db } = await import('../../src/lib/server/db/index.js'); + return db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all() as Array<{ + id: number; game: string; map: string; map_key: string; map_name: string; + status: string; highest_round: number; start_observed: number; + }>; +} + +async function records(guid = '2741626') { + const { db } = await import('../../src/lib/server/db/index.js'); + return db().prepare('SELECT * FROM leaderboard_match_players WHERE guid = ? ORDER BY match_id').all(guid) as Array<{ + match_id: number; guid: string; highest_round: number; rounds_survived: number; + kills: number; headshots: number; downs: number; revives: number; best_score: number; + completed: number; present: number; + }>; +} + +it('persists independent stat high-water marks without adding cumulative snapshots or retries', async () => { + await report('game_start'); + await report('round_change', 1); + const first = player(undefined, { kills: 100, headshots: 60, downs: 2, revives: 3, score: 5000 }); + await report('round_change', 3, [first]); + await report('round_change', 3, [first]); + await report('round_change', 4, [player(undefined, { + kills: 120, headshots: 55, downs: 1, revives: 5, score: 1000 + })]); + await report('game_end', 4, [player(undefined, { + kills: 125, headshots: 62, downs: 3, revives: 5, score: 2000 + })]); + expect(await matches()).toHaveLength(1); + expect(await records()).toEqual([expect.objectContaining({ + highest_round: 4, rounds_survived: 3, kills: 125, headshots: 62, + downs: 3, revives: 5, best_score: 5000, completed: 1 + })]); +}); + +it('keeps a latest player identity across games on different servers without merging different game GUIDs', async () => { + await report('round_change', 8, [player(undefined, { kills: 111 })]); + await report('game_end', 8, [player(undefined, { kills: 120, name: 'New name' })]); + await report('game_start'); + await report('game_end', 2, [player(undefined, { kills: 20, name: 'Latest name' })]); + await report('game_end', 5, [player(undefined, { kills: 200, name: 'Other game' })], 't6_zombie_town_1', 'zm_transit'); + const { db } = await import('../../src/lib/server/db/index.js'); + expect(db().prepare('SELECT game, guid, name FROM leaderboard_players ORDER BY game').all()).toEqual([ + { game: 't5', guid: '2741626', name: 'Latest name' }, + { game: 't6', guid: '2741626', name: 'Other game' } + ]); + expect((await records()).map(row => row.kills)).toEqual([120, 20, 200]); + expect(db().prepare(`SELECT SUM(p.rounds_survived) AS total FROM leaderboard_match_players p + JOIN leaderboard_matches m ON m.id = p.match_id WHERE m.game = 't5' AND p.guid = ?`).get('2741626')) + .toEqual({ total: 1 }); +}); + +it('credits the highest round but only observed survival for a late join or a return after an absent roster', async () => { + await report('game_start'); + await report('round_change', 2); + await report('round_change', 3, []); + const late = player('4229696', { name: 'Late player' }); + await report('round_change', 5, [player(), late]); + await report('round_change', 6, [late, player()]); + await report('game_end', 6, [player()]); + expect(await records()).toEqual([expect.objectContaining({ + highest_round: 6, rounds_survived: 2, completed: 1 + })]); + expect(await records(late.guid)).toEqual([expect.objectContaining({ + highest_round: 6, rounds_survived: 1, completed: 0, present: 0 + })]); +}); + +it('credits the highest round without inventing survival before the first midgame snapshot', async () => { + await report('round_change', 8, [player(undefined, { kills: 111 })]); + expect(await records()).toEqual([expect.objectContaining({ highest_round: 8, rounds_survived: 0, kills: 111 })]); + await report('round_change', 10, [player(undefined, { kills: 160 })]); + await report('game_end', 10, [player(undefined, { kills: 170 })]); + expect(await records()).toEqual([expect.objectContaining({ highest_round: 10, rounds_survived: 2, completed: 1 })]); + expect(await matches()).toEqual([expect.objectContaining({ start_observed: 0, status: 'completed' })]); +}); + +it('keeps disconnected player records while completing only the final roster', async () => { + const other = player('4229696', { kills: 40, name: 'Other player' }); + await report('game_start', 0, [player(), other]); + await report('round_change', 4, [player(undefined, { kills: 80 }), other]); + await report('game_end', 5, [player(undefined, { kills: 100 })]); + expect(await records(other.guid)).toEqual([expect.objectContaining({ kills: 40, highest_round: 4, rounds_survived: 3, completed: 0 })]); + expect(await records()).toEqual([expect.objectContaining({ kills: 100, highest_round: 5, rounds_survived: 4, completed: 1 })]); +}); + +it('starts separate matches for game starts, map changes, round resets, and new rounds after an end', async () => { + await report('game_start'); + await report('round_change', 5, [player(undefined, { kills: 50 })]); + await report('game_start'); + await report('round_change', 2, [player(undefined, { kills: 20 })]); + await report('round_change', 3, [player(undefined, { kills: 30 })], server, 'zombie_moon'); + await report('round_change', 1, [player(undefined, { kills: 10 })], server, 'zombie_moon'); + await report('game_end', 1, [player(undefined, { kills: 15 })], server, 'zombie_moon'); + await report('round_change', 1, [player(undefined, { kills: 5 })], server, 'zombie_moon'); + expect((await matches()).map(row => [row.status, row.highest_round])).toEqual([ + ['interrupted', 5], ['interrupted', 2], ['interrupted', 3], ['completed', 1], ['active', 1] + ]); + expect((await records()).map(row => row.kills)).toEqual([50, 20, 30, 15, 5]); +}); + +it('deduplicates consecutive start and end retries even with new receipt timestamps and reordered players', async () => { + const other = player('4229696'); + await report('game_start', 0, [player(), other]); + await report('game_start', 0, [other, player()]); + await report('round_change', 3, [player(), other]); + await report('game_end', 3, [player(), other]); + await report('game_end', 3, [other, player()]); + expect(await matches()).toHaveLength(1); + expect(await records()).toEqual([expect.objectContaining({ rounds_survived: 2, completed: 1 })]); +}); + +it('rejects a GUID repeated in the same telemetry roster before it can double-count participation', async () => { + const { parseGameEvent } = await import('../../src/lib/telemetry.js'); + const snapshot = player(undefined, { kills: 100, headshots: 50 }); + for (const event of ['game_start', 'round_change', 'game_end']) { + expect(() => parseGameEvent({ event, server, map: 'zombie_theater', round: 5, players: [snapshot, snapshot] })) + .toThrow(/guid must be unique/); + } +}); + +it('keeps T6 location variants distinct when they share the same engine map', async () => { + await report('game_end', 8, [player()], 't6_zombie_town_1', 'zm_transit'); + await report('game_end', 12, [player()], 't6_zombie_farm_1', 'zm_transit'); + const rows = await matches(); + expect(rows.map(row => row.game)).toEqual(['t6', 't6']); + expect(rows.map(row => row.map)).toEqual(['zm_transit', 'zm_transit']); + expect(new Set(rows.map(row => row.map_key)).size).toBe(2); + expect(rows.map(row => row.map_name)).toEqual(['Town Survival', 'Farm Survival']); +}); + +it('retains historical records across app restarts and server removal', async () => { + await report('game_start'); + await report('round_change', 4, [player(undefined, { kills: 100 })]); + const { db, closeDb } = await import('../../src/lib/server/db/index.js'); + closeDb(); + await report('game_end', 5, [player(undefined, { kills: 150 })]); + db().prepare('DELETE FROM servers WHERE name = ?').run(server); + closeDb(); + expect(await records()).toEqual([expect.objectContaining({ kills: 150, rounds_survived: 4, completed: 1 })]); + expect(await matches()).toHaveLength(1); +}); + +it('backfills valid saved telemetry once while skipping malformed and legacy snapshots', async () => { + const { db, closeDb } = await import('../../src/lib/server/db/index.js'); + const receivedAt = '2026-09-18T20:01:39.729Z'; + const saved = { event: 'round_change', server, map: 'zombie_theater', round: 8, + players: [player(undefined, { kills: 111, headshots: 61 })], receivedAt }; + const insert = db().prepare('INSERT INTO server_telemetry (server, round, players, received_at, payload) VALUES (?, ?, ?, ?, ?)'); + insert.run(server, 8, 1, receivedAt, JSON.stringify(saved)); + insert.run('t6_zombie_town_1', 99, 4, receivedAt, '{malformed'); + insert.run('t6_zombie_farm_1', 99, 4, receivedAt, null); + db().prepare("DELETE FROM settings WHERE key = 'leaderboard_v1'").run(); + closeDb(); + expect(await matches()).toEqual([expect.objectContaining({ highest_round: 8, start_observed: 0 })]); + expect(await records()).toEqual([expect.objectContaining({ kills: 111, headshots: 61, rounds_survived: 0 })]); + expect(db().prepare("SELECT value FROM settings WHERE key = 'leaderboard_v1'").get()).toEqual({ value: '1' }); + closeDb(); + await report('round_change', 9, [player(undefined, { kills: 150, headshots: 80 })]); + closeDb(); + expect(await matches()).toHaveLength(1); + expect(await records()).toEqual([expect.objectContaining({ kills: 150, headshots: 80, rounds_survived: 1 })]); +}); + +it('does not fabricate players for an empty match', async () => { + await report('game_start', 0, []); + await report('game_end', 0, []); + expect(await records()).toEqual([]); + expect(await matches()).toEqual([expect.objectContaining({ highest_round: 0, status: 'completed' })]); +}); + +it('rolls back history if writing the latest scoreboard fails', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + db().exec(`CREATE TRIGGER reject_scoreboard BEFORE INSERT ON server_telemetry + BEGIN SELECT RAISE(ABORT, 'scoreboard write failed'); END`); + await expect(report('round_change', 8, [player(undefined, { kills: 111 })])).rejects.toThrow('scoreboard write failed'); + expect(await records()).toEqual([]); + expect(await matches()).toEqual([]); + expect(db().prepare('SELECT COUNT(*) AS count FROM leaderboard_players').get()).toEqual({ count: 0 }); +}); diff --git a/tests/unit/leaderboard-query.test.ts b/tests/unit/leaderboard-query.test.ts new file mode 100644 index 0000000..65c82e2 --- /dev/null +++ b/tests/unit/leaderboard-query.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; +import type { LeaderboardResponse } from '../../src/lib/leaderboard.js'; + +let fixture: Fixture; +beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); +afterEach(async () => { await fixture.cleanup(); }); + +async function seedRecords() { + const { db } = await import('../../src/lib/server/db/index.js'); + const handle = db(); + const match = handle.prepare(`INSERT INTO leaderboard_matches + (id, server, game, map, map_key, map_name, started_at, last_received_at, status, last_event, last_fingerprint) + VALUES (?, 'removed-server', ?, ?, ?, ?, ?, ?, ?, 'round_change', 'test')`); + const player = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at) + VALUES (?, ?, ?, '2026-09-01T00:00:00.000Z', '2026-09-05T00:00:00.000Z')`); + const record = handle.prepare(`INSERT INTO leaderboard_match_players + (match_id, guid, kills, highest_round, rounds_survived, headshots, revives, downs, best_score, + completed, has_stats, first_seen_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`); + const iso = (day: number) => `2026-09-0${day}T00:00:00.000Z`; + match.run(1, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(1), iso(2), 'completed'); + match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'active'); + match.run(3, 't5', 'zombie_coast', 't5:coast', 'Call of the Dead', iso(3), iso(4), 'completed'); + match.run(4, 't6', 'zm_transit', 't6:town', 'Town Survival', iso(4), iso(5), 'completed'); + match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'interrupted'); + match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'active'); + player.run('t5', '101', 'Ace'); + player.run('t5', '202', 'Blake'); + player.run('t5', '303', 'Casey'); + player.run('t5', '404', 'No stats'); + player.run('t6', '101', 'Other game'); + player.run('t6', '505', 'Eve'); + record.run(1, '101', 100, 10, 9, 50, 2, 1, 1000, 1, 1, iso(2)); + record.run(1, '202', 100, 10, 9, 40, 3, 2, 2000, 1, 1, iso(2)); + record.run(2, '101', 150, 8, 7, 60, 1, 2, 3000, 0, 1, iso(3)); + record.run(2, '303', 70, 8, 7, 20, 0, 0, 1200, 0, 1, iso(3)); + record.run(3, '101', 20, 4, 3, 10, 1, 0, 500, 1, 1, iso(4)); + record.run(3, '404', 9999, 99, 99, 999, 99, 99, 99999, 1, 0, iso(4)); + record.run(4, '101', 500, 20, 19, 200, 4, 1, 5000, 1, 1, iso(5)); + record.run(5, '505', 200, 15, 14, 80, 0, 3, 3500, 0, 1, iso(6)); + record.run(6, '404', 9999, 99, 99, 999, 99, 99, 99999, 1, 0, iso(7)); + return handle; +} + +describe('persisted leaderboard queries', () => { + it('sums counters across matches, retains personal bests and separates games for the same GUID', async () => { + await seedRecords(); + const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); + const board = getLeaderboard(); + expect(board.entries.find(entry => entry.game === 't5' && entry.guid === '101')).toEqual({ + rank: 3, game: 't5', guid: '101', name: 'Ace', highestRound: 10, kills: 270, + roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, + gamesPlayed: 3, gamesCompleted: 2, bestKills: 150, bestScore: 3000, + lastSeen: '2026-09-04T00:00:00.000Z' + }); + expect(board.entries.find(entry => entry.game === 't6' && entry.guid === '101')) + .toMatchObject({ name: 'Other game', kills: 500, gamesPlayed: 1, rank: 1 }); + expect(board.entries.some(entry => entry.guid === '404')).toBe(false); + expect(board.summary).toEqual({ players: 5, matches: 5, kills: 1140, roundsSurvived: 68, + highestRound: 20, firstTrackedAt: '2026-09-01T00:00:00.000Z', updatedAt: '2026-09-06T00:00:00.000Z' }); + }); + + it('filters exact map variants and retains historical and custom map options independently of selection', async () => { + await seedRecords(); + const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); + const theater = getLeaderboard({ game: 't5', map: 't5:theater', metric: 'kills' }); + expect(theater.entries.map(entry => [entry.name, entry.kills])).toEqual([['Ace', 250], ['Blake', 100], ['Casey', 70]]); + expect(theater.summary).toMatchObject({ players: 3, matches: 2, kills: 420, roundsSurvived: 32, highestRound: 10 }); + expect(theater.maps.map(map => map.key)).toEqual(['t5:coast', 't5:custom_map', 't5:theater']); + const town = getLeaderboard({ map: 't6:town' }); + expect(town.entries.map(entry => entry.name)).toEqual(['Other game']); + expect(town.maps).toContainEqual({ key: 't6:farm', game: 't6', map: 'zm_transit', name: 'Farm Survival' }); + }); + + it('uses competition ranks with deterministic ties and searches after ranking before pagination', async () => { + await seedRecords(); + const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); + const board = getLeaderboard({ game: 't5' }); + expect(board.entries.map(entry => [entry.name, entry.rank])).toEqual([['Ace', 1], ['Blake', 1], ['Casey', 3]]); + const search = getLeaderboard({ game: 't5', q: 'CASE', limit: 1 }); + expect(search.entries.map(entry => [entry.name, entry.rank])).toEqual([['Casey', 3]]); + expect(search.total).toBe(1); + expect(search.summary).toEqual(board.summary); + expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0]) + .toMatchObject({ name: 'Blake', rank: 2 }); + expect(getLeaderboard({ q: '303' }).entries[0]).toMatchObject({ name: 'Casey', rank: 5 }); + }); + + it('treats SQL and LIKE punctuation as literal input and allows only known ranking columns', async () => { + const handle = await seedRecords(); + handle.prepare("UPDATE leaderboard_players SET name = '100%_Blake' WHERE guid = '202'").run(); + const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); + expect(getLeaderboard({ q: '%' }).entries.map(entry => entry.guid)).toEqual(['202']); + expect(getLeaderboard({ map: "' OR 1=1 --" }).entries).toEqual([]); + expect(getLeaderboard({ q: "' OR 1=1 --" }).entries).toEqual([]); + expect(getLeaderboard({ metric: 'kills; DROP TABLE leaderboard_players' as never }).metric).toBe('highestRound'); + expect(getLeaderboard({ metric: 'downs', game: 't5' }).entries.map(entry => entry.downs)).toEqual([3, 2, 0]); + }); + + it('returns an empty summary and bounds pagination for direct callers', async () => { + const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); + expect(getLeaderboard()).toEqual({ entries: [], maps: [], total: 0, limit: 50, offset: 0, metric: 'highestRound', + summary: { players: 0, matches: 0, kills: 0, roundsSurvived: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } }); + expect(getLeaderboard({ limit: 1000, offset: -1 })).toMatchObject({ limit: 100, offset: 0 }); + expect(getLeaderboard({ limit: NaN, offset: 2_000_000 })).toMatchObject({ limit: 50, offset: 1_000_000 }); + }); +}); + +describe('leaderboard API', () => { + it('returns the requested ranking and pagination', async () => { + await seedRecords(); + const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); + const response = GET({ url: new URL('http://localhost/api/leaderboard?game=t5&map=t5%3Atheater&metric=kills&limit=1&offset=1') }); + expect(response.status).toBe(200); + const result = await response.json() as LeaderboardResponse; + expect(result).toMatchObject({ metric: 'kills', limit: 1, offset: 1, total: 3 }); + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ guid: '202', rank: 2, kills: 100 }); + }); + + it('rejects invalid filters and non-integer or out-of-range pagination', async () => { + const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); + for (const query of ['game=t7', 'game=', 'metric=unknown', 'limit=0', 'limit=101', 'limit=', + 'limit=1.5', 'limit=1e1', 'offset=-1', 'offset=1000001', 'offset=Infinity', 'offset=0.5', + `q=${'a'.repeat(101)}`, `map=${'a'.repeat(1025)}`]) { + const response = GET({ url: new URL(`http://localhost/api/leaderboard?${query}`) }); + expect(response.status, query).toBe(400); + expect(await response.json()).toMatchObject({ ok: false, kind: 'bad-request' }); + } + }); + + it('accepts returned opaque map keys longer than the telemetry map name limit', async () => { + const handle = await seedRecords(); + const map = 'a'.repeat(255); + const key = `t5:${map}`; + handle.prepare('UPDATE leaderboard_matches SET map = ?, map_key = ?, map_name = ? WHERE id = 1') + .run(map, key, map); + const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); + const all = await GET({ url: new URL('http://localhost/api/leaderboard') }).json() as LeaderboardResponse; + const returnedKey = all.maps.find(option => option.map === map)!.key; + const response = GET({ url: new URL(`http://localhost/api/leaderboard?map=${encodeURIComponent(returnedKey)}`) }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ total: 2, summary: { matches: 1, kills: 200 } }); + }); +});