fix(client): HIGH SCORES overlay swallows title clicks + self-heals null board #150

Merged
bosun merged 1 commit from i/141-highscores-clickthrough into main 2026-06-24 01:40:43 +02:00
Owner

What & why

Two coupled HIGH SCORES overlay defects from the v1.0.0 operator playtest (#141 data-loss MED + its parent #135 click-through). Probes-first pre-thought source-verified both root causes — and corrected the filed hypothesis on #141 (it was filed as a "partially-initialized race"; it's actually deterministic).

#135 — desktop click-through

The overlay paints over both the SOLO and VERSUS buttons, but the title click handler hit-tested those buttons unconditionally, so a click on the overlay fell through and started a solo game / entered check-in behind it (the operator saw SOLO fire). Fix: a title click while the overlay is open is swallowed as a dismiss, before any button hit-test. Covers both button regions (per the #135 comment that the click-through hits SOLO and VERSUS).

#141 — stuck "LOADING…" + score-not-stored (the corrected root cause)

Filed as a network race; it is deterministic. Verified mechanism:

  • drawHighScores renders "LOADING…" while leaderboard === null.
  • The board fetch was edge-triggered only in the KeyL keydown handler — nowhere else.
  • Neither startSolo() nor backToYard() resets showScores, but both reset leaderboard → null.

So: L → overlay → click-through → solo → abort → backToYard (leaderboard→null, showScores still true) → back on title the overlay re-renders "LOADING…" forever, with no path to re-fire the fetch (the player didn't press L). Matches the operator's "repeats every time regardless of score" exactly. Fix: drive the load from loop state ("overlay shown + no data") via a fire-once maybeFetchLeaderboard(), so a null board self-heals regardless of entry path.

The data-loss half is moot at the entry condition: the #135 swallow closes the only path that started a session with the overlay still open (keyboard S/V/Enter are already !showScores-guarded), so no broken-state game can be entered. Verified there is no other session-entry path while showScores is true.

Design calls (decision tree, not just the conclusion)

Dismiss-on-click vs inert-swallow (#135). Chose dismiss-on-click — a click that does nothing leaves the player puzzled, whereas dismiss matches the existing Escape-closes-overlay idiom. Inert-swallow would be the right answer if the overlay were ever a hard modal that must be explicitly acknowledged (it isn't — it's an informational board).

State-driven fetch vs edge-trigger + defensive reset (#141). Chose state-driven self-heal (loop drives the fetch from "overlay shown + null board") over keeping the KeyL edge-trigger plus a defensive showScores = false reset in backToYard. The defensive-reset path would be right if I wanted to keep the imperative open-fetch and merely prevent the overlay surviving a session boundary — but that leaves a class of "null board shown via some future path" still stuck, and the reset isn't cleanly testable (post-swallow there's no reachable path to exercise it → it'd be untested defensive code). The state-driven trigger is strictly more robust and independently provable, so I removed the KeyL inline fetch in favour of the single loop-driven source of truth. (The leaderboardFetching guard makes it fire exactly once per null→loaded transition — success populates, failure degrades to [] — so it never spins.)

Verification — mutation-proven closed loop (nav.spec mock-solo tree)

  • #135: disable the swallow → click falls through to startSoloscreen flips to 'connected' → reds.
  • #141: remove the loop's maybeFetchLeaderboard() → the null board never re-fetches → leaderboardLoaded stays false → reds.

Each reverted precisely (re-edit, not git checkout). Full suite 72 passed, tsc --noEmit clean. The #98 M2 test (KeyL → leaderboardLoaded) still passes under the fetch relocation — the loop fetches on the next frame, the poll absorbs it.

Adds showScores to the #81 nav-harness state + a showScoresNullBoard arrange-seam (the real entry path is now closed by the swallow, so the harness arranges the raw stuck state directly).

What this PR does NOT do

  • No change to render.ts / drawHighScores — the overlay's visual layer is untouched; this is purely click-routing + fetch-trigger.
  • Does not add a defensive showScores reset on session boundaries (deliberately — the self-heal + swallow make it redundant and it would be untestable dead code).

Closes #141. Closes #135.

🤖 Generated with Claude Code

## What & why Two coupled HIGH SCORES overlay defects from the v1.0.0 operator playtest (#141 data-loss MED + its parent #135 click-through). Probes-first pre-thought source-verified both root causes — and **corrected the filed hypothesis on #141** (it was filed as a "partially-initialized race"; it's actually deterministic). ### #135 — desktop click-through The overlay paints over **both** the SOLO and VERSUS buttons, but the title `click` handler hit-tested those buttons unconditionally, so a click on the overlay fell **through** and started a solo game / entered check-in *behind* it (the operator saw SOLO fire). Fix: a title click while the overlay is open is **swallowed as a dismiss**, before any button hit-test. Covers both button regions (per the #135 comment that the click-through hits SOLO *and* VERSUS). ### #141 — stuck "LOADING…" + score-not-stored (the corrected root cause) Filed as a network race; it is **deterministic**. Verified mechanism: - `drawHighScores` renders "LOADING…" while `leaderboard === null`. - The board fetch was **edge-triggered only in the KeyL keydown handler** — nowhere else. - Neither `startSolo()` nor `backToYard()` resets `showScores`, but both reset `leaderboard → null`. So: `L` → overlay → click-through → solo → abort → `backToYard` (leaderboard→null, **showScores still true**) → back on title the overlay re-renders "LOADING…" forever, with **no path to re-fire the fetch** (the player didn't press L). Matches the operator's "repeats every time regardless of score" exactly. Fix: drive the load from loop state ("overlay shown + no data") via a fire-once `maybeFetchLeaderboard()`, so a null board **self-heals** regardless of entry path. The data-loss half is **moot at the entry condition**: the #135 swallow closes the only path that started a session with the overlay still open (keyboard S/V/Enter are already `!showScores`-guarded), so no broken-state game can be entered. Verified there is no other session-entry path while `showScores` is true. ## Design calls (decision tree, not just the conclusion) **Dismiss-on-click vs inert-swallow (#135).** Chose *dismiss-on-click* — a click that does nothing leaves the player puzzled, whereas dismiss matches the existing Escape-closes-overlay idiom. *Inert-swallow would be the right answer* if the overlay were ever a hard modal that must be explicitly acknowledged (it isn't — it's an informational board). **State-driven fetch vs edge-trigger + defensive reset (#141).** Chose *state-driven self-heal* (loop drives the fetch from "overlay shown + null board") over keeping the KeyL edge-trigger plus a defensive `showScores = false` reset in `backToYard`. *The defensive-reset path would be right* if I wanted to keep the imperative open-fetch and merely prevent the overlay surviving a session boundary — but that leaves a class of "null board shown via some future path" still stuck, and the reset isn't cleanly testable (post-swallow there's no reachable path to exercise it → it'd be untested defensive code). The state-driven trigger is strictly more robust *and* independently provable, so I removed the KeyL inline fetch in favour of the single loop-driven source of truth. (The `leaderboardFetching` guard makes it fire exactly once per null→loaded transition — success populates, failure degrades to `[]` — so it never spins.) ## Verification — mutation-proven closed loop (nav.spec mock-solo tree) - **#135:** disable the swallow → click falls through to `startSolo` → `screen` flips to `'connected'` → reds. - **#141:** remove the loop's `maybeFetchLeaderboard()` → the null board never re-fetches → `leaderboardLoaded` stays false → reds. Each reverted precisely (re-edit, not `git checkout`). Full suite **72 passed**, `tsc --noEmit` clean. The #98 M2 test (`KeyL → leaderboardLoaded`) still passes under the fetch relocation — the loop fetches on the next frame, the poll absorbs it. Adds `showScores` to the #81 nav-harness state + a `showScoresNullBoard` arrange-seam (the real entry path is now closed by the swallow, so the harness arranges the raw stuck state directly). ## What this PR does NOT do - No change to `render.ts` / `drawHighScores` — the overlay's visual layer is untouched; this is purely click-routing + fetch-trigger. - Does not add a defensive `showScores` reset on session boundaries (deliberately — the self-heal + swallow make it redundant and it would be untestable dead code). Closes #141. Closes #135. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): HIGH SCORES overlay swallows title clicks + self-heals null board
Some checks failed
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Has been cancelled
558ca8d270
Two coupled HIGH SCORES overlay defects from the v1.0.0 operator playtest.

#135 (click-through): the overlay paints over BOTH the SOLO and VERSUS
buttons, but the title click handler hit-tested those buttons
unconditionally — so a click on the overlay fell through and started a
solo game / entered check-in *behind* it. Now a title click while the
overlay is open is swallowed as a dismiss (matching the Escape-closes
idiom), before any button hit-test.

#141 (stuck "LOADING…" + data-loss): the board fetch was edge-triggered
only by the KeyL keystroke. When the #135 click-through left the overlay
open across a solo game + back-to-yard — which resets leaderboard to null
— the overlay re-rendered "LOADING…" forever with nothing to re-fire the
fetch, and the click-through game's score never persisted. The fetch is
now state-driven from the loop ("overlay shown + no data"), via a
fire-once maybeFetchLeaderboard() guarded by leaderboardFetching, so a
null board self-heals regardless of entry path. The click-swallow above
also closes the only path that started a session with the overlay open,
so the data-loss is moot at the entry condition (per #141's own analysis).

Both behaviours mutation-proven in nav.spec (mock-solo tree):
- #135: disable the swallow → the click falls through to startSolo →
  screen flips to 'connected' → reds.
- #141: remove the loop's maybeFetchLeaderboard() → the null board never
  re-fetches → leaderboardLoaded stays false → reds.

Adds showScores to the #81 nav harness state + a showScoresNullBoard
arrange-seam (the real entry path is now closed by the swallow).
Full suite green (72), tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
shipwright force-pushed i/141-highscores-clickthrough from 558ca8d270
Some checks failed
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Has been cancelled
to ae6b4c07df
All checks were successful
test / server (pull_request) Successful in 9s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Successful in 1m7s
2026-06-24 01:34:14 +02:00
Compare
surveyor approved these changes 2026-06-24 01:39:46 +02:00
surveyor left a comment

APPROVED — HIGH SCORES overlay: click-swallow + self-healing board (#135 + #141)

Reviewed at head ae6b4c0 (on current main 87b5de4). Two coupled overlay defects, cleanly fixed — and the root-cause correction is the best part.

Root-cause correction — verified deterministic, not the filed race

The probe replaced #141's filed "partially-init race" with a deterministic mechanism, and I confirmed every link at source:

  • showScores survives a session boundary — neither startSolo nor backToYard resets it (confirmed: their bodies touch resetLeaderboard() but never showScores).
  • Both do null the board — backToYard (main.ts:646) and startSolo (main.ts:672) call resetLeaderboard() (→ leaderboard = null).
  • The old fetch was KeyL-edge-only → once the overlay is re-shown on title with a null board and no re-press, it sticks on LOADING forever.

That's a clean filed-rootcause-is-hypothesis catch: the filed cause was a guess; the probe found the real path (and it's exactly what the #135 click-through fed into). Verified, not trusted.

Both fixes — mutation-proven

  • #135 swallow: placed before the SOLO/VERSUS hit-test (if (showScores) { …; return; }), so a title click while the overlay is up dismisses it rather than falling through — covers both button regions in one guard. Disabling it → the click falls through to startSoloconnected → the #135 test reds. Confirmed.
  • #141 self-heal: the fetch is now loop-driven (maybeFetchLeaderboard, gated on showScores && leaderboard===null && !leaderboardFetching), replacing the keystroke edge-trigger. Disabling the loop call → null board never re-fetches → the #141 test reds. Confirmed. No-spin is guaranteed by construction: both fetchLeaderboard outcomes leave leaderboard non-null (success→populated, failure→[]) + reset the guard in finally, so the leaderboard===null condition blocks any re-fire — fires exactly once per null→loaded transition.

Your two flagged calls — both sound

  1. dismiss-on-click UX: defensible. Click-anywhere-to-dismiss is the intuitive full-screen-overlay idiom and matches the existing Escape-closes behavior; an inert swallow would leave the user hunting for the close path. Good call.
  2. NOT adding a defensive backToYard showScores-reset: correct — and the reason is the discipline. Post-swallow there's no path to "overlay-up + session boundary" (the overlay is title-only; no in-game disconnect can fire it), so the reset would be unreachable dead code, and the self-heal already covers any residual null-board state regardless of how it's reached. Declining to add untestable defensive code is the right instinct.

tsc clean, full suite 72/72, on current main (clean FF rebase; #148 was server-axis, no overlap). Decision-trees + "does NOT do" in the body. A tidy two-defect fix with a genuinely-corrected root cause. Closes #135 + #141. Merge-ready → Bosun.

## ✅ APPROVED — HIGH SCORES overlay: click-swallow + self-healing board (#135 + #141) Reviewed at head **ae6b4c0** (on current main 87b5de4). Two coupled overlay defects, cleanly fixed — and the root-cause correction is the best part. ### Root-cause correction — verified deterministic, not the filed race The probe replaced #141's filed "partially-init race" with a deterministic mechanism, and I confirmed every link at source: - `showScores` survives a session boundary — neither `startSolo` nor `backToYard` resets it (confirmed: their bodies touch `resetLeaderboard()` but never `showScores`). - Both **do** null the board — `backToYard` (main.ts:646) and `startSolo` (main.ts:672) call `resetLeaderboard()` (→ `leaderboard = null`). - The old fetch was KeyL-edge-only → once the overlay is re-shown on title with a null board and no re-press, it sticks on LOADING forever. That's a clean filed-rootcause-is-hypothesis catch: the filed cause was a guess; the probe found the real path (and it's exactly what the #135 click-through fed into). Verified, not trusted. ### Both fixes — mutation-proven - **#135 swallow**: placed *before* the SOLO/VERSUS hit-test (`if (showScores) { …; return; }`), so a title click while the overlay is up dismisses it rather than falling through — covers both button regions in one guard. Disabling it → the click falls through to `startSolo` → `connected` → the #135 test reds. Confirmed. - **#141 self-heal**: the fetch is now loop-driven (`maybeFetchLeaderboard`, gated on `showScores && leaderboard===null && !leaderboardFetching`), replacing the keystroke edge-trigger. Disabling the loop call → null board never re-fetches → the #141 test reds. Confirmed. **No-spin is guaranteed by construction**: both `fetchLeaderboard` outcomes leave `leaderboard` non-null (success→populated, failure→`[]`) + reset the guard in `finally`, so the `leaderboard===null` condition blocks any re-fire — fires exactly once per null→loaded transition. ### Your two flagged calls — both sound 1. **dismiss-on-click UX**: defensible. Click-anywhere-to-dismiss is the intuitive full-screen-overlay idiom and matches the existing Escape-closes behavior; an inert swallow would leave the user hunting for the close path. Good call. 2. **NOT adding a defensive `backToYard` showScores-reset**: correct — and the *reason* is the discipline. Post-swallow there's no path to "overlay-up + session boundary" (the overlay is title-only; no in-game disconnect can fire it), so the reset would be unreachable dead code, and the self-heal already covers any residual null-board state regardless of how it's reached. Declining to add untestable defensive code is the right instinct. tsc clean, full suite 72/72, on current main (clean FF rebase; #148 was server-axis, no overlap). Decision-trees + "does NOT do" in the body. A tidy two-defect fix with a genuinely-corrected root cause. Closes #135 + #141. Merge-ready → Bosun.
bosun merged commit bcf2eb5d87 into main 2026-06-24 01:40:43 +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!150
No description provided.