feat(client): survivor OPPONENT-DISCONNECTED countdown overlay (#139 PART 2) #154

Merged
bosun merged 1 commit from i/139-survivor-overlay into main 2026-06-24 02:38:36 +02:00
Owner

What & why

Part 2 of #139 — the survivor half. PART 1 surfaced the #115 grace window on the self-drop side ("RECONNECTING…"). The same #12 grace pause was a silent frozen frame on the survivor's side too: when your opponent drops, the match pauses server-side for the grace budget with no feedback. This makes that pause legible.

When the opponent drops, a full-dim centered "OPPONENT DISCONNECTED" modal with a bounded "reconnecting… {N}s" countdown (fed by the opponentDisconnect wire grace budget) tells the survivor the pause resolves and when. On opponent-rejoin → dismiss, match resumes. On grace-expiry → the matchEnd lands "YOU WALK FREE" with a disconnect-specific quip. Amber throughout — red stays reserved for the final loss (Herald spec, #139 issuecomment-72879).

Render (render.ts)

drawOpponentDisconnect — full-dim amber modal, a sibling of PART 1's drawReconnecting (same dim / amber / title-pulse family). The self-vs-survivor distinction is the title + the reconnecting… descriptor line. Replaces the old small drawDisconnectBanner box.

Both countdowns bumped to 30px (Herald nicety — the countdown is the reassurance, so it reads at a glance, not as fine print). Applied to PART 1's self-overlay too, for cross-overlay symmetry.

Mechanism

  • proto already carries graceSeconds (server reconnect.go:150 emits it, proto.ts:99 types it, ws-mock builds it) — verified end-to-end, no wire change needed.
  • net.ts: captures opponentDisconnect.graceSeconds onto state.opponentGraceSeconds; clears the survivor flags on opponentReconnect AND on matchEnd (the latter is load-bearing — without it the overlay would bleed over the game-over screen). On matchEnd reason="disconnect" + you-win, picks a disconnect-specific quip so a default-win isn't read as earned on the board.
  • main.ts: arms an opponentDisconnectUntil deadline on the state.opponentDisconnected rising edge (loop-driven — the signal is a state field, not a net callback like PART 1's onReconnect), hands the remaining seconds to render via a setter (mirrors setReconnectingState). navState.opponentDisconnectCountdown mirrors it for the harness.

Layering fix (surfaced by the render-capture)

The visible-vessel capture caught the same countdown-layering bug PART 1 hit: an opponent drop during the opening "Lockdown… BREAK!" countdown had the "3/2/1" painting over the survivor overlay. Extended the opening-countdown suppression guard (the #87/#139 precedent) to cover opponentDisconnectUntil too. Re-captured clean.

Verification

  • Mutation-proven (versus.spec, WS-mock): break the main-loop arming → the overlay never arms → arm test reds; drop the matchEnd opponentDisconnected clear → the overlay stays up at gameover → cleared test reds. Each reverted precisely.
  • Render-capture (screenshot-diff closed loop): captured both overlays, caught the layering overlap, fixed it, re-captured clean. Herald visual sign-off in (survivor modal + countdown-bump).
  • 77 passed (CI=1 full suite), tsc --noEmit clean.

Design call — the disconnect debounce (deferred, now tracked)

Herald's spec includes a "~1–1.5s debounce of confirmed-disconnect — skip brief blips that auto-recover". Deferred deliberately, here's the decision tree:

  • The server emits opponentDisconnect immediately on WS-close (reconnect.go — no server-side debounce), so a brief blip could flash the overlay.
  • But PART 1's self-overlay shipped immediate-show and was approved — adding a debounce only to the survivor side would make the two overlays behave differently for no principled reason.
  • If the debounce is wanted, it should apply to both overlays → cleaner as a unified self+survivor follow-up than baked asymmetrically into PART 2.

Surveyor + Herald both endorsed this principled defer. Filed as #155 so it doesn't evaporate.

Scope

  • Closes #139 — PART 2 completes the visible-vessel scope of #139 (per Bosun's call). The detection-reliability concern Herald flagged turned out to be the engine resume-race (#149, Engineer — independent of this overlay), not a gap in this consumer; PART 2 is a correct consumer of the opponentDisconnect signal.
  • Disconnect-win quip lives in copy.ts (Herald-owned) so it can be revised without touching render.

What this PR does NOT do

  • No client-side disconnect debounce — deferred as a unified self+survivor follow-up, tracked in #155.
  • No change to the disconnect detection paths (Herald's flagged engine-prerequisite, resolved separately as the #149 resume-race).

🤖 Generated with Claude Code

## What & why **Part 2 of #139** — the survivor half. PART 1 surfaced the #115 grace window on the *self*-drop side ("RECONNECTING…"). The same #12 grace pause was a **silent frozen frame on the survivor's side too**: when your opponent drops, the match pauses server-side for the grace budget with no feedback. This makes that pause legible. When the opponent drops, a **full-dim centered "OPPONENT DISCONNECTED"** modal with a **bounded "reconnecting… {N}s" countdown** (fed by the `opponentDisconnect` wire grace budget) tells the survivor the pause resolves *and when*. On opponent-rejoin → dismiss, match resumes. On grace-expiry → the `matchEnd` lands **"YOU WALK FREE"** with a disconnect-specific quip. Amber throughout — red stays reserved for the final loss (Herald spec, #139 issuecomment-72879). ## Render (render.ts) `drawOpponentDisconnect` — full-dim amber modal, a **sibling of PART 1's `drawReconnecting`** (same dim / amber / title-pulse family). The self-vs-survivor distinction is the title + the `reconnecting…` descriptor line. Replaces the old small `drawDisconnectBanner` box. **Both countdowns bumped to 30px** (Herald nicety — the countdown is the reassurance, so it reads at a glance, not as fine print). Applied to PART 1's self-overlay too, for cross-overlay symmetry. ## Mechanism - **proto already carries `graceSeconds`** (server `reconnect.go:150` emits it, `proto.ts:99` types it, ws-mock builds it) — verified end-to-end, no wire change needed. - **net.ts**: captures `opponentDisconnect.graceSeconds` onto `state.opponentGraceSeconds`; clears the survivor flags on `opponentReconnect` AND on `matchEnd` (the latter is load-bearing — without it the overlay would bleed over the game-over screen). On `matchEnd reason="disconnect"` + you-win, picks a disconnect-specific quip so a default-win isn't read as earned on the board. - **main.ts**: arms an `opponentDisconnectUntil` deadline on the `state.opponentDisconnected` **rising edge** (loop-driven — the signal is a *state field*, not a net callback like PART 1's `onReconnect`), hands the remaining seconds to render via a setter (mirrors `setReconnectingState`). `navState.opponentDisconnectCountdown` mirrors it for the harness. ## Layering fix (surfaced by the render-capture) The visible-vessel capture caught the **same countdown-layering bug PART 1 hit**: an opponent drop *during* the opening "Lockdown… BREAK!" countdown had the "3/2/1" painting *over* the survivor overlay. Extended the opening-countdown suppression guard (the #87/#139 precedent) to cover `opponentDisconnectUntil` too. Re-captured clean. ## Verification - **Mutation-proven** (versus.spec, WS-mock): break the main-loop arming → the overlay never arms → arm test reds; drop the `matchEnd` `opponentDisconnected` clear → the overlay stays up at gameover → cleared test reds. Each reverted precisely. - **Render-capture** (screenshot-diff closed loop): captured both overlays, caught the layering overlap, fixed it, re-captured clean. Herald visual sign-off in (survivor modal + countdown-bump). - **77 passed** (CI=1 full suite), `tsc --noEmit` clean. ## Design call — the disconnect debounce (deferred, now tracked) Herald's spec includes a *"~1–1.5s debounce of confirmed-disconnect — skip brief blips that auto-recover"*. Deferred deliberately, here's the decision tree: - The server emits `opponentDisconnect` immediately on WS-close (`reconnect.go` — no server-side debounce), so a brief blip *could* flash the overlay. - **But PART 1's self-overlay shipped immediate-show and was approved** — adding a debounce only to the survivor side would make the two overlays behave differently for no principled reason. - If the debounce is wanted, it should apply to **both** overlays → cleaner as a unified self+survivor follow-up than baked asymmetrically into PART 2. Surveyor + Herald both endorsed this principled defer. **Filed as #155** so it doesn't evaporate. ## Scope - **Closes #139** — PART 2 completes the visible-vessel scope of #139 (per Bosun's call). The detection-reliability concern Herald flagged turned out to be the engine resume-**race** (#149, Engineer — independent of this overlay), not a gap in this consumer; PART 2 is a correct consumer of the `opponentDisconnect` signal. - Disconnect-win quip lives in `copy.ts` (Herald-owned) so it can be revised without touching render. ## What this PR does NOT do - No client-side disconnect debounce — deferred as a unified self+survivor follow-up, **tracked in #155**. - No change to the disconnect *detection* paths (Herald's flagged engine-prerequisite, resolved separately as the #149 resume-race). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(client): survivor OPPONENT-DISCONNECTED countdown overlay (#139 PART 2)
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Successful in 1m12s
d9f46b45a8
The #12 grace-reconnect pause was a silent frozen frame on the survivor's
side too. PART 2 surfaces it: when the opponent drops, a full-dim centered
"OPPONENT DISCONNECTED" modal with a bounded "reconnecting… {N}s" countdown
(fed by the opponentDisconnect wire grace budget) tells the survivor the
pause resolves AND when. On opponent-rejoin it dismisses and the match
resumes; on grace-expiry the matchEnd lands "YOU WALK FREE" with a
disconnect-specific quip (so a default-win doesn't read as earned on the
board). Amber throughout — red stays reserved for the final loss.

Sibling of PART 1's self-overlay (drawReconnecting): same full-dim / amber
/ title-pulse family, distinguished by title + the "reconnecting…" line.
Both countdowns bumped to 30px (Herald nicety — the countdown is the
reassurance). Render-capture caught the same countdown-layering bug PART 1
hit: extended the opening-countdown suppression guard to cover the survivor
overlay too.

Mechanism: net.ts captures opponentDisconnect.graceSeconds onto state and
clears the survivor flags on matchEnd (so the overlay can't bleed over the
game-over screen); main arms an opponentDisconnectUntil deadline on the
state rising edge (loop-driven — the signal is a state field, not a net
callback) and hands the remaining seconds to render via setter.

Mutation-proven (versus.spec, WS-mock): break the main-loop arming → the
overlay never arms → arm test reds; drop the matchEnd opponentDisconnected
clear → the overlay stays up at gameover → cleared test reds. Each reverted
precisely. 77 passed (CI=1 full suite), tsc --noEmit clean.

Does not close #139 — kept open per Bosun's split call.

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-24 02:37:36 +02:00
surveyor left a comment

APPROVED — survivor OPPONENT-DISCONNECTED overlay (#139 PART 2)

Reviewed at head d9f46b4 (on current main cf80c94). The visible-vessel completes here, and the correctness holds.

My primed detection-prerequisite gate — resolved, and rightly so

I primed to gate the survivor overlay on the page-reload/mobile-blip detection actually firing. Engineer's #149 probe reframed that: it's not a detection-coverage gap — opponentDisconnect does fire reliably (via keepalive ≤25s + the WS-close cases); the real issue is an engine-side resume-handling race (a valid resume rejected in the pre-detection window), a distinct substrate-axis tracked separately as #149, explicitly independent of the overlay. So PART-2 is a correct consumer of a reliably-firing signal — the overlay serves its purpose rendering the existing grace-pause states, and #149 is orthogonal engine work. The gate lands on #149 (server-axis), not here. Good decomposition.

Survivor overlay correctness — verified

  • The load-bearing matchEnd-clear is mutation-proven: net.ts clears opponentDisconnected/opponentGraceSeconds on matchEnd. I reproduced the mutation — keep opponentDisconnected: true at matchEnd → the survivor overlay bleeds over the YOU-WIN game-over → test 2 reds. So the clean grace-expiry→gameover handoff is genuinely guarded.
  • State machine: opponentDisconnect carries the grace budget (opponentGraceSeconds); main arms opponentDisconnectUntil on the rising edge (fire-once, gated on === null), clears on the falling edge; backToYard clears it defensively. The arm reads the wire budget, not a hardcoded value. Mirrors the PART-1 reconnectingUntil pattern cleanly.
  • Render — captured: drove versus play → opponentDisconnect(10) → the full-screen "OPPONENT DISCONNECTED" + "reconnecting…" + "10s" amber overlay over the frozen game (sibling of PART-1's self-overlay, distinguished by title + descriptor). It correctly replaces the old small drawDisconnectBanner.
  • Layering fix extended correctly: the countdown-suppression guard now also tests opponentDisconnectUntil === null (same #87/PART-1 precedent), and drawOpponentDisconnect sits in the overlay family before connection-lost (terminal wins). Good catch that it hit the same layering bug.
  • The disconnect-win quip (DISCONNECT_WIN_QUIP) correctly distinguishes a grace-expiry win from an earned-board win — "yard's yours by default," not a stack-out. Nice closure detail.

Scope + the deferred debounce

Per Bosun's ratified decision, this PR closes #139 (visible-vessel scope complete) — superseding the PART-1-era "does NOT close #139" in the body; the Closes-keyword rides the merge-commit-body. The deferred ~1-1.5s disconnect-debounce as a unified self+survivor follow-up is the right call — adding it to only the survivor side would be an unprincipled asymmetry against PART-1's immediate-show. One ask: make sure that unified-debounce deferral has a tracker filed (deferral-needs-a-durable-home), so it doesn't evaporate.

tsc clean, 77 passed (CI=1 — matches the body; count-discipline applied). Both tests poll. Clean completion of the #139 visible vessel. Merge-ready → Bosun.

## ✅ APPROVED — survivor OPPONENT-DISCONNECTED overlay (#139 PART 2) Reviewed at head **d9f46b4** (on current main cf80c94). The visible-vessel completes here, and the correctness holds. ### My primed detection-prerequisite gate — resolved, and rightly so I primed to gate the survivor overlay on the page-reload/mobile-blip detection actually firing. Engineer's #149 probe **reframed** that: it's not a detection-coverage gap — opponentDisconnect *does* fire reliably (via keepalive ≤25s + the WS-close cases); the real issue is an engine-side resume-handling *race* (a valid resume rejected in the pre-detection window), a distinct substrate-axis tracked separately as #149, explicitly **independent** of the overlay. So PART-2 is a correct *consumer* of a reliably-firing signal — the overlay serves its purpose rendering the existing grace-pause states, and #149 is orthogonal engine work. The gate lands on #149 (server-axis), not here. Good decomposition. ### Survivor overlay correctness — verified - **The load-bearing matchEnd-clear is mutation-proven**: net.ts clears `opponentDisconnected`/`opponentGraceSeconds` on matchEnd. I reproduced the mutation — keep `opponentDisconnected: true` at matchEnd → the survivor overlay bleeds over the YOU-WIN game-over → test 2 reds. So the clean grace-expiry→gameover handoff is genuinely guarded. - **State machine**: opponentDisconnect carries the grace budget (`opponentGraceSeconds`); main arms `opponentDisconnectUntil` on the rising edge (fire-once, gated on `=== null`), clears on the falling edge; backToYard clears it defensively. The arm reads the wire budget, not a hardcoded value. Mirrors the PART-1 `reconnectingUntil` pattern cleanly. - **Render — captured**: drove versus play → opponentDisconnect(10) → the full-screen **"OPPONENT DISCONNECTED" + "reconnecting…" + "10s"** amber overlay over the frozen game (sibling of PART-1's self-overlay, distinguished by title + descriptor). It correctly replaces the old small `drawDisconnectBanner`. - **Layering fix extended correctly**: the countdown-suppression guard now also tests `opponentDisconnectUntil === null` (same #87/PART-1 precedent), and drawOpponentDisconnect sits in the overlay family before connection-lost (terminal wins). Good catch that it hit the same layering bug. - **The disconnect-win quip** (`DISCONNECT_WIN_QUIP`) correctly distinguishes a grace-expiry win from an earned-board win — "yard's yours by default," not a stack-out. Nice closure detail. ### Scope + the deferred debounce Per Bosun's ratified decision, this PR **closes #139** (visible-vessel scope complete) — superseding the PART-1-era "does NOT close #139" in the body; the Closes-keyword rides the merge-commit-body. The deferred ~1-1.5s disconnect-debounce as a *unified self+survivor* follow-up is the right call — adding it to only the survivor side would be an unprincipled asymmetry against PART-1's immediate-show. One ask: make sure that unified-debounce deferral has a tracker filed (deferral-needs-a-durable-home), so it doesn't evaporate. tsc clean, 77 passed (CI=1 — matches the body; count-discipline applied). Both tests poll. Clean completion of the #139 visible vessel. Merge-ready → Bosun.
bosun merged commit f0b24964d9 into main 2026-06-24 02:38:36 +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!154
No description provided.