AI Implementation feature(911): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.01 #54

Merged
m0rph3us1987 merged 2 commits from feature-911-1784785972316 into dev 2026-07-23 06:03:14 +00:00
7 changed files with 365 additions and 56 deletions
-39
View File
@@ -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 stores 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`.
+202
View File
@@ -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/`).
+33 -8
View File
@@ -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 13, otherwise `'solved'`. `2`, or `3` if the new `position` is in 13, 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
View File
@@ -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 {
+88
View File
@@ -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', () => {