Add persistent player leaderboards from game telemetry
This commit is contained in:
@@ -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
|
complete payload examples. Telemetry does not replace RCON's empty-server
|
||||||
check before an automated restart.
|
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
|
## Running tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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
|
### 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
|
protocol does not attempt to identify delayed events from earlier games or
|
||||||
provide exactly-once delivery across games.
|
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
|
The game scripts are uploaded artifacts, not source maintained in this
|
||||||
repository. Their reporter should emit the three payloads above.
|
repository. Their reporter should emit the three payloads above.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { config } from '../config.js';
|
|||||||
import schemaSql from './schema.sql?raw';
|
import schemaSql from './schema.sql?raw';
|
||||||
import { requirement, type BetaPlan, type BetaRequirement } from '../../beta-plan.js';
|
import { requirement, type BetaPlan, type BetaRequirement } from '../../beta-plan.js';
|
||||||
import { emptyMetrics } from '../fleet/beta-evaluation.js';
|
import { emptyMetrics } from '../fleet/beta-evaluation.js';
|
||||||
|
import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
|
||||||
|
|
||||||
let _db: Database.Database | null = null;
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
@@ -259,6 +260,7 @@ export function db(): Database.Database {
|
|||||||
backfillMultiScriptJoins(handle);
|
backfillMultiScriptJoins(handle);
|
||||||
assertSnapshotSchemeSafe(handle);
|
assertSnapshotSchemeSafe(handle);
|
||||||
migrateGameTelemetry(handle);
|
migrateGameTelemetry(handle);
|
||||||
|
backfillLeaderboard(handle);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handle.close();
|
handle.close();
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -6,6 +6,56 @@
|
|||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
PRAGMA foreign_keys = ON;
|
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 (
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container
|
komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import type { GameTelemetry } from '../../telemetry.js';
|
import type { GameTelemetry } from '../../telemetry.js';
|
||||||
|
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
|
||||||
|
|
||||||
export function listTelemetry(): GameTelemetry[] {
|
export function listTelemetry(): GameTelemetry[] {
|
||||||
const rows = db().prepare(`SELECT t.payload FROM server_telemetry t JOIN servers s ON s.name = t.server
|
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 {
|
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
|
VALUES (?, ?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET
|
||||||
round = excluded.round, players = excluded.players, received_at = excluded.received_at, payload = excluded.payload`)
|
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));
|
.run(report.server, report.event === 'game_start' ? 0 : report.round, report.players.length, report.receivedAt, JSON.stringify(report));
|
||||||
if (report.event === 'game_start') {
|
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') {
|
} 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.
|
// 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')`)
|
AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`)
|
||||||
.run(report.receivedAt, report.server, report.receivedAt);
|
.run(report.receivedAt, report.server, report.receivedAt);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (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');
|
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) => {
|
const players = body.players.map((value, index) => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`players[${index}] must be an object`);
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`players[${index}] must be an object`);
|
||||||
const player = value as Record<string, unknown>;
|
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`);
|
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 };
|
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;
|
if (body.event === 'game_start') return identity;
|
||||||
const stats = {} as Omit<TelemetryPlayerStats, keyof TelemetryPlayer>;
|
const stats = {} as Omit<TelemetryPlayerStats, keyof TelemetryPlayer>;
|
||||||
for (const key of ['kills', 'headshots', 'downs', 'revives', 'score'] as const) {
|
for (const key of ['kills', 'headshots', 'downs', 'revives', 'score'] as const) {
|
||||||
|
|||||||
@@ -93,7 +93,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<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}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -124,7 +127,10 @@
|
|||||||
button span { color: var(--text-tertiary); }
|
button span { color: var(--text-tertiary); }
|
||||||
button span.active, button:hover { color: var(--accent-primary-hover); }
|
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); }
|
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; }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.report-meta { text-align: left; }
|
.report-meta { text-align: left; }
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
||||||
const shortcuts: Record<string, string> = {
|
const shortcuts: Record<string, string> = {
|
||||||
f: '/', d: '/deployments', a: '/campaigns', b: '/betas',
|
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 armed = false;
|
||||||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -83,7 +83,8 @@
|
|||||||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
||||||
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
|
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
|
||||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
{ 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)
|
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user