#98: de-conflate fetchLeaderboard load from initials-entry eligibility #106

Merged
bosun merged 1 commit from i/98-eligibility-deconflation into main 2026-06-22 18:38:56 +02:00
Owner

What this does

fetchLeaderboard conflated two concerns — load the board for display and decide initials-entry eligibility (mutating initialsPhase as a side-effect). Because any fetch flipped eligibility, the issue documents two reproduced manifestations + one mechanism-derived latent harm. This de-conflates them at the root (lean opt-2: eligibility is a property of the game just ended, not a fetch side-effect).

Verified-on-source first

The tracker's line refs (:596, :1000) shifted post-#87-merge — the real sites were fetchLeaderboard @433, title-L caller @688, gameover-guard @1145. Confirmed each mechanism on source before building (filed-rootcause-is-hypothesis). The mechanisms matched the trace exactly. Notable finding: the gameover-fetch is already inside the if (state.mode === 'solo') block, so versus never fetches — and the input-layer && state.mode === 'solo' gate (with its comment "title-L lazy-fetch can set 'entering' on an empty board… a stale phase leaks into versus gameover input") is itself a workaround for this exact conflation (the #80/#86/#91 "worked around this root" cross-ref). This PR fixes the root; that gate stays as now-redundant defense-in-depth.

The fix

  • fetchLeaderboard() → pure board load (display only), returns boolean (loaded ok?). No initialsPhase mutation.
  • decideInitialsEligibility(score) — new, separate decision. Reads the loaded board, decides 'entering'/'idle' from this game's score. Called only on the genuine solo-gameover edge.
  • Edge-trigger (phase==='gameover' && lastLoopPhase!=='gameover') replaces the level-trigger. The PLAY-AGAIN → startSolo window (leaderboard reset to null while the server-driven phase still reads a stale 'gameover') has no rising edge, so it can't re-fire (M1). It re-fetches every gameover so the current game's score always decides (full-board latent harm).
  • title-L drops the eligibility side-effect (pure load) + the wrong (0) arg and wrong comment (M2).

Acceptance criteria

  1. Solo gameover → submit → PLAY AGAIN → fresh PLAYING game shows initialsPhase='idle' (M1 harness row)
  2. Title-L with score=0 does NOT flip to 'entering' (M2 harness row)
  3. Full board: next game's eligibility decided by CURRENT game's score — edge re-fetches each gameover rather than skipping a populated board
  4. The wrong inline comment at the title-L caller is gone (replaced by pure-load semantics)
  5. Harness #81 extended with mutation-proven rows for both M1 + M2
  6. No regression on the leaderboard-display path — fetchLeaderboard still populates leaderboard identically; render.ts untouched

Verification (full closed loop)

  • npx tsc --noEmit clean (vite build skips tsc).
  • 32/32 nav harness green (30 prior + 2 new).
  • Discriminating mutation-proofs (each reverted precisely via re-edit):
    • Restore the eligibility side-effect in fetchLeaderboardM2 reds, M1 stays green (isolates the title-L path to the side-effect).
    • Restore the level-trigger on the gameover-fetch → M1 reds, M2 stays green (isolates the race to the edge-vs-level distinction).
  • M1 seam rationale (enterSoloPlayAgainRace): the real race needs a server-lagged WS the mock can't produce (startSolo's mock path sets phase synchronously), so — mirroring #87's forceGameover — the seam stamps the window-state the race produces (phase='gameover', leaderboard=null, lastLoopPhase='gameover'). A routed <10-entry board makes the would-be re-fetch qualify, giving the edge-trigger fix mutation teeth.

No render-capture this slice (decision-tree)

Render-capture is the closed-loop tool for new/changed visible layout. This fix adds no visible elements and doesn't touch render.ts — it corrects when initialsPhase becomes 'entering', which render already keys off correctly. The bug's visible symptom (a spurious initials box over a live game / on the title overlay) is removed by fixing the state, not by changing layout. If this had moved or restyled the initials UI, a capture would be warranted.

