Document current leaderboard API contract

This commit is contained in:
2026-10-05 16:44:10 -04:00
parent b1b3e8b958
commit bd10c1c5d2
+213 -23
View File
@@ -148,33 +148,222 @@ would require additional reporter identity/ordering information.
## Read API ## Read API
`GET /api/leaderboard` accepts optional `game` (`t5` or `t6`), `map` (the opaque This reference describes the current repository implementation. A deployed
key returned in `maps`), `metric` (default `highestRound`), `q` (name/GUID search), instance must run the corresponding code to provide the same behavior. The
`limit` (1–100, default 50), and `offset` (default 0). It returns ranked `entries`, sources are the [HTTP handler](../src/routes/api/leaderboard/+server.ts),
available `maps`, the matching player `total`, pagination values, and `summary`. [response types](../src/lib/leaderboard.ts),
Each entry represents one GUID and combines its qualifying matches within the [query implementation](../src/lib/server/fleet/leaderboard.ts), and
game/map filters. Entries have no game field; map options retain their game [authentication guard](../src/lib/server/auth/guard.ts).
identity. Both `total` and `summary.players` count unique GUIDs; `total` applies
the selected metric's qualification rules and player search, while the summary ### Authentication and requests
covers all completed qualifying participation in the game/map scope.
`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`, Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`,
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore`, `headshotRate`, `downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore`, `headshotRate`,
`killsPerDown`, `averageKills`, `averageRounds`, and `completionRate`. `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. ### Success response
Entries also include `records`, keyed by `highestRound`, `bestKills`, and
`bestScore`, with the completed match ID, value, map/game/server, timing and HTTP `200` returns the following top-level fields directly, with no `ok` or
observed participation behind each personal best. `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 ## Query performance
Leaderboard reads materialize the selected completed participation once, reuse On a cache miss, leaderboard reads aggregate the selected completed participation
one player aggregate for ranking and summary totals, and reuse the ranked/search for rankings and summary totals. The ranked scope and map options are reused
result for pagination and its matching count. Empty and out-of-range pages still across search/pagination requests at the same database revision; filtering and
return accurate totals. Record evidence is fetched only for players on the page. pagination operate on that ranked list. Empty and out-of-range pages still
Exact player-rank reads share the ranking rules but skip unused fleet summaries return accurate totals. Record evidence is fetched only for requested entries.
and map lists. No response cache delays visibility of a newly completed game. 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: 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 baseline's complete responses, including ratios and record evidence. Timings
include SQL preparation and evidence lookup, exclude HTTP/browser rendering, and include SQL preparation and evidence lookup, exclude HTTP/browser rendering, and
use a warm filesystem cache with alternating implementation order. These are use a warm filesystem cache with alternating implementation order. These are
local synthetic measurements, not production latency guarantees. Queries still local synthetic measurements, not production latency guarantees. SQL queries
run synchronously, so larger histories remain a capacity consideration. 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 or warnings, and a production build. Isolated Firefox walkthroughs at 1440px and
390px checked completed-only totals, record links, efficiency qualification, 390px checked completed-only totals, record links, efficiency qualification,
scoped ranks/history, preserved navigation, empty scopes and mobile expansion. scoped ranks/history, preserved navigation, empty scopes and mobile expansion.