#98: de-conflate fetchLeaderboard load from initials-entry eligibility #106
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/98-eligibility-deconflation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this does
fetchLeaderboardconflated two concerns — load the board for display and decide initials-entry eligibility (mutatinginitialsPhaseas 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 werefetchLeaderboard@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 theif (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), returnsboolean(loaded ok?). NoinitialsPhasemutation.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.phase==='gameover' && lastLoopPhase!=='gameover') replaces the level-trigger. The PLAY-AGAIN →startSolowindow (leaderboard reset tonullwhile 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).(0)arg and wrong comment (M2).Acceptance criteria
initialsPhase='idle'(M1 harness row)'entering'(M2 harness row)fetchLeaderboardstill populatesleaderboardidentically;render.tsuntouchedVerification (full closed loop)
npx tsc --noEmitclean (vite build skips tsc).fetchLeaderboard→ M2 reds, M1 stays green (isolates the title-L path to the side-effect).enterSoloPlayAgainRace): the real race needs a server-lagged WS the mock can't produce (startSolo's mock path sets phase synchronously), so — mirroring #87'sforceGameover— 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 wheninitialsPhasebecomes'entering', whichrenderalready 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
lastLoopPhase = state.phaseupdates inside the gameplayelsebranch (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.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
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 returningboolean, noinitialsPhasemutation; theleaderboardpopulation 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, guardsleaderboard===null.fetchLeaderboard()— no eligibility side-effect, the wrong(0)arg + wrong comment gone.phase==='gameover' && lastLoopPhase!=='gameover'). The PLAY-AGAIN →startSolowindow (leaderboard reset to null while the server-driven phase still reads a stale'gameover') has no rising edge —lastLoopPhaseis already'gameover'— so it can't re-fire.leaderboard===nullskip), so the current game's score always decides;finalScorecaptured synchronously before the async.Flagged item #1 — edge-trigger placement: verified sound
lastLoopPhase = state.phaselives in the gameplayelsebranch (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, thenlastLoopPhase='gameover'blocks re-fire next frame; in the PLAY-AGAIN windowlastLoopPhaseis already'gameover'→ no re-fire; the freeze is harmless because the first gameplay frame re-syncslastLoopPhasebefore 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
tsc --noEmitexit 0, CI combined-success onc96c127, on current main (only open PR).leaderboard===null) → M1(32) reds, M2(31) green (isolates the race to the edge-vs-level distinction);fetchLeaderboard→ M2(31) reds, M1(32) green (isolates title-L to the side-effect).Each reverted precisely; diff empty after each.
leaderboardLoaded===true && initialsPhase==='idle') — it passes on the fix and reds under the side-effect mutation. M1's race is stamped byenterSoloPlayAgainRace(mirroring the #87forceGameoverpattern — the server-lag the mock can't produce), with the routed<10board giving the would-be re-fetch teeth.Process — verified-on-source-first
Good
filed-rootcause-is-hypothesisdiscipline: you re-traced my:596/:1000refs (shifted to:688/:1145post-#87-merge) and confirmed each mechanism on source before building. And the observation that thestate.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 wheninitialsPhasebecomes'entering'(state), adds no visible elements,render.tsuntouched; 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. 🔧