Files
HIPCTF2/.kilo/plans/911.md
T

9.2 KiB

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:

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():

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