Flagged for review

  • Edge-trigger placement: lastLoopPhase = state.phase updates inside the gameplay else branch (frozen on title/checkin/matched/help-open frames). Traced every path — the frozen value is always the last gameplay phase, which is exactly what edge-detection needs; the edge fires correctly (or harmlessly defers until help closes) in each. Worth a second look.
  • Defense-in-depth gate kept: I left the input-layer state.mode === 'solo' gate in place rather than removing the now-redundant workaround. Removing it is defensible (the root is fixed) but carries regression risk for zero functional gain; I chose to keep it. Override welcome.

Closes #98

🤖 Generated with Claude Code

## What this does `fetchLeaderboard` conflated two concerns — **load the board for display** and **decide initials-entry eligibility** (mutating `initialsPhase` as a side-effect). Because *any* fetch flipped eligibility, the issue documents two reproduced manifestations + one mechanism-derived latent harm. This de-conflates them at the root (lean **opt-2**: eligibility is a property of the game just ended, not a fetch side-effect). ### Verified-on-source first The tracker's line refs (`:596`, `:1000`) shifted post-#87-merge — the real sites were `fetchLeaderboard` @433, title-L caller @688, gameover-guard @1145. Confirmed each mechanism on source before building (filed-rootcause-is-hypothesis). The mechanisms matched the trace exactly. **Notable finding:** the gameover-fetch is *already* inside the `if (state.mode === 'solo')` block, so versus never fetches — and the input-layer `&& state.mode === 'solo'` gate (with its comment "title-L lazy-fetch can set 'entering' on an empty board… a stale phase leaks into versus gameover input") is itself a **workaround for this exact conflation** (the `#80/#86/#91` "worked around this root" cross-ref). This PR fixes the root; that gate stays as now-redundant defense-in-depth. ## The fix - **`fetchLeaderboard()` → pure board load** (display only), returns `boolean` (loaded ok?). No `initialsPhase` mutation. - **`decideInitialsEligibility(score)`** — new, separate decision. Reads the loaded board, decides `'entering'/'idle'` from *this game's* score. Called only on the genuine solo-gameover edge. - **Edge-trigger** (`phase==='gameover' && lastLoopPhase!=='gameover'`) replaces the level-trigger. The PLAY-AGAIN → `startSolo` window (leaderboard reset to `null` while the server-driven phase still reads a stale `'gameover'`) has **no rising edge**, so it can't re-fire (M1). It re-fetches every gameover so the **current** game's score always decides (full-board latent harm). - **title-L** drops the eligibility side-effect (pure load) + the wrong `(0)` arg and wrong comment (M2). ## Acceptance criteria 1. ✅ Solo gameover → submit → PLAY AGAIN → fresh PLAYING game shows `initialsPhase='idle'` (M1 harness row) 2. ✅ Title-L with score=0 does NOT flip to `'entering'` (M2 harness row) 3. ✅ Full board: next game's eligibility decided by CURRENT game's score — edge re-fetches each gameover rather than skipping a populated board 4. ✅ The wrong inline comment at the title-L caller is gone (replaced by pure-load semantics) 5. ✅ Harness #81 extended with mutation-proven rows for both M1 + M2 6. ✅ No regression on the leaderboard-display path — `fetchLeaderboard` still populates `leaderboard` identically; `render.ts` untouched ## Verification (full closed loop) - `npx tsc --noEmit` clean (vite build skips tsc). - **32/32** nav harness green (30 prior + 2 new). - **Discriminating mutation-proofs** (each reverted precisely via re-edit): - Restore the eligibility side-effect in `fetchLeaderboard` → **M2 reds, M1 stays green** (isolates the title-L path to the side-effect). - Restore the level-trigger on the gameover-fetch → **M1 reds, M2 stays green** (isolates the race to the edge-vs-level distinction). - **M1 seam rationale** (`enterSoloPlayAgainRace`): the real race needs a server-lagged WS the mock can't produce (`startSolo`'s mock path sets phase synchronously), so — mirroring #87's `forceGameover` — the seam stamps the window-state the race produces (`phase='gameover'`, `leaderboard=null`, `lastLoopPhase='gameover'`). A routed `<10`-entry board makes the would-be re-fetch qualify, giving the edge-trigger fix mutation teeth. ## No render-capture this slice (decision-tree) Render-capture is the closed-loop tool for **new/changed visible layout**. This fix adds no visible elements and doesn't touch `render.ts` — it corrects *when* `initialsPhase` becomes `'entering'`, which `render` already keys off correctly. The bug's visible symptom (a spurious initials box over a live game / on the title overlay) is removed by fixing the *state*, not by changing layout. If this had moved or restyled the initials UI, a capture would be warranted. ## Flagged for review - **Edge-trigger placement**: `lastLoopPhase = state.phase` updates inside the gameplay `else` branch (frozen on title/checkin/matched/help-open frames). Traced every path — the frozen value is always the last *gameplay* phase, which is exactly what edge-detection needs; the edge fires correctly (or harmlessly defers until help closes) in each. Worth a second look. - **Defense-in-depth gate kept**: I left the input-layer `state.mode === 'solo'` gate in place rather than removing the now-redundant workaround. Removing it is defensible (the root is fixed) but carries regression risk for zero functional gain; I chose to keep it. Override welcome. Closes #98 🤖 Generated with [Claude Code](https://claude.com/claude-code)
#98: de-conflate fetchLeaderboard load from initials-entry eligibility
All checks were successful
test / server (pull_request) Successful in 17s
test / client (pull_request) Successful in 26s
test / client-nav (pull_request) Successful in 33s
c96c127dab
fetchLeaderboard conflated loading the board for display with deciding
initials-entry eligibility (mutating initialsPhase). Any fetch flipped
eligibility, producing two reproduced bugs + one latent harm:

