From bd10c1c5d25471a9f7c1e09b43a2e4f02ccb1b68 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 5 Oct 2026 16:44:10 -0400 Subject: [PATCH] Document current leaderboard API contract --- docs/leaderboard.md | 236 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 213 insertions(+), 23 deletions(-) diff --git a/docs/leaderboard.md b/docs/leaderboard.md index 66f8f6c..ad58709 100644 --- a/docs/leaderboard.md +++ b/docs/leaderboard.md @@ -148,33 +148,222 @@ 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`. -Each entry represents one GUID and combines its qualifying matches within the -game/map filters. Entries have no game field; map options retain their game -identity. Both `total` and `summary.players` count unique GUIDs; `total` applies -the selected metric's qualification rules and player search, while the summary -covers all completed qualifying participation in the game/map scope. +This reference describes the current repository implementation. A deployed +instance must run the corresponding code to provide the same behavior. The +sources are the [HTTP handler](../src/routes/api/leaderboard/+server.ts), +[response types](../src/lib/leaderboard.ts), +[query implementation](../src/lib/server/fleet/leaderboard.ts), and +[authentication guard](../src/lib/server/auth/guard.ts). + +### Authentication and requests + +`GET /api/leaderboard` is a session-authenticated JSON endpoint. A **viewer**, +operator or administrator account can read it after any required temporary +password change. There is no API-key or bearer-token authentication for this +endpoint; telemetry and host-agent credentials do not grant access. + +A tool can sign in with `POST /api/auth/login`, sending a JSON object with +`username` and `password`, `Content-Type: application/json`, and an `Origin` +header exactly matching the instance's origin (scheme, hostname and port, with +no trailing slash). Preserve the returned `__Host-ec_session` cookie and send it +on subsequent requests. Login returns `{ "user": ... }`; the session token is +in `Set-Cookie`, not the JSON body. Sessions expire after 12 hours or 30 minutes +of inactivity, and can be revoked earlier. Handle `401` by signing in again. +See [Users and access](authentication.md) for setup and session rules. + +For example, with an existing account's credentials in a private `login.json` +file containing `{"username":"reader","password":"..."}`: + +```sh +EC_BASE_URL='https://ec.example.com' +umask 077 +curl --fail-with-body --silent --show-error \ + --cookie-jar ec-cookies.txt \ + --header "Origin: $EC_BASE_URL" \ + --header 'Content-Type: application/json' \ + --data-binary @login.json \ + "$EC_BASE_URL/api/auth/login" + +curl --fail-with-body --silent --show-error \ + --cookie ec-cookies.txt \ + --get "$EC_BASE_URL/api/leaderboard" \ + --data-urlencode 'game=t6' \ + --data-urlencode 'metric=kills' \ + --data-urlencode 'limit=100' \ + --data-urlencode 'offset=0' +``` + +HTTPS is required. Local HTTP development requires `EC_AUTH_ALLOW_HTTP=true` +and a loopback hostname, and uses the separate `ec_session_dev` cookie. The +read request does not require an `Origin` header. Successful responses use +`Content-Type: application/json`, `Cache-Control: no-store` and `Vary: Cookie`. + +### Query parameters + +All parameters are optional. Names and enumerated values are case-sensitive. +Use URL encoding for map keys and search text. + +| Parameter | Default | Accepted values and behavior | +| --- | --- | --- | +| `game` | Both games | Exactly `t5` or `t6`. An empty value is invalid. | +| `map` | All maps | Opaque `maps[].key`, at most 1024 characters. Empty means no map filter. An unknown key or a game/map mismatch returns an empty selection, not an error. | +| `metric` | `highestRound` | One of the metric keys below. An empty or unknown value is invalid. | +| `q` | No search | At most 100 characters before trimming. Matches a literal substring of the current name or GUID, ignoring ASCII letter case. Surrounding whitespace is trimmed; whitespace-only input means no search. `%` and `_` are literal characters, not wildcards. | +| `limit` | `50` | Integer from 1 to 100. | +| `offset` | `0` | Integer from 0 to 1,000,000; counts matching entries to skip, not ranks or pages. | + +`limit` and `offset` accept decimal digits only. Empty strings, signs, spaces, +fractions and exponent notation are rejected rather than clamped. String +length limits use JavaScript string length (UTF-16 code units). Unrecognized +query parameters are ignored; if a parameter is repeated, its first value wins. Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`, `downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore`, `headshotRate`, `killsPerDown`, `averageKills`, `averageRounds`, and `completionRate`. -Efficiency values in entries are unrounded ratios (for example, `0.5` means 50%) -or `null` when their denominator is zero. UI percentages multiply by 100. -Entries also include `records`, keyed by `highestRound`, `bestKills`, and -`bestScore`, with the completed match ID, value, map/game/server, timing and -observed participation behind each personal best. + +### Success response + +HTTP `200` returns the following top-level fields directly, with no `ok` or +`data` wrapper: + +| Field | Type | Meaning | +| --- | --- | --- | +| `entries` | Entry array | Requested page, ordered by the selected metric descending. | +| `maps` | Map array | Available completed-match map options, filtered only by `game`, independent of `map`, `q`, metric qualification and pagination. | +| `total` | number | Number of matching GUIDs after metric qualification and search, before pagination. | +| `limit`, `offset` | number | Accepted pagination values. | +| `metric` | string | Selected metric key, including the default when omitted. | +| `summary` | object | Totals for all qualifying completed participation in the game/map scope, independent of `q`, metric qualification and pagination. | + +Each entry combines one GUID's qualifying participation across the selected +games/maps. It contains all of these fields regardless of the selected metric: + +| Entry field | Type | Meaning | +| --- | --- | --- | +| `guid` | string | Player identity. Preserve it as a string, including leading zeros. | +| `name` | string | Latest reported name across both games, even if that report is outside the selected scope or from an unfinished match. | +| `rank` | number | Rank within the scope after metric qualification, before search/pagination. Ties share rank and leave gaps: 1, 1, 3. | +| `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`, `downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore` | number | The ten counters defined under [Recorded statistics](#recorded-statistics). | +| `headshotRate`, `killsPerDown`, `averageKills`, `averageRounds`, `completionRate` | number or null | Ratios defined below; `null` if the denominator is zero. | +| `lastSeen` | string | Latest player observation among the qualifying participation in this scope, not the latest global identity update. | +| `records` | object | Personal-best evidence keyed by `highestRound`, `bestKills` and `bestScore`. | + +Entries have no `game` field. Equal metric values are displayed in name order +(ASCII case-insensitive, then exact name), then GUID order. `q` is a substring +search, not an exact-GUID lookup; a tool looking up one GUID must check equality +on returned `guid` values and account for pagination. + +| Ratio | Calculation | Requirement to rank by this metric | +| --- | --- | --- | +| `headshotRate` | `headshots / kills` | `gamesPlayed >= 5` and `kills >= 100` | +| `killsPerDown` | `kills / downs` | `gamesPlayed >= 5` and `downs >= 5` | +| `averageKills` | `kills / gamesPlayed` | `gamesPlayed >= 5` | +| `averageRounds` | `roundsSurvived / gamesPlayed` | `gamesPlayed >= 5` | +| `completionRate` | `gamesCompleted / gamesPlayed` | `gamesPlayed >= 5` | + +These are unrounded ratios, not formatted strings. For `headshotRate` and +`completionRate`, `0.5` means 50%. Requirements use the selected game/map scope +and apply only to the metric being ranked. Entries on a counter leaderboard +still expose ratio values even when they would not qualify for a ratio ranking. +`gamesPlayed` counts ended matches with qualifying participation, including +matches the player left early; it does not require completion credit. + +Each map option has string fields `key`, `game`, `map` and `name`. `map` is the +engine map identifier; `name` is the display name. Pass `key` as the request's +`map` parameter, preferably together with the option's `game`. Options come from +completed matches reaching round 2, so an option can exist without any qualifying +players. Removed servers' maps remain available. + +| Summary field | Type | Meaning | +| --- | --- | --- | +| `players` | number | Unique qualifying GUIDs, combined across games. | +| `matches` | number | Distinct completed matches with qualifying player participation; not the sum of `gamesPlayed`. | +| `kills`, `roundsSurvived` | number | Sums across qualifying players; rounds are player-rounds. | +| `highestRound` | number | Highest qualifying player's recorded round. | +| `firstTrackedAt` | string or null | Earliest match `startedAt` among those matches. | +| `updatedAt` | string or null | Latest match report receipt time among those matches; not a response-generation time or a complete data-change token. | + +For an empty scope, numeric summary fields are zero and both timestamps are +`null`. A search with no results or an offset beyond `total` returns +`entries: []` while preserving the scope's summary and available maps. An +efficiency board can likewise have no entries while `summary.players > 0`. + +Each `records` value has the following fields. Evidence uses the same game/map +scope as the entry, and ties choose the earliest completion, then lowest match +ID. The three records can refer to different matches. + +| Evidence field | Type | Meaning | +| --- | --- | --- | +| `matchId`, `value` | number | Completed match ID and that personal-best value. Link to `/matches/{matchId}` for its results page. | +| `game`, `mapKey`, `mapName`, `server` | string | Recorded game, opaque map key, map display name and server name. | +| `startedAt` | string | First recorded match observation; only the game start when `startObserved` is true. | +| `endedAt` | string or null | Recorded completion time; can be unknown in older stored records. | +| `startObserved` | boolean | Whether a `game_start` was received for the match. | +| `presentAtStart` | boolean or null | Whether the player was first observed at the recorded start; `null` for unknown older participation. | +| `firstObservedRound` | number or null | Round of the player's first observation, or unknown. This does not prove continuous survival. | + +Timestamps reflect controller receipt/observation times, normally ISO 8601 UTC +strings such as `2026-10-05T12:00:00.000Z`, not reporter gameplay-clock times. + +### Errors, pagination and freshness + +Invalid query values return HTTP `400` with +`{ "ok": false, "kind": "bad-request", "error": "..." }`. Authentication errors +have a different shape: HTTP `401` returns `{ "error": "...", "setupRequired": +false }` (`true` before initial administrator setup); a required password change +returns HTTP `403` with `{ "error": "...", "mustChangePassword": true }`. +Host/enrollment bearer credentials return `403`. Disallowed HTTP transport +returns `426` with an `error` string. Report-service failures can produce `5xx`; +check status and content type before assuming the success schema or a uniform +error body. Failed/rate-limited login requests use their own auth error responses. + +For successive pages, retain the same filters and increase `offset` by `limit` +until `offset + entries.length >= total` or the page is empty. There is no cursor, +snapshot token, date-range filter or incremental `since` parameter. Each report +reads a consistent database snapshot, but separate requests may see newly ended +matches or name changes. A multi-page crawl can therefore miss or repeat players +as their ordering changes; deduplicate by GUID and refresh as needed. The offset +cap also limits how far a tool can page through a very large selection. + +Reports run in a worker and use caches keyed by database revision, invalidated +by writes to match, participation and player-identity tables. There is no fixed +TTL wait before newly committed leaderboard data becomes eligible for a fresh +report. Identical requests already in flight may share a response. Neither +`summary.updatedAt` nor an entry's `lastSeen` should be used as a cache revision: +an identity rename can change results without changing those timestamps. + +### Related routes and current limits + +`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match +summaries as `{ "matches": [...], "total": number }`, under the same session +authentication. Both accept only `limit` (default 25, range 1–10,000); there is +no offset pagination. Results are newest first by first observation, then match +ID, and include active/interrupted matches that reached round 2. Their +[ServerMatch fields](../src/lib/match-history.ts) differ from leaderboard entries +and do not include player counters or a roster. Unknown server names return an +empty list. URL-encode server names in paths. + +`/leaderboard/players/{guid}` and `/matches/{id}` are HTML pages backed by server +loaders. There is currently no dedicated JSON endpoint at +`/api/leaderboard/players/{guid}` or `/api/matches/{id}` for complete profiles, +player match history or match rosters. A tool needing those records will need +an API extension; the leaderboard endpoint supplies aggregate stats and +personal-best evidence only. ## Query performance -Leaderboard reads materialize the selected completed participation once, reuse -one player aggregate for ranking and summary totals, and reuse the ranked/search -result for pagination and its matching count. Empty and out-of-range pages still -return accurate totals. Record evidence is fetched only for players on the page. -Exact player-rank reads share the ranking rules but skip unused fleet summaries -and map lists. No response cache delays visibility of a newly completed game. +On a cache miss, leaderboard reads aggregate the selected completed participation +for rankings and summary totals. The ranked scope and map options are reused +across search/pagination requests at the same database revision; filtering and +pagination operate on that ranked list. Empty and out-of-range pages still +return accurate totals. Record evidence is fetched only for requested entries. +Exact player-rank reads use the same ranked scope, including its summaries and +map list. Report work runs in a dedicated worker with revision-keyed response +caching as described above. + +The benchmark and measurements below describe the earlier SQL optimization, +before the worker and revision caches were added. They are historical results, +not measurements of the current HTTP implementation. Run the deterministic, isolated benchmark with: @@ -208,10 +397,11 @@ small-fixture parity cases and 22 timed small/large request cases matched the baseline's complete responses, including ratios and record evidence. Timings include SQL preparation and evidence lookup, exclude HTTP/browser rendering, and use a warm filesystem cache with alternating implementation order. These are -local synthetic measurements, not production latency guarantees. Queries still -run synchronously, so larger histories remain a capacity consideration. +local synthetic measurements, not production latency guarantees. SQL queries +run synchronously within the report worker; larger histories still affect +report latency and worker capacity. -Validation for this change passed 1,019 unit tests, Svelte checking with no errors +Validation for that optimization passed 1,019 unit tests, Svelte checking with no errors or warnings, and a production build. Isolated Firefox walkthroughs at 1440px and 390px checked completed-only totals, record links, efficiency qualification, scoped ranks/history, preserved navigation, empty scopes and mobile expansion.