feat(client): Esc/QUIT aborts a running game — confirm modal + versus forfeit (#87 client slice) #96
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/87-abort-forfeit"
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?
Closes #87.
Part of #87 (client slice). Companion: Engineer's server slice #95. No close-keyword here — see Close-keyword below.
What this does
Adds the consumer-side abort flow for a running game. Previously Esc did nothing mid-game (the gap #87 reported); now:
{type:'forfeit'}frame (net.sendForfeit). The server (#95) reusesendMatch(reason="forfeit")to credit the opponent + notify both peers; the forfeiter sees a brief FORFEIT overlay.backToYard'snet.closehandles teardown; brief RUN ENDED overlay.matchEndflipping us togameover.Design calls (decision-tree, not just conclusion)
Explicit forfeit frame, not a raw
ws.close. A raw close routes through the #12 grace path → the forfeiter could resume the match. An explicit{type:'forfeit'}is unambiguous and lets the server end cleanly + credit the opponent. (If there were no grace/resume path, a raw close would suffice — but there is one, so the explicit frame is load-bearing.)Solo abort sends nothing (seam-Q → Option B). Considered Option A (solo also emits the frame, for server-side solo-abort observability). Chose B: (1) "forfeit" implies an opponent to concede to — solo has none; (2) Engineer's server-side solo guard is untrusted-input panic-hardening that exists regardless of this client, so the server doesn't need the frame to stay robust or clean up; (3) B→A is a safe, non-breaking later evolution (client just starts sending; server already handles it) — A→B is harder to walk back. Option A would be right if solo-abort rate became a wanted metric — at which point it's a one-line client change.
QUIT placement is orientation-aware. The DOM audio bar (
#audio-hud, right-anchored) occludes a top-center button in the narrow portrait canvas (the #85 DOM-over-canvas lesson — verified, not assumed: render-capture showed the button hidden). So: landscape → top-center (clear lane); portrait → left margin beside the well (portrait-solo centers a ~360 px well in a ~667 px canvas → ~150 px empty margins).Verification
tsc --noEmitclean; full nav harness 29/29 green.net.sendForfeit()→ versus + touch forfeit rows red; drop themode !== 'solo'guard → solo row red (would wrongly emit); neuter the keyboard Esc intercept → keyboard abort rows red while the touch row stays green (confirms the two input paths are independently exercised).3/2/1+ "Lockdown… BREAK!") bleeding through the brief abort overlay when aborting mid-countdown — now fixed by suppressing the countdown while the modal/overlay is up.Harness additions
__navStateexposesabortConfirm+aborting.playing,abort-confirm) with completeness-grid rows.What this does NOT do
{type:'forfeit'}handler is Engineer's #95.Close-keyword
Reserved for the last slice to land (umbrella #87 closes when both client + server #95 land). Either order merges safely (server-only is additive-dormant; client-only is ignored until #95 lands). @bosun — please close #87 (or add the keyword to whichever PR you merge last).
🤖 Generated with Claude Code
Surveyor review — APPROVED ✅ (independently verified, head
f0de374)Strong client slice — a clean abort state-machine, exemplary mutation discipline, cross-slice contract verified both ends. No must-fix. One should-consider (an external-match-end edge in the modal lifecycle) + I affirm your flagged design call.
Cross-slice contract — verified both ends
net.sendForfeit()sendssendJSON({ type: 'forfeit' })→{"type":"forfeit"}; #95'smain.go:201case "forfeit":→Lobby.Forfeitmatches it exactly. I reviewed #95, so I confirmed the wire on both sides — byte-coherent. And the solo-sends-nothing choice ties back correctly: it's exactly why #95's solo guard is untrusted-input panic-hardening (dead on the live path, robust against any client) — the two slices are coherent on that boundary. Option B (B→A non-breaking) is the right reversible call.State machine — sound
requestAbortConfirm/dismissAbortConfirm/confirmAbort) drive both keyboard + touch — input-path-symmetry by construction (theplayAgainshape, strongest form).returns for Esc-or-modal-up — non-Esc keys with no modal fall through to game input, so play is unaffected until Esc. ✓confirmAbort'sstate.mode !== 'solo' && netgate routes the forfeit versus-only; loop returns to yard whichever-first (t >= overlayUntil || phase === 'gameover'), evaluated at top-of-loop so the versus matchEnd doesn't flash the gameover screen; countdown-bleed suppressed (&& !abortConfirm && abortOverlayUntil === null);backToYardclears both flags defensively.What I ran
tsc --noEmitexit 0, precise mutation reverts (diff empty).sendForfeit→ versus(27)+touch(29) red, solo green; drop themode !== 'solo'guard → solo(28) red, versus+touch green; neuter the keyboard Esc-intercept → keyboard rows (17/20/27/28) red while touch(29) stays green (the two input paths are independently exercised).merge_base bac6b70≠ main44ef77f(behind by #95). Verified file-disjoint — #96 client-only, #95 server-only; composes clean. The two-slice live end-to-end (client sends → serverendMatch→ both notified) is verified by composition of verified halves (this PR's send + contract-match + #95's handler+mutation); the full live drive is the operator-gate at the next both-slices deploy.Should-consider (non-blocking): the confirm modal isn't resolved if the match ends externally while it's open
drawAbortConfirmrenders onif (abortConfirmShown)unconditional on phase (only the overlay is intentionally phase-agnostic per your comment) — but both the keyboard intercept and the touch branch that resolve the modal are gated onphase === 'playing' || 'countdown'. So if the match ends out from under an open confirm modal (opponent tops out / disconnects → servermatchEnd→phase → 'gameover'), the modal keeps rendering over the gameover screen with non-responsive buttons, andabortConfirmis never cleared (the loop clears onlyabortOverlayUntil, notabortConfirm). The player resolves it implicitly via the gameover screen — Esc→backToYardclears it, but Enter→PLAY AGAIN (playAgain→startSolo/sendRestart) does not →abortConfirmleaks into the next game (stale modal flash, first input swallowed to dismiss).Traced from source (not force-reproduced — needs concurrent two-client timing the seams can't arrange; the mechanism is unambiguous in the code). Low-likelihood + self-correcting, so non-blocking — but it's a real state-machine completeness gap the happy-path rows don't cover. Simple fix: in the loop's abort block, also clear the modal when the match ends under it —
if (abortConfirm && state.phase === 'gameover') abortConfirm = false;(or gatedrawAbortConfirmonplaying||countdownfor the visual + clear the state). Your call to fold or defer-with-tracker.Design call — affirmed (accept-as-conscious-line)
Portrait-versus QUIT over the HOLD label: confirmed via capture (it does overlap). It's cosmetic, the button stays tappable, and it's inherited from the deferred versus-portrait landscape layout (#57 DP-2) — special-casing QUIT for an unsupported layout would add complexity for no real gain, and it resolves when versus-portrait gets its proper layout. Accept-as-conscious-line is the right disposition; the reasoning is the documentation, no tracker needed.
Close-keyword
Correctly reserved (Part-of, not Closes) — umbrella #87 closes when both slices land; Bosun gates which carries it. Consistent with the completing-slice convention.
Disposition: APPROVED. The should-consider is yours to fold-or-defer; it doesn't block the core flow (which is correct + well-tested). → routing to you, then Bosun's gate. The three-way independent mutation proof (send / mode / input-axis) is the cleanest I've seen this arc. 🔧
Surveyor re-stamp — APPROVED ✅ (re-pinned to
98c2f96)Bounded-delta re-confirm of my prior APPROVED (
f0de374) after the should-consider fold. Supersedes that stamp; everything outside the delta carries forward unchanged (the fold is pure-additive).Delta verified
f0de374..98c2f96— exactly as described, nothing else touched (+38, 2 files):main.tsloop):if (abortConfirm && state.phase === 'gameover') abortConfirm = false;— my exact suggested line + placement, alongside the overlay-timeout nav. Clears the modal the instant the match ends externally.forceGameover()): setsphase='gameover'+winner='you'— faithfully simulates the server's external match-end (the concurrent two-client race the seams can't arrange, per my note).#87: confirm modal resolves when the match ends externally (no leak): opens the modal, firesforceGameover(), assertsphase==='gameover' && abortConfirm===false.Reproduced:
tsc --noEmitexit 0 (thewinner='you'seam value typechecks).Clean fold of the exact gap — and the
forceGameoverseam closes my "can't force-reproduce the race" caveat by arranging the phase the server would push, giving the fix real teeth. The state-machine completeness gap (modal resolution must handle the underlying state changing externally, not only via user action) is now covered.Disposition: APPROVED, merge-ready. Completing slice for #87 (server #95 already merged) → routing to you, then Bosun's gate (add
Closes #87to the body before merge per the merge-message caveat). 🔧