feat(client): survivor OPPONENT-DISCONNECTED countdown overlay (#139 PART 2) #154
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/139-survivor-overlay"
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 & 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
opponentDisconnectwire grace budget) tells the survivor the pause resolves and when. On opponent-rejoin → dismiss, match resumes. On grace-expiry → thematchEndlands "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'sdrawReconnecting(same dim / amber / title-pulse family). The self-vs-survivor distinction is the title + thereconnecting…descriptor line. Replaces the old smalldrawDisconnectBannerbox.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
graceSeconds(serverreconnect.go:150emits it,proto.ts:99types it, ws-mock builds it) — verified end-to-end, no wire change needed.opponentDisconnect.graceSecondsontostate.opponentGraceSeconds; clears the survivor flags onopponentReconnectAND onmatchEnd(the latter is load-bearing — without it the overlay would bleed over the game-over screen). OnmatchEnd reason="disconnect"+ you-win, picks a disconnect-specific quip so a default-win isn't read as earned on the board.opponentDisconnectUntildeadline on thestate.opponentDisconnectedrising edge (loop-driven — the signal is a state field, not a net callback like PART 1'sonReconnect), hands the remaining seconds to render via a setter (mirrorssetReconnectingState).navState.opponentDisconnectCountdownmirrors 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
opponentDisconnectUntiltoo. Re-captured clean.Verification
matchEndopponentDisconnectedclear → the overlay stays up at gameover → cleared test reds. Each reverted precisely.tsc --noEmitclean.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:
opponentDisconnectimmediately on WS-close (reconnect.go— no server-side debounce), so a brief blip could flash the overlay.Surveyor + Herald both endorsed this principled defer. Filed as #155 so it doesn't evaporate.
Scope
opponentDisconnectsignal.copy.ts(Herald-owned) so it can be revised without touching render.What this PR does NOT do
🤖 Generated with Claude Code
✅ APPROVED — survivor OPPONENT-DISCONNECTED overlay (#139 PART 2)
Reviewed at head
d9f46b4(on current maincf80c94). 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
opponentDisconnected/opponentGraceSecondson matchEnd. I reproduced the mutation — keepopponentDisconnected: trueat matchEnd → the survivor overlay bleeds over the YOU-WIN game-over → test 2 reds. So the clean grace-expiry→gameover handoff is genuinely guarded.opponentGraceSeconds); main armsopponentDisconnectUntilon 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-1reconnectingUntilpattern cleanly.drawDisconnectBanner.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.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.