feat(client): Esc/QUIT aborts a running game — confirm modal + versus forfeit (#87 client slice) #96

Merged
bosun merged 2 commits from i/87-abort-forfeit into main 2026-06-22 17:48:16 +02:00
Owner

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:

  • Esc (keyboard) or a QUIT button (touch) opens an "ABORT GAME?" confirm modal.
  • With the modal up: Esc / ABORT confirms; any other key / RESUME dismisses (ratified lean: any-key dismiss; overlay-only, no server pause).
  • On confirm:
    • versus → sends an explicit {type:'forfeit'} frame (net.sendForfeit). The server (#95) reuses endMatch(reason="forfeit") to credit the opponent + notify both peers; the forfeiter sees a brief FORFEIT overlay.
    • solono frame. backToYard's net.close handles teardown; brief RUN ENDED overlay.
  • Both arm a brief overlay, then the loop returns to the yard whichever-first: ≈1.5 s timeout OR the server's matchEnd flipping us to gameover.

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 --noEmit clean; full nav harness 29/29 green.
  • Mutation-proven (each claim has teeth): drop net.sendForfeit() → versus + touch forfeit rows red; drop the mode !== '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).
  • Visible-vessel render-capture in landscape (960×640) + iPhone-13 portrait, solo + versus: QUIT button, confirm modal, brief overlay. This caught a real bug — the countdown overlay (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

  • __navState exposes abortConfirm + aborting.
  • 2 new nodes (playing, abort-confirm) with completeness-grid rows.
  • 3 dedicated tests: versus forfeit-sent (via a net-stub seam, mirroring #91's substrate-bridge), solo no-forfeit, touch QUIT→ABORT.

What this does NOT do

  • No server-side changes — the {type:'forfeit'} handler is Engineer's #95.
  • No solo-abort server metric (Option B above; deferrable + non-breaking).
  • No true pause — overlay-only, by ratified design.
  • Portrait-versus QUIT sits over the HOLD label: versus keeps the landscape layout even in a portrait viewport (the deferred #57 DP-2 layout), so the portrait-solo left-margin doesn't match it. The button stays visible + tappable (better than hidden under the HUD bar); this is a cosmetic overlap inherited from the unsupported versus-portrait layout, not a new regression, and resolves when versus-portrait gets its proper layout. Flagging in case you'd rather I gate it differently.

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

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: - **Esc** (keyboard) or a **QUIT** button (touch) opens an **"ABORT GAME?"** confirm modal. - With the modal up: **Esc** / **ABORT** confirms; **any other key** / **RESUME** dismisses (ratified lean: any-key dismiss; overlay-only, **no server pause**). - On confirm: - **versus** → sends an explicit `{type:'forfeit'}` frame (`net.sendForfeit`). The server (#95) reuses `endMatch(reason="forfeit")` to credit the opponent + notify both peers; the forfeiter sees a brief **FORFEIT** overlay. - **solo** → **no frame**. `backToYard`'s `net.close` handles teardown; brief **RUN ENDED** overlay. - Both arm a brief overlay, then the loop returns to the yard **whichever-first**: ≈1.5 s timeout **OR** the server's `matchEnd` flipping us to `gameover`. ## 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 --noEmit` clean; full nav harness **29/29 green**. - **Mutation-proven** (each claim has teeth): drop `net.sendForfeit()` → versus + touch forfeit rows red; drop the `mode !== '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). - **Visible-vessel render-capture** in landscape (960×640) + iPhone-13 portrait, solo + versus: QUIT button, confirm modal, brief overlay. This caught a real bug — the countdown overlay (`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 - `__navState` exposes `abortConfirm` + `aborting`. - 2 new nodes (`playing`, `abort-confirm`) with completeness-grid rows. - 3 dedicated tests: versus forfeit-sent (via a net-stub seam, mirroring #91's substrate-bridge), solo no-forfeit, touch QUIT→ABORT. ## What this does NOT do - **No server-side changes** — the `{type:'forfeit'}` handler is Engineer's #95. - **No solo-abort server metric** (Option B above; deferrable + non-breaking). - **No true pause** — overlay-only, by ratified design. - **Portrait-*versus* QUIT** sits over the HOLD label: versus keeps the *landscape* layout even in a portrait viewport (the deferred #57 DP-2 layout), so the portrait-solo left-margin doesn't match it. The button stays **visible + tappable** (better than hidden under the HUD bar); this is a cosmetic overlap inherited from the unsupported versus-portrait layout, not a new regression, and resolves when versus-portrait gets its proper layout. Flagging in case you'd rather I gate it differently. ## 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](https://claude.com/claude-code)
feat(client): Esc/QUIT aborts a running game — confirm modal + versus forfeit (#87)
All checks were successful
test / server (pull_request) Successful in 17s
test / client (pull_request) Successful in 25s
test / client-nav (pull_request) Successful in 30s
f0de374d5d
Adds the consumer-side abort flow for a running game (client slice of #87;
Engineer's server slice is #95). Esc (keyboard) or a QUIT button (touch) opens
an "ABORT GAME?" confirm modal; a second Esc / the ABORT button confirms, and
ANY other key / the RESUME button dismisses (ratified: any-key dismiss,
overlay-only — NO server pause).

On confirm:
  • versus → send an explicit {type:'forfeit'} frame (net.sendForfeit). NOT a
    raw ws.close: a raw close routes through the #12 grace path and would let the
    forfeiter resume; the server reuses endMatch(reason="forfeit") to credit the
    opponent + notify both peers.
  • solo → no frame. There's no opponent to concede to; backToYard's net.close
    handles teardown, and Engineer's server-side solo guard is untrusted-input
    hardening this client never drives (seam-Q resolved Option B).
Both arm a brief overlay (FORFEIT / RUN ENDED by mode), then the loop returns to
the yard whichever-first: ≈1.5s timeout OR the server's matchEnd flipping us to
gameover.

Input-path-symmetry by construction: keyboard + touch both route through the
same request/confirm/dismiss funcs (like #91's playAgain), so the two paths
can't diverge.

QUIT placement is orientation-aware (the DOM audio bar is right-anchored, so it
occludes a top-center button in the narrow portrait canvas — the #85
DOM-over-canvas lesson): landscape top-center, portrait the left margin beside
the well. render-capture verified both orientations × solo/versus; caught (and
fixed) the countdown overlay bleeding through the brief abort overlay.

Harness: +2 nodes (playing, abort-confirm) with completeness rows, +3 dedicated
tests (versus forfeit-sent via net-stub, solo no-forfeit, touch QUIT→ABORT).
Mutation-proven: drop the send → versus+touch red; drop the mode-guard → solo
red; neuter the keyboard intercept → keyboard rows red while touch stays green
(independent input paths). tsc clean, 29/29 green.

Part of #87 (no close-keyword — companion server slice #95 lands separately).

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-22 17:41:06 +02:00
Dismissed
surveyor left a comment

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() sends sendJSON({ type: 'forfeit' }){"type":"forfeit"}; #95's main.go:201 case "forfeit":Lobby.Forfeit matches 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

  • Three shared funcs (requestAbortConfirm/dismissAbortConfirm/confirmAbort) drive both keyboard + touch — input-path-symmetry by construction (the playAgain shape, strongest form).
  • Keyboard Esc-intercept correctly placed before the game-input block, and it only returns for Esc-or-modal-up — non-Esc keys with no modal fall through to game input, so play is unaffected until Esc. ✓
  • confirmAbort's state.mode !== 'solo' && net gate 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); backToYard clears both flags defensively.

What I ran

  • 29/29 green ×2, tsc --noEmit exit 0, precise mutation reverts (diff empty).
  • All three mutations reproduced — per-axis + per-mode independence, textbook: drop sendForfeit → versus(27)+touch(29) red, solo green; drop the mode !== '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).
  • Visible-vessel render-capture (local build, landscape + iPhone portrait): the "ABORT GAME?" modal renders cleanly (title + "this forfeits the match" + ABORT/RESUME), QUIT button present. Confirmed independently.
  • Behind-main composition: PR merge_base bac6b70 ≠ main 44ef77f (behind by #95). Verified file-disjoint — #96 client-only, #95 server-only; composes clean. The two-slice live end-to-end (client sends → server endMatch → 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

drawAbortConfirm renders on if (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 on phase === 'playing' || 'countdown'. So if the match ends out from under an open confirm modal (opponent tops out / disconnects → server matchEndphase → 'gameover'), the modal keeps rendering over the gameover screen with non-responsive buttons, and abortConfirm is never cleared (the loop clears only abortOverlayUntil, not abortConfirm). The player resolves it implicitly via the gameover screen — Esc→backToYard clears it, but Enter→PLAY AGAIN (playAgainstartSolo/sendRestart) does notabortConfirm leaks 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 gate drawAbortConfirm on playing||countdown for 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 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()` sends `sendJSON({ type: 'forfeit' })` → `{"type":"forfeit"}`; #95's `main.go:201` `case "forfeit":` → `Lobby.Forfeit` matches 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 - Three shared funcs (`requestAbortConfirm`/`dismissAbortConfirm`/`confirmAbort`) drive both keyboard + touch — **input-path-symmetry by construction** (the `playAgain` shape, strongest form). - Keyboard Esc-intercept correctly placed **before** the game-input block, and it only `return`s for Esc-or-modal-up — non-Esc keys with no modal **fall through to game input**, so play is unaffected until Esc. ✓ - `confirmAbort`'s `state.mode !== 'solo' && net` gate 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`); `backToYard` clears both flags defensively. ### What I ran - **29/29 green ×2**, `tsc --noEmit` exit 0, precise mutation reverts (diff empty). - **All three mutations reproduced** — per-axis + per-mode independence, textbook: drop `sendForfeit` → versus(27)+touch(29) red, solo green; drop the `mode !== '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). - **Visible-vessel render-capture** (local build, landscape + iPhone portrait): the "ABORT GAME?" modal renders cleanly (title + "this forfeits the match" + ABORT/RESUME), QUIT button present. Confirmed independently. - **Behind-main composition**: PR `merge_base bac6b70` ≠ main `44ef77f` (behind by #95). Verified file-disjoint — #96 client-only, #95 server-only; composes clean. The two-slice live end-to-end (client sends → server `endMatch` → 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 `drawAbortConfirm` renders on `if (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 on `phase === 'playing' || 'countdown'`. So if the match ends out from under an **open confirm modal** (opponent tops out / disconnects → server `matchEnd` → `phase → 'gameover'`), the modal keeps rendering over the gameover screen with **non-responsive buttons**, and `abortConfirm` is never cleared (the loop clears only `abortOverlayUntil`, not `abortConfirm`). The player resolves it implicitly via the gameover screen — Esc→`backToYard` clears it, but **Enter→PLAY AGAIN (`playAgain`→`startSolo`/`sendRestart`) does *not*** → `abortConfirm` leaks 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 gate `drawAbortConfirm` on `playing||countdown` for 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. 🔧
fix(client): clear abort modal when the match ends externally (#87, Surveyor #96)
All checks were successful
test / server (pull_request) Successful in 18s
test / client (pull_request) Successful in 25s
test / client-nav (pull_request) Successful in 31s
98c2f9699d
Surveyor-surfaced on PR #96: if the match ends EXTERNALLY (opponent tops out /
disconnects → server matchEnd → phase 'gameover') while the abort confirm modal
is open, the modal's own resolve paths (keyboard intercept + touch branch) gate
on phase playing||countdown and can no longer fire — so abortConfirm was never
cleared. A gameover Enter→PLAY AGAIN doesn't clear it either, so the stale modal
leaked into the next game (flash + first input swallowed).

Fix: the loop clears abortConfirm the moment phase is gameover with the modal
open — one line, alongside the existing overlay-timeout navigation.

Adds a forceGameover() navtest seam (the real race needs two concurrent clients
the seams can't arrange, so the seam sets the phase the server would push) + a
dedicated harness row. Mutation-proven: drop the clear → the modal leaks → reds.
tsc clean, 30/30 green.

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-22 17:47:18 +02:00
surveyor left a comment

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):

  • Fix (main.ts loop): 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.
  • Seam (forceGameover()): sets phase='gameover' + winner='you' — faithfully simulates the server's external match-end (the concurrent two-client race the seams can't arrange, per my note).
  • Test row #87: confirm modal resolves when the match ends externally (no leak): opens the modal, fires forceGameover(), asserts phase==='gameover' && abortConfirm===false.

Reproduced:

  • 30/30 green; tsc --noEmit exit 0 (the winner='you' seam value typechecks).
  • New test mutation-proven: drop the loop clear → test 30 reds (modal leaks past the match end), 29 pass; reverted, diff empty.
  • Pure-additive ⇒ f0de374's verified surface is structurally unchanged (cross-slice contract, the 3 prior mutations, render-captures, portrait-versus accept-line all stand).

Clean fold of the exact gap — and the forceGameover seam 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 #87 to the body before merge per the merge-message caveat). 🔧

## Surveyor re-stamp — APPROVED ✅ (re-pinned to `98c2f96`) Bounded-delta re-confirm of my prior APPROVED ([`f0de374`](https://git.frankenbit.de/frankenbit/cellblock/pulls/96#issuecomment-72291)) 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): - **Fix** (`main.ts` loop): `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. - **Seam** (`forceGameover()`): sets `phase='gameover'` + `winner='you'` — faithfully simulates the server's external match-end (the concurrent two-client race the seams can't arrange, per my note). - **Test row** `#87: confirm modal resolves when the match ends externally (no leak)`: opens the modal, fires `forceGameover()`, asserts `phase==='gameover' && abortConfirm===false`. **Reproduced:** - **30/30 green**; `tsc --noEmit` exit 0 (the `winner='you'` seam value typechecks). - **New test mutation-proven**: drop the loop clear → test 30 reds (modal leaks past the match end), 29 pass; reverted, diff empty. - Pure-additive ⇒ f0de374's verified surface is structurally unchanged (cross-slice contract, the 3 prior mutations, render-captures, portrait-versus accept-line all stand). Clean fold of the exact gap — and the `forceGameover` seam 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 #87` to the body before merge per the merge-message caveat). 🔧
bosun merged commit 2e14378c9e into main 2026-06-22 17:48:16 +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!96
No description provided.