Files
HIPCTF2/docs/guides/challenges-board.md
T

198 lines
14 KiB
Markdown

---
type: guide
title: Challenges Board
description: How a signed-in player navigates the `/challenges` page, opens a challenge modal, submits a flag, and watches live solve updates.
tags: [guide, challenges, board, flag, score, sse, tester]
timestamp: 2026-07-23T00:10:00Z
---
# When this view is available
`/challenges` is rendered by `ChallengesPage`
(`frontend/src/app/features/challenges/challenges.page.ts`) inside the
authenticated shell. The page is gated by:
| Gate | Where |
|---------------------------------------|--------------------------------------------------|
| Signed in (access token + refresh) | `authGuard` in `app.routes.ts`. |
| Event window state | `ChallengesPage.isRunning()` derived from `EventStatusStore`. The board is only shown when `state === 'running'`; otherwise the page renders a gate panel (see below). |
The first time the page mounts it triggers `ChallengesStore.load()`
which calls `GET /api/v1/challenges/board`. It also calls
`GET /api/v1/challenges/status` once to populate the event state
synchronously, then opens the authenticated SSE stream on
`/api/v1/events` for live `solve` frames.
# How to access (tester steps)
1. Initialize the instance and sign in (`/login`).
2. Confirm the SPA routes to `/` and redirects to `/challenges`.
3. Configure an event window (`/admin/general`) whose
`eventStartUtc` is in the past and `eventEndUtc` is in the future
so the event state is `running`.
# Expected behavior
## Board grid (state = `running`)
The page renders an `<h2>Challenges</h2>` followed by a horizontal
flex container (`[data-testid="challenges-grid"]`) of one column per
category.
| Element | Selector | Expected |
|----------------------------------------|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| Category column | `[data-testid="category-column-CR"]` (or any abbreviation) | Header shows the category `icon`, uppercase `abbreviation`, and full `name`; below it is a vertical list of cards. |
| Category column with no challenges | Same | The header renders but the body shows `No challenges yet.` |
| Challenge card | `[data-testid="challenge-card-<uuid>"]` | Click anywhere on the card to open the modal. |
| Difficulty pill | `.diff.diff-LOW` / `.diff-MEDIUM` / `.diff-HIGH` | Green / yellow / red pill matching the challenge difficulty. |
| Live points | `[data-testid="points-<uuid>"]` | `livePoints` from the board payload; updates live as `solve` frames arrive. |
| Solve count | text `N solve` / `N solves` | Number of distinct players who have solved this challenge. |
| Solved-by-me check | `.check` (the `✓` glyph) | Appears on the card once the player has solved it; the card border also flips to the success color. |
Columns are sorted alphabetically by `abbreviation`; cards within a
column are sorted `LOW → MEDIUM → HIGH` then by name (lowercased).
## Gate panel (state != `running`)
When the event is in any other state, the grid is hidden and the page
renders `[data-testid="challenges-gate"]`:
| Event state | Headline | Countdown text (`[data-testid="challenges-countdown"]`) | Helper label |
|---------------|---------------------------|--------------------------------------------------------|-----------------------------------------------------|
| `countdown` | `Event starts in` | `DD:HH:mm` until `eventStartUtc` | `The board will be available once the event is running.` |
| `running` | _(grid shown instead)_ | — | — |
| `stopped` | `Event ended` | `Event ended` | `The board is closed.` |
| `unconfigured`| `Awaiting configuration` | empty | _(none)_ |
When `secondsToStart` or `secondsToEnd` reach zero, the
`ChallengesPage` triggers a full `window.location.reload()` via
`EventStatusStore.reloadAtCountdownZero(...)` so the SPA re-evaluates
the board visibility from the server snapshot.
## Challenge modal
Clicking a card opens `ChallengeModalComponent`
(`[data-testid="challenge-modal-<uuid>"]`). The modal has a dark
backdrop; clicking the backdrop or pressing `Esc` closes it.
| Section | Selector / element | Expected |
|------------------------|----------------------------------------------------------|------------------------------------------------------------------------------------------|
| Header | `<h3>` + pills | Challenge name, category abbreviation pill, difficulty pill (`LOW`/`MEDIUM`/`HIGH`), live points pill, and `Close` button. |
| Solved banner | `[data-testid="modal-solved-banner"]` | Renders `You solved this challenge.` when `card.solvedByMe` is true. |
| Awarded banner | `[data-testid="modal-awarded-banner"]` | `Awarded X pts (base Y + rank bonus Z)` after a successful submit. |
| Notice banner | `.notice` | When the event is not running, shows `Submissions are only accepted while the event is running.` and disables the input. |
| Description | `[innerHTML]` from `MarkdownService.render(...)` | Markdown body, links, code, etc. rendered from `card.descriptionMd`. |
| Flag input | `[data-testid="flag-input"]` | Free text input; disabled while submitting or while the event is not running. |
| Solve button | `[data-testid="solve-button"]` | Disabled while submitting. Submits via `ChallengesStore.submit(...)`. |
| Inline error | `[data-testid="modal-error"]` | Renders the friendly message from `messageForSolveError(parseSolveError(...))`. |
| Solvers list | `[data-testid="modal-solvers"]` | Header `Solvers (N)` then a row per solver (`#position`, name, local-time, awarded points). Top three ranks are coloured gold / silver / bronze; subsequent ranks use the success color. |
| Empty solvers state | `<em>No solvers yet.</em>` | Shown when the challenge has no solves yet. |
## Submitting a flag
1. Type the flag into `[data-testid="flag-input"]`.
2. Click `[data-testid="solve-button"]` (or press Enter inside the
form).
3. The modal calls `ChallengesStore.submit(challengeId, flag)` which
POSTs `{ flag }` to `/api/v1/challenges/:id/solves`.
4. On `200 solved`:
* The flag input is cleared.
* The `Awarded X pts (base Y + rank bonus Z)` banner appears.
* The solvers list re-renders with the player now at position `N`.
* The corresponding card on the board gets the success border and
`✓`.
5. On `200 already_solved` (idempotent re-submit):
* Same banner + solvers list, but the inline error area shows
`You already solved this challenge.`
6. On `400 FLAG_INCORRECT`:
* Inline error: `Incorrect flag. Try again.`
* Flag input is re-enabled so the player can retry.
7. On `409 EVENT_NOT_RUNNING`:
* Inline error: `Submissions are only accepted while the event is running.`
* The notice banner also reflects the event state.
8. On any other error (network, 5xx):
* The `errorNotificationInterceptor` also pushes a top-level
notification in the SPA's notification store (see
[Notifications](/guides/notifications.md)).
## Live solve updates
While the modal is open, the page subscribes to live `solve` frames
for the selected challenge via
`ChallengesStore.registerSolveListener(id, payload => modal.applyLiveSolve(payload))`.
Each `solve` SSE frame:
* Updates `card.livePoints` and `card.solveCount` on the modal.
* Merges the new solver into the existing `solvers` array (sorted by
`solvedAtUtc` ASC) via `mergeSolveEventIntoSolvers`.
* Updates the matching board card (point value + solve count + the
`✓` if the player is the solver).
When the user closes the modal, the listener is unregistered; on
destruction of the page the SSE source is closed via
`ChallengesStore.stop()`.
# Tester flows
## Running event board
1. Configure the event window as running; confirm the board renders
the columns/cards described above.
2. Open and close a card — the modal should mount and unmount cleanly
with no console errors; the SSE source should remain connected.
3. Solve a flag and confirm the awarded banner, the new solver row,
the updated board card (`✓` and new live points), and the new
`livePoints` on the same card across all open clients.
## Countdown / stopped gate
1. Set `eventStartUtc` in the future; confirm the gate panel shows
`Event starts in` and the `DD:HH:mm` countdown.
2. Wait for the countdown to hit zero; the SPA reloads and now shows
the board.
3. Set `eventEndUtc` in the past; confirm the gate shows
`Event ended`.
4. Delete one of the event-window settings; confirm `Awaiting
configuration` and an empty countdown.
## Submission errors
1. Submit an empty string — the modal's local guard skips the call.
2. Submit a wrong flag — expect `Incorrect flag. Try again.`.
3. Submit a correct flag twice — second call returns `already_solved`
and shows both the awarded banner and the
`You already solved this challenge.` inline error.
4. Try to submit while the event is `stopped` — expect
`Submissions are only accepted while the event is running.` and a
matching notice banner.
# Architecture map
| File | Responsibility |
|---------------------------------------------------------|------------------------------------------------------------------------------------------------|
| `frontend/src/app/features/challenges/challenges.page.ts` | Smart page; owns the modal lifecycle, gate logic, SSE wiring, and the countdown-zero reload. |
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, solve listeners, per-card mutation on submit + SSE solve. |
| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service: `getBoard`, `getDetail`, `getEventState`, `submit` (returns `ApiErrorEnvelope`). |
| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types (`BoardCard`, `SolverRow`, `SolveEventPayload`, etc.) + helpers (`sortColumnsByAbbrev`, `formatDdHhMm`, `parseSolveEvent`, `messageForSolveError`). |
| `frontend/src/app/features/challenges/category-column.component.ts` | Renders one category column (header + cards). |
| `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders one challenge card on the board. |
| `frontend/src/app/features/challenges/challenge-modal.component.ts` | Renders the challenge detail modal, flag form, solvers list, and live-solve updates. |
| `frontend/src/app/core/services/event-status.store.ts` | Anchor-based clock + 4-state event store; reused here for the gate logic. |
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based SSE transport used to open `/api/v1/events`. |
| `frontend/src/app/core/services/notification.service.ts` | Signal-backed notification store used by `errorNotificationInterceptor`. |
| `tests/frontend/challenges.pure.spec.ts` | Pure helpers (sorting, parsers, friendly error mapping). |
| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, stop(). |
| `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. |
| `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, and `?include=solvers` flag. |
| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. |
| `tests/backend/challenges-events-sse.spec.ts` | `/api/v1/events` SSE: status + solve frames, dedup, ordering. |
| `tests/backend/challenges-submit-flag.spec.ts` | Submit flow: correct/incorrect flag, idempotent re-submit, event-state guard, race handling. |
# See also
- [Challenges Endpoints](/api/challenges.md)
- [Challenge Tables](/database/challenges.md)
- [System Endpoints](/api/system.md) (legacy `/events/status`)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport)
- [Notifications](/guides/notifications.md) (error interceptor + service)