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

15 KiB

type, title, description, tags, timestamp
type title description tags timestamp
guide Challenges Board How a signed-in player navigates the `/challenges` page, opens a challenge modal, submits a flag, and watches live solve updates.
guide
challenges
board
flag
score
sse
tester
2026-07-23T03:09:00Z

When this view is available

/challenges is rendered by ChallengesPage (frontend/src/app/features/challenges/challenges.page.ts) inside the authenticated shell.

On a fresh database the SeedSampleChallenges1700000000600 migration loads a representative sample set of challenges so the board has cards the moment an admin configures the event window; an empty board is only possible when the admin deleted every challenge.

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:ss until eventStartUtc (ticks each second during the final minute so the player sees time elapse) 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.subscribeReloadAtCountdownZero(...) so the SPA re-evaluates the board visibility from the server snapshot. The subscription is page-owned: ChallengesPage registers the handler in its constructor and wires the returned disposer into its DestroyRef so the reload listener is cleared as soon as the page is destroyed, independent of the store's start()/stop() transport lifecycle (see Event Window — Auto-reload at the transition boundary).

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).

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:ss countdown. Within the final minute the seconds digit visibly ticks down (e.g. 00:00:00:2400:00:00:23 → …) so the player can see time elapse.
  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