AI Implementation feature(911): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.01 #54
@@ -1,39 +0,0 @@
|
|||||||
# Implementation Plan: Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
- **Codebase style & conventions:** Node.js npm-workspace monorepo with a NestJS 10 backend and Angular 17 standalone frontend, both written in TypeScript. Backend request/business logic is service-oriented; live updates use RxJS-backed NestJS SSE. Frontend feature state uses private Angular signals exposed as read-only signals, with SSE frames normalized in `ScoreboardStore` and then applied immutably. The Event Log template already renders `categoryAbbreviation` and `challengeName`; the defect originates upstream because the solve publish path and authenticated SSE mapper omit those fields, and the frontend currently replaces them with empty strings.
|
|
||||||
- **Data Layer:** TypeORM with `better-sqlite3`. `ChallengesService.submitFlag` already joins `challenge` to `category`, so the successful transaction has `challenge.name` and `challenge.abbreviation` available without an additional query. The reloaded Event Log projection independently joins `solve`, `user`, `challenge`, and `category`, which explains why labels appear after refresh. No schema or migration is required.
|
|
||||||
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; backend tests are under `tests/backend/` using Nest testing utilities/Supertest and frontend tests are under `tests/frontend/` using jsdom. All tests run from the repository root with `npm test`; focused suites can use `npm run test:backend` and `npm run test:frontend`. Keep regression coverage in the existing dedicated test folders, not beside source files.
|
|
||||||
- **Required Tools & Dependencies:** No new package, system tool, global CLI, persistent `/data` fixture, or `setup.sh` change is required. Existing Node/npm, TypeScript, Jest, NestJS, Angular, RxJS, TypeORM, and SQLite dependencies cover the implementation. Verification should use `npm test` and `npm run build`; the repository exposes no lint script.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
- **To Modify:**
|
|
||||||
- `backend/src/modules/challenges/dto/challenges.dto.ts` — extend the typed live solve contract with `challengeName` and `categoryAbbreviation`.
|
|
||||||
- `backend/src/modules/challenges/challenges.service.ts` — include challenge/category labels in the post-insert hub payload using metadata already loaded by `submitFlag`.
|
|
||||||
- `backend/src/modules/challenges/events.controller.ts` — forward the two labels in authenticated solve SSE frames, preserving the established snake_case wire format and accepting current camelCase hub fields.
|
|
||||||
- `frontend/src/app/features/scoreboard/scoreboard.pure.ts` — extend `SolveLivePayload` so the normalized live event carries the Event Log labels.
|
|
||||||
- `frontend/src/app/features/scoreboard/scoreboard.store.ts` — parse label fields from SSE aliases and populate the newly prepended `EventLogRow` instead of writing empty strings.
|
|
||||||
- `tests/backend/challenges-submit-flag.spec.ts` — strengthen the existing hub-publish success-path test to assert challenge name and category abbreviation.
|
|
||||||
- `tests/frontend/scoreboard.store.spec.ts` — add focused regression assertions proving an SSE-pushed Event Log row retains both labels.
|
|
||||||
- `docs/api/challenges.md` — update the documented authenticated solve-frame contract and example fields.
|
|
||||||
- `docs/guides/scoreboard-stream.md` — document that live solve frames carry display metadata needed by the Event Log.
|
|
||||||
- **To Create:** None.
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
1. **Database / Schema Migration:** Make no database changes. Reuse the challenge/category metadata already selected in `ChallengesService.submitFlag`; keep the existing reload query in `ScoreboardService.getEventLog` unchanged.
|
|
||||||
2. **Backend Logic & APIs:**
|
|
||||||
1. Add required `challengeName` and `categoryAbbreviation` properties to `SolveEventPayload` so omissions are caught at the backend contract boundary.
|
|
||||||
2. Extend the private `publishSolveEvent` input and its `SseHubService.emitScoreboard` payload with `challenge.name` and `challenge.abbreviation`, passing them from the successful fresh-solve branch only. Preserve current idempotency: `already_solved`, failed validation, and uniqueness-race fallback paths must not publish a new event.
|
|
||||||
3. Extend `ChallengesEventsController.solveFrame` to emit `challenge_name` and `category_abbreviation` alongside `challenge_id`, player, scoring, and timing data. Do not add a lookup or business logic to the controller; it should remain a pure transport mapping over the enriched hub payload.
|
|
||||||
4. Keep all existing fields and aliases intact so Ranking, Matrix, Graph, Challenges Board, and any current stream consumers remain backward-compatible.
|
|
||||||
3. **Frontend UI Integration:**
|
|
||||||
1. Extend `SolveLivePayload` with `challengeName` and `categoryAbbreviation`.
|
|
||||||
2. In `ScoreboardStore.handleSseFrame`, normalize both snake_case fields (`challenge_name`, `category_abbreviation`) and camelCase fallbacks (`challengeName`, `categoryAbbreviation`) in the same style as existing solve properties.
|
|
||||||
3. In `applySolveEvent`, copy those normalized values into the new `EventLogRow`. Leave `EventLogComponent` unchanged because its `.what` span already renders the abbreviation followed by challenge name, and leave the existing deduplication/cap behavior unchanged.
|
|
||||||
4. Update API/stream documentation so the public contract matches the corrected wire payload and explains that both initial REST rows and live SSE rows contain identical display metadata.
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
- **Target Unit Test File:**
|
|
||||||
- `tests/backend/challenges-submit-flag.spec.ts`: enhance the existing “emits an SSE solve payload” test to assert the emitted hub event contains the seeded challenge name and `CRY` abbreviation. This directly verifies the root publish boundary without adding a large SSE/network fixture.
|
|
||||||
- `tests/frontend/scoreboard.store.spec.ts`: extend the existing live solve-frame test, or add one minimal test, with `challenge_name` and `category_abbreviation`, then assert the prepended Event Log row contains both exact values while retaining awarded points. This covers the user-visible regression through the store’s SSE normalization path without UI/visual testing.
|
|
||||||
- **Mocking Strategy:** Reuse the existing in-memory SQLite Nest application and real `SseHubService` subscription in the backend test; no external service mocks or persistent files are needed. Reuse `FakeApi`, the fake `DestroyRef`, and the lightweight fake `EventSourceLike` in the frontend test; feed a synthetic `MessageEvent` and inspect the public read-only `eventLog` signal. Do not add Playwright, browser, visual, filesystem, network, or `/data` fixtures. Implement test-first: make the focused assertions fail against the current omission, then apply the smallest payload-contract changes and run the root `npm test` plus `npm run build`.
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Implementation Plan: Scoreboard — Distinct Line Colors per Top-10 Player on Score Graph
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Feature NOT yet implemented.** The `PLAYER_COLOR_PALETTE` exposes 10 distinct
|
||||||
|
hex colors and there are 10 legend entries / 10 polylines, but each `colorIndex`
|
||||||
|
is derived from `stablePlayerColorIndex(playerId)` — an FNV-1a hash modulo 10.
|
||||||
|
For many player-id sets (including the reported one) that hash produces
|
||||||
|
collisions: e.g. `PlayerC` and `Player05` both hash to `5` → `#ec4899`;
|
||||||
|
`Player07` and `Player08` both hash to `9` → `#84cc16`; `PlayerA` and
|
||||||
|
`Player10` both hash to `2` → `#10b981`. Verified empirically:
|
||||||
|
|
||||||
|
```
|
||||||
|
hash → palette index
|
||||||
|
PlayerA → 2 (#10b981) green
|
||||||
|
PlayerB → 8 (#6366f1)
|
||||||
|
PlayerC → 5 (#ec4899) pink ← collides with Player05
|
||||||
|
Player04 → 8 (#6366f1)
|
||||||
|
Player05 → 5 (#ec4899) pink ← collides with PlayerC
|
||||||
|
Player06 → 3 (#ef4444)
|
||||||
|
Player07 → 9 (#84cc16) lime ← collides with Player08
|
||||||
|
Player08 → 9 (#84cc16) lime ← collides with Player07
|
||||||
|
Player09 → 3 (#ef4444)
|
||||||
|
Player10 → 2 (#10b981) green ← collides with PlayerA
|
||||||
|
```
|
||||||
|
|
||||||
|
Collisions are observed across **all four** scoreboard tabs (Ranking swatch,
|
||||||
|
Matrix first-column swatch, Event Log has no swatch, Score Graph swatch +
|
||||||
|
polyline) because every component reads its color through the same
|
||||||
|
`colorIndex → PLAYER_COLOR_PALETTE` lookup.
|
||||||
|
|
||||||
|
The Job text limits the symptom to the Score Graph, but the root cause is
|
||||||
|
shared data (`colorIndex`), so the fix must be applied at the data layer
|
||||||
|
(`scoreboard.pure.ts`) and re-projected through `scoreboard.store.ts` so the
|
||||||
|
same top-10 player keeps the same distinct color in every tab.
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
|
||||||
|
- **Framework & language:** TypeScript strict mode, Angular standalone
|
||||||
|
components, NestJS backend (irrelevant for this fix — the bug is purely
|
||||||
|
client-side rendering).
|
||||||
|
- **Code style:** Standalone components with `ChangeDetectionStrategy.OnPush`,
|
||||||
|
signal-based store, pure-function helpers in `scoreboard.pure.ts` (no
|
||||||
|
Angular DI), services in `*.service.ts`, store in `*.store.ts`.
|
||||||
|
- **Data layer:** Pure-function projections over server-supplied payloads.
|
||||||
|
The four tabs are populated from
|
||||||
|
`/api/v1/scoreboard/{ranking,matrix,event-log,graph}` (parallel fetch in
|
||||||
|
`ScoreboardStore.loadAll()`) and incrementally patched in
|
||||||
|
`ScoreboardStore.applySolveEvent()` from the `/api/v1/events` SSE
|
||||||
|
`solve` frame.
|
||||||
|
- **Color model:** A single readonly 10-entry hex array
|
||||||
|
`PLAYER_COLOR_PALETTE` in `frontend/src/app/features/scoreboard/scoreboard.pure.ts:73`
|
||||||
|
and a hashing helper `stablePlayerColorIndex(playerId)`
|
||||||
|
(`scoreboard.pure.ts:86`). Every consumer (ranking, matrix, score-graph)
|
||||||
|
reads `PLAYER_COLOR_PALETTE[idx]` directly. There is currently **no**
|
||||||
|
collision-resolution pass.
|
||||||
|
- **Test framework & structure:** Jest 29 with `ts-jest` preset, configured
|
||||||
|
by `/repo/tests/jest.config.js` (two projects: `backend` and `frontend`,
|
||||||
|
the latter with `jsdom`). Tests live exclusively in `/repo/tests/frontend/`
|
||||||
|
and `/repo/tests/backend/` (never next to source). Single command:
|
||||||
|
`npm test` from the repo root.
|
||||||
|
- **Required tools & dependencies:** None new. The fix is purely a
|
||||||
|
pure-function rewrite inside existing files plus a tiny test addition.
|
||||||
|
No `setup.sh` change, no new packages.
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
|
||||||
|
- **To Modify:**
|
||||||
|
- `frontend/src/app/features/scoreboard/scoreboard.pure.ts` — add a
|
||||||
|
collision-free top-N palette assigner; rewire the three projection
|
||||||
|
helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`,
|
||||||
|
`applySolveToGraph`) so they use it instead of bare
|
||||||
|
`stablePlayerColorIndex`.
|
||||||
|
- `frontend/src/app/features/scoreboard/scoreboard.store.ts` — apply
|
||||||
|
the unique-color projection after `loadAll()` populates each signal
|
||||||
|
(ranking/matrix/graph) so the initial REST responses also benefit.
|
||||||
|
- `tests/frontend/scoreboard.pure.spec.ts` — add focused specs covering
|
||||||
|
the new collision-free assigner (success path: distinct indices for
|
||||||
|
the documented colliding ID set) and a regression spec asserting
|
||||||
|
existing single-player behaviour still holds.
|
||||||
|
|
||||||
|
- **To Create:** None.
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
|
||||||
|
### 3.1. Add a unique-color assigner in `scoreboard.pure.ts`
|
||||||
|
|
||||||
|
Export a new pure helper next to `stablePlayerColorIndex`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function assignUniquePlayerColors<T extends { playerId: string }>(
|
||||||
|
items: readonly T[],
|
||||||
|
getColorIndex: (t: T) => number,
|
||||||
|
capacity: number = PLAYER_COLOR_PALETTE.length,
|
||||||
|
): T[] {
|
||||||
|
const used = new Set<number>();
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const preferred = ((getColorIndex(item) % capacity) + capacity) % capacity;
|
||||||
|
let chosen = preferred;
|
||||||
|
if (used.has(preferred)) {
|
||||||
|
// Linear probe — the top-10 window guarantees a free slot exists.
|
||||||
|
let probe = (preferred + 1) % capacity;
|
||||||
|
while (used.has(probe) && probe !== preferred) {
|
||||||
|
probe = (probe + 1) % capacity;
|
||||||
|
}
|
||||||
|
chosen = probe;
|
||||||
|
}
|
||||||
|
used.add(chosen);
|
||||||
|
out.push({ ...item, colorIndex: chosen } as T);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
|
||||||
|
- The first player keeps their hashed index; collisions are pushed to the
|
||||||
|
next free slot via linear probe. With `capacity = 10` and at most 10
|
||||||
|
players, a free slot is always found (the input length is itself
|
||||||
|
bounded to ≤ capacity by the upstream `top-10 cutoff` in
|
||||||
|
`applySolveToGraph`).
|
||||||
|
- Order is preserved → ranking/rank-cell order is unaffected.
|
||||||
|
- The mapper is injected so the same helper works for `RankingRow`,
|
||||||
|
`GraphSeries`, and any future list type that needs a `colorIndex`.
|
||||||
|
|
||||||
|
### 3.2. Use the assigner inside the three projection helpers
|
||||||
|
|
||||||
|
In `parseSolveEventIntoRanking`, replace the direct assignment at the
|
||||||
|
"new player" branch (currently
|
||||||
|
`colorIndex: stablePlayerColorIndex(payload.playerId)`) by:
|
||||||
|
|
||||||
|
1. Build a temporary list of the affected/new player with the hashed index.
|
||||||
|
2. After the sort, call `assignUniquePlayerColors(sorted, (r) => r.colorIndex)`
|
||||||
|
and return that.
|
||||||
|
|
||||||
|
In `mutateMatrixFromSolve`, when a brand-new player is appended, derive
|
||||||
|
their provisional `colorIndex` from the hash, then post-process the
|
||||||
|
`players` array with `assignUniquePlayerColors` before returning.
|
||||||
|
|
||||||
|
In `applySolveToGraph`, after the new series is added and the array is
|
||||||
|
sorted + sliced to top 10, run `assignUniquePlayerColors` on the top-10
|
||||||
|
series so the polyline colors are guaranteed unique. New players appended
|
||||||
|
after the cutoff don't affect the top-10.
|
||||||
|
|
||||||
|
This keeps the FNV hash as the **deterministic preference** (so a
|
||||||
|
player retains their color across all tabs and across reloads whenever
|
||||||
|
possible) while guaranteeing uniqueness inside the top-10 window.
|
||||||
|
|
||||||
|
### 3.3. Apply the same pass after `loadAll()` in `scoreboard.store.ts`
|
||||||
|
|
||||||
|
The initial REST responses already arrive with `colorIndex` set by the
|
||||||
|
backend (which uses the same hashing convention). After the four
|
||||||
|
parallel fetches resolve, re-project each view through the new assigner
|
||||||
|
so the very first paint also shows distinct colors. Specifically, in
|
||||||
|
the success branch of `loadAll()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this._ranking.set(assignUniquePlayerColors(ranking, (r) => r.colorIndex));
|
||||||
|
const matrixPlayers = assignUniquePlayerColors(matrix.players, (p) => p.colorIndex);
|
||||||
|
this._matrix.set({ ...matrix, players: matrixPlayers });
|
||||||
|
this._graph.set({ ...graph, series: assignUniquePlayerColors(graph.series, (s) => s.colorIndex) });
|
||||||
|
```
|
||||||
|
|
||||||
|
(Backed by tests in §4; the projection is a no-op when there is only
|
||||||
|
one player or when no collisions exist.)
|
||||||
|
|
||||||
|
### 3.4. Verify Ranking/Matrix tabs visually
|
||||||
|
|
||||||
|
The existing `ranking.component.ts:68`, `matrix.component.ts:108`, and
|
||||||
|
`score-graph.component.ts:112` `color(idx)` helpers already guard
|
||||||
|
against out-of-range indices via the same modulo trick the assigner
|
||||||
|
uses, so no template changes are required — they keep working as soon as
|
||||||
|
`colorIndex` is unique.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
|
||||||
|
- **Target test file:** `tests/frontend/scoreboard.pure.spec.ts`
|
||||||
|
(and one regression test for `applySolveToGraph` covering the exact
|
||||||
|
Job example: ten PlayerC/Player05-style IDs).
|
||||||
|
- **Mocking strategy:** The helper is a pure function with no DOM, no
|
||||||
|
Angular DI, and no async — it is imported directly into the existing
|
||||||
|
spec file, which already mocks only `@angular/common/http` (no extra
|
||||||
|
setup needed). No `TestBed`, no fixtures, no chart rendering.
|
||||||
|
- **Spec list (minimal, focused):**
|
||||||
|
1. `assignUniquePlayerColors` returns distinct indices for the
|
||||||
|
documented colliding id set
|
||||||
|
`['PlayerA','PlayerB','PlayerC','Player04','Player05','Player06','Player07','Player08','Player09','Player10']`
|
||||||
|
and the colors they map to are all different (assert via
|
||||||
|
`PLAYER_COLOR_PALETTE[idx]` set size === 10).
|
||||||
|
2. `assignUniquePlayerColors` is a no-op when there are no collisions
|
||||||
|
(single player, or hashed indices already unique).
|
||||||
|
3. `applySolveToGraph` end-to-end: feed it an empty view + 10 SSE
|
||||||
|
solves for 10 colliding players, then assert every series in the
|
||||||
|
resulting `view.series` has a unique `colorIndex` (regression for
|
||||||
|
the Job's DOM-inspection symptom at the polyline level).
|
||||||
|
4. `parseSolveEventIntoRanking` regression: existing test still passes
|
||||||
|
— proves the rewire did not change ranking sort/points logic.
|
||||||
|
|
||||||
|
- **How to run:** `npm test` from the repo root (resolves to the
|
||||||
|
pre-configured Jest project; frontend project selected automatically
|
||||||
|
because all impacted specs live under `tests/frontend/`).
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
type: guide
|
type: guide
|
||||||
title: Scoreboard Page
|
title: Scoreboard Page
|
||||||
description: How a signed-in player navigates /scoreboard, uses the four tabs (Ranking, Matrix, Event Log, Score Graph), and watches live solve updates arriving over the authenticated SSE stream.
|
description: How a signed-in player navigates /scoreboard, uses the four tabs, distinguishes up to ten visible players by collision-resolved colors, and watches live solve updates.
|
||||||
tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester]
|
tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester]
|
||||||
timestamp: 2026-07-23T05:41:00Z
|
timestamp: 2026-07-23T06:01:38Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
@@ -58,9 +58,14 @@ A table (`data-testid="scoreboard-ranking"`) with columns **Rank**,
|
|||||||
|
|
||||||
* Each row has `data-testid="ranking-row-<playerId>"` and a
|
* Each row has `data-testid="ranking-row-<playerId>"` and a
|
||||||
`data-rank="<n>"` attribute.
|
`data-rank="<n>"` attribute.
|
||||||
* Each player name is prefixed with a colored swatch (10-entry
|
* Each player name is prefixed with a colored swatch from the 10-entry
|
||||||
`PLAYER_COLOR_PALETTE` chosen by `stablePlayerColorIndex`) so the
|
`PLAYER_COLOR_PALETTE`. `stablePlayerColorIndex` supplies the preferred
|
||||||
same player keeps the same color across all four tabs.
|
color; `assignUniquePlayerColors` keeps that color when available and
|
||||||
|
linearly selects the next free palette slot after a collision. Ranking,
|
||||||
|
Matrix, and Score Graph apply this assignment in their current display
|
||||||
|
order after initial REST loading and after live updates, so up to ten
|
||||||
|
simultaneously displayed players remain visually distinct within each
|
||||||
|
projection.
|
||||||
* Ranking uses competition ranks: equal-point players share the same
|
* Ranking uses competition ranks: equal-point players share the same
|
||||||
`rank` (1, 2, 2, 4, …). Zero-solve players are still listed at the
|
`rank` (1, 2, 2, 4, …). Zero-solve players are still listed at the
|
||||||
bottom in user-id order.
|
bottom in user-id order.
|
||||||
@@ -121,7 +126,9 @@ points for the **top 10 players** over the configured event window
|
|||||||
(`startUtc` → `endUtc`):
|
(`startUtc` → `endUtc`):
|
||||||
|
|
||||||
* Above the chart, a legend (`data-testid="score-graph-legend-<playerId>"`)
|
* Above the chart, a legend (`data-testid="score-graph-legend-<playerId>"`)
|
||||||
lists each player with their colored swatch.
|
lists each player with their colored swatch. The top ten visible series
|
||||||
|
receive distinct palette colors, including when two player IDs hash to
|
||||||
|
the same preferred color.
|
||||||
* Each player has a `polyline` element
|
* Each player has a `polyline` element
|
||||||
(`data-testid="score-graph-line-<playerId>"`) using the same color
|
(`data-testid="score-graph-line-<playerId>"`) using the same color
|
||||||
index as the Ranking and Matrix tabs.
|
index as the Ranking and Matrix tabs.
|
||||||
@@ -152,9 +159,11 @@ The same `solve` frame that lights up the
|
|||||||
`awardedPoints`, `solvedCount` increments by 1, and the list is
|
`awardedPoints`, `solvedCount` increments by 1, and the list is
|
||||||
re-sorted (with competition ranks applied). If the player is new,
|
re-sorted (with competition ranks applied). If the player is new,
|
||||||
they are appended with `points = awardedPoints`, `solvedCount = 1`.
|
they are appended with `points = awardedPoints`, `solvedCount = 1`.
|
||||||
|
Colors are reassigned in sorted ranking order to avoid palette collisions.
|
||||||
* **Matrix**: the cell at `[<playerId>, <challengeId>]` becomes `1`,
|
* **Matrix**: the cell at `[<playerId>, <challengeId>]` becomes `1`,
|
||||||
`2`, or `3` if the new `position` is in 1–3, otherwise `'solved'`.
|
`2`, or `3` if the new `position` is in 1–3, otherwise `'solved'`.
|
||||||
New players are appended to the matrix row list.
|
New players are appended to the matrix row list, then visible row colors
|
||||||
|
are collision-resolved in matrix order.
|
||||||
* **Event Log**: a new `EventLogRow` is prepended to the list
|
* **Event Log**: a new `EventLogRow` is prepended to the list
|
||||||
(deduplicated by `solveId` so reconnects don't duplicate) and the
|
(deduplicated by `solveId` so reconnects don't duplicate) and the
|
||||||
list is capped at 200. The SSE solve frame carries
|
list is capped at 200. The SSE solve frame carries
|
||||||
@@ -166,7 +175,8 @@ The same `solve` frame that lights up the
|
|||||||
* **Score Graph**: the matching player's series receives a new
|
* **Score Graph**: the matching player's series receives a new
|
||||||
`{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point,
|
`{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point,
|
||||||
the series list is re-sorted by final value DESC and trimmed to the
|
the series list is re-sorted by final value DESC and trimmed to the
|
||||||
top 10.
|
top 10. Those ten series are then assigned unique palette slots in
|
||||||
|
graph order.
|
||||||
|
|
||||||
On `ngOnDestroy`, `store.stop()` aborts the SSE transport and clears
|
On `ngOnDestroy`, `store.stop()` aborts the SSE transport and clears
|
||||||
the reconnect timer so no callbacks fire after the page leaves the
|
the reconnect timer so no callbacks fire after the page leaves the
|
||||||
@@ -256,6 +266,21 @@ DOM.
|
|||||||
tabs and matches the same player's swatch in the challenges board
|
tabs and matches the same player's swatch in the challenges board
|
||||||
modal solvers list.
|
modal solvers list.
|
||||||
|
|
||||||
|
## Verifying collision-resolved player colors
|
||||||
|
|
||||||
|
1. Seed or create up to ten visible players, including player IDs whose
|
||||||
|
preferred palette hashes collide.
|
||||||
|
2. Open `/scoreboard` and inspect **Ranking**. Every one of the first ten
|
||||||
|
displayed players has a distinct swatch; players without a collision keep
|
||||||
|
their preferred hashed color.
|
||||||
|
3. Open **Matrix**. Up to ten visible player rows likewise use distinct
|
||||||
|
swatches in matrix row order.
|
||||||
|
4. Open **Score Graph**. Its ten legend swatches and matching polylines use
|
||||||
|
ten distinct palette colors.
|
||||||
|
5. Trigger a solve. Ranking and graph order may change, but each projection
|
||||||
|
resolves its displayed colors again and remains collision-free for up to
|
||||||
|
ten players.
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
- [Challenges Endpoints](/api/challenges.md) — `/api/v1/scoreboard/*` request/response shapes.
|
- [Challenges Endpoints](/api/challenges.md) — `/api/v1/scoreboard/*` request/response shapes.
|
||||||
|
|||||||
+3
-3
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
|||||||
controls.
|
controls.
|
||||||
|
|
||||||
The docs below are organized by purpose so agents can pull just the slice
|
The docs below are organized by purpose so agents can pull just the slice
|
||||||
they need. Last regenerated 2026-07-23T05:41:00Z.
|
they need. Last regenerated 2026-07-23T06:01:38Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@ they need. Last regenerated 2026-07-23T05:41:00Z.
|
|||||||
broadcast to clients (public unauthenticated stream + authenticated
|
broadcast to clients (public unauthenticated stream + authenticated
|
||||||
`/api/v1/events`).
|
`/api/v1/events`).
|
||||||
* [Scoreboard Page](/guides/scoreboard-page.md) - How a signed-in player
|
* [Scoreboard Page](/guides/scoreboard-page.md) - How a signed-in player
|
||||||
navigates `/scoreboard`, uses the 4 tabs (Ranking, Matrix, Event Log,
|
navigates `/scoreboard`, uses the 4 tabs, verifies collision-resolved
|
||||||
Score Graph), and watches live solve updates.
|
colors for up to ten visible players, and watches live solve updates.
|
||||||
* [Notifications](/guides/notifications.md) - The root notification
|
* [Notifications](/guides/notifications.md) - The root notification
|
||||||
store + global HTTP error interceptor that translate backend error
|
store + global HTTP error interceptor that translate backend error
|
||||||
codes and statuses into friendly user-facing messages.
|
codes and statuses into friendly user-facing messages.
|
||||||
|
|||||||
@@ -94,6 +94,32 @@ export function stablePlayerColorIndex(playerId: string): number {
|
|||||||
return ((hash % n) + n) % n;
|
return ((hash % n) + n) % n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assignUniquePlayerColors<T extends { playerId: string }>(
|
||||||
|
items: readonly T[],
|
||||||
|
getColorIndex: (t: T) => number,
|
||||||
|
capacity: number = PLAYER_COLOR_PALETTE.length,
|
||||||
|
): T[] {
|
||||||
|
const used = new Set<number>();
|
||||||
|
const cap = Math.max(1, Math.floor(capacity));
|
||||||
|
const out: T[] = new Array(items.length);
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
const item = items[i];
|
||||||
|
const preferredRaw = getColorIndex(item);
|
||||||
|
const preferred = ((preferredRaw % cap) + cap) % cap;
|
||||||
|
let chosen = preferred;
|
||||||
|
if (used.has(preferred)) {
|
||||||
|
let probe = (preferred + 1) % cap;
|
||||||
|
while (used.has(probe) && probe !== preferred) {
|
||||||
|
probe = (probe + 1) % cap;
|
||||||
|
}
|
||||||
|
chosen = probe;
|
||||||
|
}
|
||||||
|
used.add(chosen);
|
||||||
|
out[i] = { ...item, colorIndex: chosen } as T;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyRankingSort(rows: readonly RankingRow[]): RankingRow[] {
|
export function applyRankingSort(rows: readonly RankingRow[]): RankingRow[] {
|
||||||
const list = rows.map((r) => ({ ...r }));
|
const list = rows.map((r) => ({ ...r }));
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
@@ -136,7 +162,8 @@ export function parseSolveEventIntoRanking(
|
|||||||
colorIndex: stablePlayerColorIndex(payload.playerId),
|
colorIndex: stablePlayerColorIndex(payload.playerId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return applyRankingSort(out);
|
const sorted = applyRankingSort(out);
|
||||||
|
return assignUniquePlayerColors(sorted, (r) => r.colorIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dedupEventLogBySolveId(
|
export function dedupEventLogBySolveId(
|
||||||
@@ -221,8 +248,9 @@ export function applySolveToGraph(
|
|||||||
const finalValue = (s: GraphSeries) => s.points[s.points.length - 1]?.value ?? 0;
|
const finalValue = (s: GraphSeries) => s.points[s.points.length - 1]?.value ?? 0;
|
||||||
seriesList.sort((a, b) => finalValue(b) - finalValue(a));
|
seriesList.sort((a, b) => finalValue(b) - finalValue(a));
|
||||||
const top = seriesList.slice(0, 10);
|
const top = seriesList.slice(0, 10);
|
||||||
|
const uniqueTop = assignUniquePlayerColors(top, (s) => s.colorIndex);
|
||||||
|
|
||||||
return { startUtc: start, endUtc: end, serverNowUtc, state, series: top };
|
return { startUtc: start, endUtc: end, serverNowUtc, state, series: uniqueTop };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mutateMatrixFromSolve(
|
export function mutateMatrixFromSolve(
|
||||||
@@ -251,7 +279,8 @@ export function mutateMatrixFromSolve(
|
|||||||
cellRow[payload.challengeId] = 'solved';
|
cellRow[payload.challengeId] = 'solved';
|
||||||
}
|
}
|
||||||
cells[payload.playerId] = cellRow;
|
cells[payload.playerId] = cellRow;
|
||||||
return { players, challenges: matrix.challenges, cells };
|
const uniquePlayers = assignUniquePlayerColors(players, (p) => p.colorIndex);
|
||||||
|
return { players: uniquePlayers, challenges: matrix.challenges, cells };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSolveDateTime(utc: string | null | undefined): string {
|
export function formatSolveDateTime(utc: string | null | undefined): string {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ScoreboardTab,
|
ScoreboardTab,
|
||||||
SolveLivePayload,
|
SolveLivePayload,
|
||||||
applySolveToGraph,
|
applySolveToGraph,
|
||||||
|
assignUniquePlayerColors,
|
||||||
dedupEventLogBySolveId,
|
dedupEventLogBySolveId,
|
||||||
mutateMatrixFromSolve,
|
mutateMatrixFromSolve,
|
||||||
parseSolveEventIntoRanking,
|
parseSolveEventIntoRanking,
|
||||||
@@ -67,10 +68,13 @@ export class ScoreboardStore {
|
|||||||
const matrix = await this.toPromise<MatrixView>(() => this.api.getMatrix());
|
const matrix = await this.toPromise<MatrixView>(() => this.api.getMatrix());
|
||||||
const eventLog = await this.toPromise<EventLogRow[]>(() => this.api.getEventLog(50));
|
const eventLog = await this.toPromise<EventLogRow[]>(() => this.api.getEventLog(50));
|
||||||
const graph = await this.toPromise<GraphView>(() => this.api.getGraph());
|
const graph = await this.toPromise<GraphView>(() => this.api.getGraph());
|
||||||
this._ranking.set(ranking);
|
const uniqueRanking = assignUniquePlayerColors(ranking, (r) => r.colorIndex);
|
||||||
this._matrix.set(matrix);
|
const uniqueMatrixPlayers = assignUniquePlayerColors(matrix.players, (p) => p.colorIndex);
|
||||||
|
const uniqueGraphSeries = assignUniquePlayerColors(graph.series, (s) => s.colorIndex);
|
||||||
|
this._ranking.set(uniqueRanking);
|
||||||
|
this._matrix.set({ ...matrix, players: uniqueMatrixPlayers });
|
||||||
this._eventLog.set(eventLog);
|
this._eventLog.set(eventLog);
|
||||||
this._graph.set(graph);
|
this._graph.set({ ...graph, series: uniqueGraphSeries });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this._error.set(err?.message ?? 'Failed to load scoreboard');
|
this._error.set(err?.message ?? 'Failed to load scoreboard');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
RankingRow,
|
RankingRow,
|
||||||
applyRankingSort,
|
applyRankingSort,
|
||||||
applySolveToGraph,
|
applySolveToGraph,
|
||||||
|
assignUniquePlayerColors,
|
||||||
dedupEventLogBySolveId,
|
dedupEventLogBySolveId,
|
||||||
formatSolveDateTime,
|
formatSolveDateTime,
|
||||||
mutateMatrixFromSolve,
|
mutateMatrixFromSolve,
|
||||||
@@ -25,6 +26,52 @@ describe('stablePlayerColorIndex', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('assignUniquePlayerColors', () => {
|
||||||
|
const ids = [
|
||||||
|
'PlayerA',
|
||||||
|
'PlayerB',
|
||||||
|
'PlayerC',
|
||||||
|
'Player04',
|
||||||
|
'Player05',
|
||||||
|
'Player06',
|
||||||
|
'Player07',
|
||||||
|
'Player08',
|
||||||
|
'Player09',
|
||||||
|
'Player10',
|
||||||
|
];
|
||||||
|
|
||||||
|
it('gives the documented colliding id set 10 distinct palette colors', () => {
|
||||||
|
const rows: RankingRow[] = ids.map((playerId) => ({
|
||||||
|
rank: 0,
|
||||||
|
playerId,
|
||||||
|
playerName: playerId,
|
||||||
|
solvedCount: 1,
|
||||||
|
points: 0,
|
||||||
|
colorIndex: stablePlayerColorIndex(playerId),
|
||||||
|
}));
|
||||||
|
const unique = assignUniquePlayerColors(rows, (r) => r.colorIndex);
|
||||||
|
expect(unique.length).toBe(ids.length);
|
||||||
|
const paletteHits = new Set(unique.map((r) => PLAYER_COLOR_PALETTE[r.colorIndex]));
|
||||||
|
expect(paletteHits.size).toBe(PLAYER_COLOR_PALETTE.length);
|
||||||
|
unique.forEach((r) => {
|
||||||
|
expect(r.colorIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(r.colorIndex).toBeLessThan(PLAYER_COLOR_PALETTE.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the hashed index when there is no collision', () => {
|
||||||
|
const rows: RankingRow[] = [
|
||||||
|
{ rank: 0, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 10, colorIndex: stablePlayerColorIndex('p1') },
|
||||||
|
{ rank: 0, playerId: 'p2', playerName: 'p2', solvedCount: 1, points: 5, colorIndex: stablePlayerColorIndex('p2') },
|
||||||
|
];
|
||||||
|
const hashed1 = rows[0].colorIndex;
|
||||||
|
const hashed2 = rows[1].colorIndex;
|
||||||
|
const unique = assignUniquePlayerColors(rows, (r) => r.colorIndex);
|
||||||
|
expect(unique[0].colorIndex).toBe(hashed1);
|
||||||
|
expect(unique[1].colorIndex).toBe(hashed2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('applyRankingSort', () => {
|
describe('applyRankingSort', () => {
|
||||||
it('produces competition ranks with zero-pt rows at the bottom', () => {
|
it('produces competition ranks with zero-pt rows at the bottom', () => {
|
||||||
const rows: RankingRow[] = [
|
const rows: RankingRow[] = [
|
||||||
@@ -114,6 +161,47 @@ describe('applySolveToGraph', () => {
|
|||||||
);
|
);
|
||||||
expect(next.series).toEqual([]);
|
expect(next.series).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('yields 10 unique polyline colors for the documented colliding id set', () => {
|
||||||
|
const ids = [
|
||||||
|
'PlayerA',
|
||||||
|
'PlayerB',
|
||||||
|
'PlayerC',
|
||||||
|
'Player04',
|
||||||
|
'Player05',
|
||||||
|
'Player06',
|
||||||
|
'Player07',
|
||||||
|
'Player08',
|
||||||
|
'Player09',
|
||||||
|
'Player10',
|
||||||
|
];
|
||||||
|
const start = '2025-01-01T00:00:00.000Z';
|
||||||
|
const end = '2025-01-02T00:00:00.000Z';
|
||||||
|
let view: any = null;
|
||||||
|
ids.forEach((playerId, idx) => {
|
||||||
|
const awarded = 100 - idx * 5;
|
||||||
|
view = applySolveToGraph(
|
||||||
|
view,
|
||||||
|
{
|
||||||
|
challengeId: `c${idx}`,
|
||||||
|
challengeName: `ch${idx}`,
|
||||||
|
categoryAbbreviation: 'CRY',
|
||||||
|
playerId,
|
||||||
|
playerName: playerId,
|
||||||
|
awardedPoints: awarded,
|
||||||
|
rankBonus: 0,
|
||||||
|
awardedAtUtc: `2025-01-01T01:00:0${idx}.000Z`,
|
||||||
|
position: idx + 1,
|
||||||
|
},
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
'2025-01-01T12:00:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(view.series.length).toBe(10);
|
||||||
|
const paletteHits = new Set(view.series.map((s: any) => PLAYER_COLOR_PALETTE[s.colorIndex]));
|
||||||
|
expect(paletteHits.size).toBe(10);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mutateMatrixFromSolve', () => {
|
describe('mutateMatrixFromSolve', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user