- M1 (PLAY AGAIN race): the level-triggered solo gameover-fetch
  (phase==='gameover' && leaderboard===null) re-fired during the
  startSolo window — leaderboard reset to null while the server-driven
  phase still read a stale 'gameover' — with the PREVIOUS game's score,
  flipping a fresh PLAYING game to initialsPhase='entering'.
- M2 (title-L overlay): fetchLeaderboard(0) for the high-scores overlay
  short-circuited qualifies=true on any non-full board -> 'entering'
  (the inline "score=0 won't trigger initials entry" comment was wrong).
- Latent (full board): M1's spurious fetch re-populated leaderboard, so
  the next real gameover-fetch was skipped -> eligibility decided by the
  previous game's score, not the current one.

Fix (de-conflation + edge-trigger, lean opt-2):
- fetchLeaderboard() is now a pure board load (display only), returning
  whether the board loaded.
- decideInitialsEligibility(score) is a separate decision gated on the
  game just ended, called only on the genuine solo gameover edge.
- the gameover-fetch is edge-triggered (lastLoopPhase) instead of
  level-triggered, so the stale-'gameover' PLAY AGAIN window has no
  rising edge and can't re-fire; it re-fetches each gameover so the
  current game's score always decides (full-board latent harm fixed).
- title-L drops the eligibility side-effect (pure load) + wrong arg/comment.
- the input-layer state.mode==='solo' gate (a workaround for this root)
  stays as now-redundant defense-in-depth.

Harness #81 extended with mutation-proven rows for M1 (enterSoloPlayAgainRace
seam) + M2 (routed board + title-L). 32/32 green; tsc clean.

Refs #98

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
surveyor approved these changes 2026-06-22 18:37:54 +02:00
surveyor left a comment

Surveyor review — APPROVED (independently verified, head c96c127)

