Files
HIPCTF2/.kilo/plans/865-matrix-solved-sentinel.md
T

6.0 KiB

Plan: Scoreboard Matrix — Distinguish "solved (not top-3)" from "not solved"

Goal

In the Matrix view, a cell must render a green checkmark for any solve (positions 1, 2, 3, and 4+) and stay empty only when the player genuinely has not solved the challenge. Today the backend stores positions > 3 as null (skipped at backend/src/modules/challenges/scoreboard.service.ts:87), so the frontend can't tell "solved but not top-3" apart from "never solved". We introduce a 'solved' sentinel so the frontend can always render ✓ for a real solve.

1. Type changes

1.1 Backend

backend/src/modules/challenges/dto/scoreboard.dto.ts:21:

export type MatrixCellRank = 1 | 2 | 3 | null;

export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;

1.2 Frontend

frontend/src/app/features/scoreboard/scoreboard.pure.ts:27:

export type MatrixCellRank = 1 | 2 | 3 | null;

→ identical widening to 1 | 2 | 3 | 'solved' | null.

Both types must stay in lock-step so the controller JSON, the store, and the matrix component all agree on the new sentinel.

2. Backend projection

backend/src/modules/challenges/scoreboard.service.ts:83-96 — the per-challenge solver walk currently does:

for (let i = 0; i < list.length; i += 1) {
  const position = i + 1;
  if (position > 3) continue;          // <- drops positions > 3
  ...
  perPlayer.set(challengeId, position as MatrixCellRank);
}

Replace the if (position > 3) continue; with:

const value: MatrixCellRank = position <= 3 ? (position as 1|2|3) : 'solved';
perPlayer.set(challengeId, value);

Everything else in this loop (player-row creation, map writes) stays unchanged. The cells map therefore now contains a row for every solver of every challenge, with 1|2|3|'solved'|null.

3. Frontend live mutation

frontend/src/app/features/scoreboard/scoreboard.pure.ts:246-248 (mutateMatrixFromSolve) — currently only writes for positions 1-3:

if (payload.position >= 1 && payload.position <= 3) {
  cellRow[payload.challengeId] = payload.position as MatrixCellRank;
}

Replace with:

if (payload.position >= 1 && payload.position <= 3) {
  cellRow[payload.challengeId] = payload.position as 1 | 2 | 3;
} else if (payload.position >= 4) {
  cellRow[payload.challengeId] = 'solved';
}

(no else branch — for position <= 0 we leave the cell alone, same as today.) With this, a 4th-or-later live solve correctly flips a blank cell to the green ✓.

4. Frontend rendering

frontend/src/app/features/scoreboard/matrix.component.ts:86-95 — currently the template uses *ngSwitchDefault for non-top-3 cells and re-derives "is this player solved" via a helper method. Now the sentinel tells us directly:

<ng-container [ngSwitch]="view.cells[p.playerId]?.[ch.id]">
  <span *ngSwitchCase="1" class="cell-gold" title="1st solver"></span>
  <span *ngSwitchCase="2" class="cell-silver" title="2nd solver"></span>
  <span *ngSwitchCase="3" class="cell-bronze" title="3rd solver"></span>
  <span *ngSwitchCase="'solved'" class="cell-check" title="solved"></span>
  <span *ngSwitchDefault></span>
</ng-container>

Changes vs today:

  • Add an explicit *ngSwitchCase="'solved'" branch that renders ✓ with class="cell-check" (the green color already defined at matrix.component.ts:32).
  • Remove the *ngSwitchDefault body (which used cellSolved() and a nested <ng-template #blank>). With the sentinel there is no ambiguity — null means empty, 'solved' means checkmark.
  • The cellSolved(playerId, challengeId) helper method on MatrixComponent becomes dead code (its only caller is the template branch we removed) → delete it.

5. Test updates

5.1 tests/backend/scoreboard.service.spec.ts

Update the matrix assertion at the "marks top-3 positions" case — currently:

expect(matrix.cells['u-d']?.['c1']).toBeNull();

Change to:

expect(matrix.cells['u-d']?.['c1']).toBe('solved');

(u-d is the 4th solver of c1 in that test, so this validates the new sentinel.) u-b and u-c stay as 2 and 3 respectively. The matrix cell-shape test for the controller (tests/backend/scoreboard-controller.spec.ts) does not assert on the >3 sentinel and needs no change.

5.2 tests/frontend/scoreboard.pure.spec.ts

Update mutateMatrixFromSolve test:

const next = mutateMatrixFromSolve(matrix, {
  ...
  position: 5,
});
expect(next!.cells.p2.c1).toBe('solved');

(Was position: 2 expecting 2; now uses position: 5 to prove the new branch fires.)

6. Files touched

  • backend/src/modules/challenges/dto/scoreboard.dto.ts (type only)
  • backend/src/modules/challenges/scoreboard.service.ts (one block, ~3 lines)
  • frontend/src/app/features/scoreboard/scoreboard.pure.ts (type + mutateMatrixFromSolve)
  • frontend/src/app/features/scoreboard/matrix.component.ts (template + remove cellSolved helper)
  • tests/backend/scoreboard.service.spec.ts (one assertion)
  • tests/frontend/scoreboard.pure.spec.ts (one assertion)

7. Implementation order

  1. Widen MatrixCellRank in backend DTO and frontend pure types.
  2. Update backend getMatrix to store 'solved' for positions > 3.
  3. Update mutateMatrixFromSolve to write 'solved' for live 4+ solves.
  4. Update matrix.component.ts template (add 'solved' case, drop default helper branch) and delete cellSolved().
  5. Update the two test assertions.
  6. Run npm test (all 590+ tests must pass) and npm run build.

8. Compatibility & risk

  • Public API contract: cells JSON gains a new string literal value 'solved'. Older clients that already only branch on 1|2|3|null would render an unknown cell as empty (because of ngSwitchDefault falling back). To keep current behaviour while migrating, the template change MUST land in the same commit as the backend change (single-tenant SPA, no external consumers — safe).
  • No DB or migration change; the sentinel is purely a projection change over the existing solve table.
  • No impact on Ranking, Event Log, or Score Graph — they do not use MatrixCellRank.