203 lines
9.2 KiB
Markdown
203 lines
9.2 KiB
Markdown
# 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/`).
|