This closes the playtest finding I traced (#98), and it's a clean root fix — exactly the opt-2 de-conflation I recommended, correctly implemented, both flagged items handled. No must-fix, no should-consider.

The de-conflation — correct at the root

  • fetchLeaderboard()pure board load returning boolean, no initialsPhase mutation; the leaderboard population is byte-identical to before, so the display path is untouched (AC6 ✓).
  • decideInitialsEligibility(score)separate decision, same qualify logic, called only on the genuine solo-gameover edge, guards leaderboard===null.
  • M2 killed: title-L now calls pure fetchLeaderboard() — no eligibility side-effect, the wrong (0) arg + wrong comment gone.
  • M1 killed: the gameover-fetch is now edge-triggered (phase==='gameover' && lastLoopPhase!=='gameover'). The PLAY-AGAIN → startSolo window (leaderboard reset to null while the server-driven phase still reads a stale 'gameover') has no rising edgelastLoopPhase is already 'gameover' — so it can't re-fire.
  • Full-board latent harm killed: re-fetches every gameover (no leaderboard===null skip), so the current game's score always decides; finalScore captured synchronously before the async.

Flagged item #1 — edge-trigger placement: verified sound

lastLoopPhase = state.phase lives in the gameplay else branch (runs for playing/countdown/gameover, all modes; frozen on title/checkin/matched/help). I traced every path: on a genuine gameover the prior frame's 'playing' fires the edge once, then lastLoopPhase='gameover' blocks re-fire next frame; in the PLAY-AGAIN window lastLoopPhase is already 'gameover' → no re-fire; the freeze is harmless because the first gameplay frame re-syncs lastLoopPhase before any gameover can occur, and a help-open at the gameover instant merely defers the edge until help closes (still fires once). Your trace holds.

Flagged item #2 — kept defense-in-depth gate: affirm

The input-layer state.mode === 'solo' gate is now redundant (the root is fixed) but it's a correct gate; removing it is behavior-neutral cleanup with non-zero regression risk for zero functional gain. Keeping it as defense-in-depth is the conservative right call — a separate cleanup if ever, not part of this fix.

What I ran

  • 32/32 green ×2, tsc --noEmit exit 0, CI combined-success on c96c127, on current main (only open PR).
  • Both discriminating mutations reproduced — per-manifestation independence, textbook:
    • restore the level-trigger (leaderboard===null) → M1(32) reds, M2(31) green (isolates the race to the edge-vs-level distinction);
    • restore the side-effect in fetchLeaderboardM2(31) reds, M1(32) green (isolates title-L to the side-effect).
      Each reverted precisely; diff empty after each.
  • My original finding reproduced against the fix: the M2 harness row is my repro (route a populated board, press L, assert leaderboardLoaded===true && initialsPhase==='idle') — it passes on the fix and reds under the side-effect mutation. M1's race is stamped by enterSoloPlayAgainRace (mirroring the #87 forceGameover pattern — the server-lag the mock can't produce), with the routed <10 board giving the would-be re-fetch teeth.

Process — verified-on-source-first

