AI Implementation feature(865): Scoreboard: Ranking, Matrix, Event Log and Score Graph (#52)

This commit was merged in pull request #52.
This commit is contained in:
2026-07-23 05:13:23 +00:00
parent 49d0930780
commit 7c4843d5cc
26 changed files with 3074 additions and 60 deletions
+170 -2
View File
@@ -2,8 +2,8 @@
type: api
title: Challenges Endpoints
description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames.
tags: [api, challenges, board, score, sse, submit]
timestamp: 2026-07-23T00:10:00Z
tags: [api, challenges, board, score, scoreboard, sse, submit]
timestamp: 2026-07-23T05:10:00Z
---
# Endpoints
@@ -21,6 +21,10 @@ are not `@Public()`). The DTOs and zod schemas live in
| `GET` | `/api/v1/challenges/:id` | Authenticated | Same. |
| `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. |
| `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` |
| `GET` | `/api/v1/scoreboard/ranking` | Authenticated | `backend/src/modules/challenges/scoreboard.controller.ts` |
| `GET` | `/api/v1/scoreboard/matrix` | Authenticated | Same. |
| `GET` | `/api/v1/scoreboard/event-log` | Authenticated | Same. |
| `GET` | `/api/v1/scoreboard/graph` | Authenticated | Same. |
> The combined `/api/v1/events` SSE is **in addition to** the existing
> `/api/v1/events/status` status-only stream. The two share the same
@@ -202,6 +206,169 @@ downstream consumer can read either shape (for example
`challenge_id` or `challengeId`, `player_id` or `userId`,
`awarded_points` or `pointsAwarded`).
# Scoreboard endpoints
The `ScoreboardController` mounts the four authenticated read-only
projections that drive the `/scoreboard` page (Ranking, Matrix,
Event Log, Score Graph tabs). All four live under
`/api/v1/scoreboard/*` and are served by
`backend/src/modules/challenges/scoreboard.controller.ts` via
`ScoreboardService`
(`backend/src/modules/challenges/scoreboard.service.ts`). The DTO
contracts live in
`backend/src/modules/challenges/dto/scoreboard.dto.ts` (zod
`EventLogQuerySchema` plus TypeScript interfaces for the response
shapes).
All four endpoints:
* Require a valid JWT (the global `JwtAuthGuard` is active; no
`@Public()` is used on the scoreboard controller).
* Read exclusively — no mutation — and never expose the `challenge.flag`
field or any admin-only data.
* Compute `awardedPoints` per row using
`computeAwardedPoints(initial, minimum, decaySolves, solveCountBefore)`
from `backend/src/modules/challenges/scoring.util.ts`, which is the
same `basePoints + rankBonus` arithmetic that `submitFlag` performs
when persisting the `solve` row.
* Color each player via `stablePlayerColorIndex(playerId)` (FNV-1a
hash → 10-entry `PLAYER_COLOR_PALETTE`) so the Ranking, Matrix, and
Score Graph share a deterministic swatch per `playerId`.
## `GET /api/v1/scoreboard/ranking`
Returns every user (including zero-solve players) ranked by total
points, with competition-rank numbering (`1, 2, 2, 4, …` — players with
equal `points` share a rank).
Response (`RankingRowDto[]`):
```json
[
{
"rank": 1,
"playerId": "<user-uuid>",
"playerName": "alice",
"solvedCount": 7,
"points": 3120,
"colorIndex": 4
}
]
```
`colorIndex` is the same value the frontend uses to tint the
per-player swatch in the Ranking, Matrix, and Score Graph tabs so a
player keeps their color across all three views.
## `GET /api/v1/scoreboard/matrix`
Returns a players × challenges grid for the live scoreboard view.
Response (`MatrixViewDto`):
```json
{
"players": [ /* RankingRowDto[] in ranking order */ ],
"challenges": [
{
"id": "<challenge-uuid>",
"name": "RSA Rollers",
"solveCount": 4,
"categoryAbbreviation": "CRY"
}
],
"cells": {
"<player-uuid>": {
"<challenge-uuid>": 1,
"<challenge-uuid>": 2,
"<challenge-uuid>": 3,
"<challenge-uuid>": "solved",
"<challenge-uuid>": null
}
}
}
```
`cells[playerId][challengeId]` is one of `1` (gold ★, 1st solver),
`2` (silver ★, 2nd solver), `3` (bronze ★, 3rd solver), `'solved'`
(✓, 4th+ solver), or `null` (not solved). Challenges are sorted by
`solveCount` DESC, then by `name` ASC. The service walks the `solve`
rows in `solved_at ASC` order per challenge to assign each cell its
first solver rank.
## `GET /api/v1/scoreboard/event-log`
Recent solves, newest first, joined with the user, challenge, and
category so the frontend never needs a second round-trip to render
labels.
Query string (`EventLogQuerySchema`, validated by `ZodValidationPipe`):
| Param | Type | Default | Description |
|---------|------|---------|----------------------------------------------|
| `limit` | int | `50` | `1 <= limit <= 200`, clamped server-side too. |
Response (`EventLogRowDto[]`):
```json
[
{
"solveId": "<solve-uuid>",
"playerId": "<user-uuid>",
"playerName": "bob",
"challengeId": "<challenge-uuid>",
"challengeName": "Forensic Trail",
"categoryAbbreviation": "FOR",
"position": 1,
"awardedPoints": 515,
"awardedAtUtc": "2026-07-23T05:01:30.000Z"
}
]
```
## `GET /api/v1/scoreboard/graph`
Time-series of cumulative points for the top 10 players over the
configured event window, used to drive the Score Graph tab.
Response (`GraphViewDto`):
```json
{
"startUtc": "2026-07-23T05:00:00.000Z",
"endUtc": "2026-07-23T09:00:00.000Z",
"serverNowUtc": "2026-07-23T05:10:00.000Z",
"state": "running",
"series": [
{
"playerId": "<user-uuid>",
"playerName": "alice",
"colorIndex": 4,
"points": [
{ "tUtc": "2026-07-23T05:00:00.000Z", "value": 0 },
{ "tUtc": "2026-07-23T05:01:30.000Z", "value": 515 },
{ "tUtc": "2026-07-23T05:09:50.000Z", "value": 980 },
{ "tUtc": "2026-07-23T09:00:00.000Z", "value": 980 }
]
}
]
}
```
`state` mirrors the 4-state event window (`running` / `countdown` /
`stopped` / `unconfigured`) and `startUtc` / `endUtc` come from
`SettingsService` via the admin general-settings pipeline. When the
event has not yet started (`countdown`) or has no window configured
(`unconfigured`), the endpoint still returns `200` with empty
`series` and `null` boundaries so the frontend can render the
"Awaiting event configuration" / "Not started" empty states.
Series are sorted by their final cumulative value DESC and trimmed to
the top 10. Each series starts with a `{tUtc: startUtc, value: 0}`
anchor and (when `endUtc` is known) ends with a
`{tUtc: endUtc, value: finalValue}` step so the line plot shows a
flat plateau between the last solve and the event end.
# Errors
The challenges endpoints use three additional error codes from
@@ -221,5 +388,6 @@ The full error envelope and middleware are documented in
- [Challenge Tables](/database/challenges.md)
- [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
- [Challenges Board Guide](/guides/challenges-board.md)
- [Backend Module Map](/architecture/backend-modules.md)