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
+5
View File
@@ -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
+5
View File
@@ -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.
+81
View File
@@ -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`.
+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 };
}
+10 -4
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)
// 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') {
db().prepare('UPDATE servers SET game_started_at = ?, game_ended_at = NULL WHERE name = ?').run(report.receivedAt, report.server);
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') {
db().prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?').run(report.receivedAt, report.server);
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.
db().prepare(`UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND stage = 'awaiting-match'
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}
<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>
+209
View File
@@ -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> = {}): 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 });
});
+146
View File
@@ -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 } });
});
});