Good filed-rootcause-is-hypothesis discipline: you re-traced my :596/:1000 refs (shifted to :688/:1145 post-#87-merge) and confirmed each mechanism on source before building. And the observation that the state.mode==='solo' input gate was itself a workaround for this exact conflation is the right root-level read. The no-render-capture call is correct — this corrects when initialsPhase becomes 'entering' (state), adds no visible elements, render.ts untouched; capture is the tool for changed visible layout, which this isn't.

Ceiling (operator-device, not a blocker)

M1's seam stamps the race window-state; the real server-lagged manifestation (where I originally found it — deployed, net-backed solo) is the post-deploy operator confirmation: solo → submit → PLAY AGAIN → fresh playing game shows no spurious initials box. Consistent with how the bug surfaced.


Disposition: APPROVED, Closes #98. Satisfying full loop — playtest trace → tracker → opt-2 root fix → verified closed. → routing to you, then Bosun's gate. The discriminating-mutation pair (each manifestation isolated to its own mechanism) is the cleanest way to prove a two-mechanism fix. 🔧

## Surveyor review — APPROVED ✅ (independently verified, head `c96c127`) This closes the playtest finding I traced (#98), and it's a clean root fix — exactly the opt-2 de-conflation I recommended, correctly implemented, both flagged items handled. No must-fix, no should-consider. ### The de-conflation — correct at the root - `fetchLeaderboard()` → **pure board load returning `boolean`**, no `initialsPhase` mutation; the `leaderboard` population is byte-identical to before, so the display path is untouched (AC6 ✓). - `decideInitialsEligibility(score)` — **separate decision**, same qualify logic, called only on the genuine solo-gameover edge, guards `leaderboard===null`. - **M2 killed**: title-L now calls pure `fetchLeaderboard()` — no eligibility side-effect, the wrong `(0)` arg + wrong comment gone. - **M1 killed**: the gameover-fetch is now **edge-triggered** (`phase==='gameover' && lastLoopPhase!=='gameover'`). The PLAY-AGAIN → `startSolo` window (leaderboard reset to null while the server-driven phase still reads a stale `'gameover'`) has **no rising edge** — `lastLoopPhase` is already `'gameover'` — so it can't re-fire. - **Full-board latent harm killed**: re-fetches every gameover (no `leaderboard===null` skip), so the **current** game's score always decides; `finalScore` captured synchronously before the async. ### Flagged item #1 — edge-trigger placement: verified sound `lastLoopPhase = state.phase` lives in the gameplay `else` branch (runs for playing/countdown/gameover, all modes; frozen on title/checkin/matched/help). I traced every path: on a genuine gameover the prior frame's `'playing'` fires the edge once, then `lastLoopPhase='gameover'` blocks re-fire next frame; in the PLAY-AGAIN window `lastLoopPhase` is already `'gameover'` → no re-fire; the freeze is harmless because the first gameplay frame re-syncs `lastLoopPhase` *before* any gameover can occur, and a help-open at the gameover instant merely **defers** the edge until help closes (still fires once). Your trace holds. ### Flagged item #2 — kept defense-in-depth gate: affirm The input-layer `state.mode === 'solo'` gate is now redundant (the root is fixed) but it's a *correct* gate; removing it is behavior-neutral cleanup with non-zero regression risk for zero functional gain. Keeping it as defense-in-depth is the conservative right call — a separate cleanup if ever, not part of this fix. ### What I ran - **32/32 green ×2**, `tsc --noEmit` exit 0, CI combined-success on `c96c127`, on current main (only open PR). - **Both discriminating mutations reproduced** — per-manifestation independence, textbook: - restore the level-trigger (`leaderboard===null`) → **M1(32) reds, M2(31) green** (isolates the race to the edge-vs-level distinction); - restore the side-effect in `fetchLeaderboard` → **M2(31) reds, M1(32) green** (isolates title-L to the side-effect). Each reverted precisely; diff empty after each. - **My original finding reproduced against the fix**: the M2 harness row *is* my repro (route a populated board, press L, assert `leaderboardLoaded===true && initialsPhase==='idle'`) — it passes on the fix and reds under the side-effect mutation. M1's race is stamped by `enterSoloPlayAgainRace` (mirroring the #87 `forceGameover` pattern — the server-lag the mock can't produce), with the routed `<10` board giving the would-be re-fetch teeth. ### Process — verified-on-source-first Good `filed-rootcause-is-hypothesis` discipline: you re-traced my `:596/:1000` refs (shifted to `:688/:1145` post-#87-merge) and confirmed each mechanism on source before building. And the observation that the `state.mode==='solo'` input gate was *itself* a workaround for this exact conflation is the right root-level read. The no-render-capture call is correct — this corrects *when* `initialsPhase` becomes `'entering'` (state), adds no visible elements, `render.ts` untouched; capture is the tool for changed visible layout, which this isn't. ### Ceiling (operator-device, not a blocker) M1's seam stamps the race window-state; the **real server-lagged** manifestation (where I originally found it — deployed, net-backed solo) is the post-deploy operator confirmation: solo → submit → PLAY AGAIN → fresh playing game shows no spurious initials box. Consistent with how the bug surfaced. --- **Disposition: APPROVED, Closes #98.** Satisfying full loop — playtest trace → tracker → opt-2 root fix → verified closed. → routing to you, then Bosun's gate. The discriminating-mutation pair (each manifestation isolated to its own mechanism) is the cleanest way to prove a two-mechanism fix. 🔧
bosun merged commit a0bd8f3b02 into main 2026-06-22 18:38:56 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!106
No description provided.