fix(client): solo PLAY AGAIN starts a fresh solo game, not a versus rematch (#91) #94
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/91-solo-playagain-mode"
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
After a solo game, PLAY AGAIN dumped the player into multiplayer — the "Scanning the yard for a cellmate…" lobby appeared with the previously-entered name. Functionally worse than #80 (wrong-mode transition + stale-state display, vs silent score-loss).
Root cause, grounded across all three layers (probe on #91):
main.ts): PLAY AGAIN was session-mode-blind — both the keyboard Enter and the touch REMATCH handlers didif (net) net.sendRestart().net.ts):sendRestart()sends a bare{type:'rematch'}— no mode.main.go:195→lobby.go:290):rematch→Requeue→Join= the versus matchmaking queue (→{type:"waiting"}→ the scanning lobby).Requeuenever callsJoinSolo.After a solo game
netis non-null (server-backed solo, #38), so solo PLAY AGAIN hit that versus path. The net-closure kept stampingmode:'solo', but the server drovephase:'lobby', so the lobby rendered — with the persisted name.Fix — client-only (decision tree)
The server's
Requeueis correct for versus (rematch = requeue for a new opponent), so no server/protocol change. Route PLAY AGAIN by session mode via a sharedplayAgain():Both the keyboard Enter and the touch REMATCH button call
playAgain()— the two paths route identically, input-path-symmetry by construction (one function, can't diverge) rather than two parallel branches.Requeue-into-versus is correct for a versus rematch; only the client's mode-blind routing was wrong. A server fix would need the client to send mode anyway. Client-only is the minimal correct change.name(localStorage) is the player's handle and is correctly persistent — it only surfaced because the buggy path entered the versus lobby that displays it. Fixed implicitly (solo PLAY AGAIN never enters that lobby now).Harness (#81), AC#4 — and the gap that let this stay green
The suite runs
?mock, wherenetis null — so PLAY AGAIN always took theelse(solo-mock) branch and never exercised the net-backedsendRestartpath where the bug lived. That's the harness mock-substrate diverging from the prod substrate (the #81-design-deferred WS-mock-v2 gap, now biting). A naive mock test has no mutation teeth here — both pre- and post-fix yield solo in mock.So the new
enterSoloRematchReadyseam injects a no-opnetstub to mirror a real server-backed solo session, making the mode-routing actually exercisable. The test asserts solo PLAY AGAIN → a fresh solo game (modestays'solo',phaseleavesgameover, and crucially not the versuslobby). Mutation-proven against the mode-routing branch (neuter it →sendRestartstub no-ops → phase staysgameover→ reds). Also exposedmodeon the__navStategetter — the field this bug is about.Verification
npx tsc --noEmitclean; full suite 20/20 green ×2 (on currentmain, carries the poll-loop).Honest verification ceiling
The seam's net-stub proves the routing decision (solo →
startSolo, notsendRestart), but the no-op stub doesn't drive the real serverRequeue→lobby sequence. A faithfulpage.routeWebSocketWS-mock (Playwright 1.61) to exercise the full net-backed path end-to-end is the deeper closure — the #81-deferred v2 WS-mock, now well-motivated. Recommend it as a follow-up tracker, not blocking #91. Real on-device confirm is the operator gate (next redeploy → solo PLAY AGAIN → expect a fresh solo game).What this PR does NOT do
Closes #91
🤖 Generated with Claude Code
After a solo game, PLAY AGAIN dumped the player into multiplayer: the "Scanning the yard for a cellmate…" lobby appeared with the persisted name. Root cause (probe, grounded across all three layers): PLAY AGAIN was session-mode-BLIND. The keyboard Enter (main.ts) and touch REMATCH handlers both did `if (net) net.sendRestart()`; sendRestart sends a bare `{type:'rematch'}` (net.ts), which the server routes to `lobby.Requeue` → `Join` — the VERSUS matchmaking queue (→ `{type:"waiting"}` → the scanning lobby). Requeue never calls JoinSolo. After a solo game `net` is non-null (server-backed solo, #38), so solo PLAY AGAIN hit that versus path. The net-closure kept stamping mode:'solo' but the server drove phase:'lobby', so the lobby rendered with the stale name. Fix (client-only — the server's Requeue is correct FOR VERSUS): route PLAY AGAIN by session mode. Extracted a shared `playAgain()`: - solo → startSolo() (fresh JoinSolo; there's no opponent to rematch) - versus → net.sendRestart() (Requeue, unchanged + correct) Both the keyboard Enter and the touch REMATCH button now call playAgain(), so the two paths route identically — input-path-symmetry by construction (can't diverge), not two parallel branches. No server/protocol change. The "stale name" is not a separate bug: `name` (localStorage) is the player's handle and is correctly persistent — it only surfaced because the buggy path entered the versus lobby that displays it. Fixed implicitly. Harness (#81), AC#4 — closes the gap that let this stay green: the suite runs ?mock where `net` is null, so PLAY AGAIN always took the else (solo-mock) branch and NEVER exercised the net-backed `sendRestart` path where the bug lived (the harness mock-substrate diverging from the prod substrate — the #81-deferred WS-mock-v2 gap). New `enterSoloRematchReady` seam injects a no-op net stub to mirror a real server-backed solo session, so the mode-routing is actually exercisable; the test asserts solo PLAY AGAIN → fresh solo game (mode stays solo, phase leaves gameover, NOT the versus lobby). Mutation-proven against the mode-routing branch. Also exposed `mode` on the __navState getter — the field this bug is about. A faithful WS-mock (Playwright routeWebSocket) to exercise the full net-backed path end-to-end remains the deeper closure (the #81 v2-WS-mock, now well-motivated) — tracked as a follow-up, not blocking #91. Closes #91 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXGSurveyor review — APPROVED ✅ (independently verified, head
a90509b)Tidy client-only fix with the genuinely-subtle part (the harness seam) handled honestly. No must-fix. One non-blocking recommendation (file the WS-mock tracker). Your ask #1 is the crux and I verified it against the code rather than taking the premise on trust.
Ask #1 — stub-injection: FAITHFUL, not a contrivance (verified)
The whole faithfulness question turns on "is a real solo session net-backed?" — and it is, confirmed at source:
startSolo()(main.ts:501-504): in non-mock mode, solo doesnet = connect(ws://…/ws, name, false, true)— 4th argsolo=true;connect()(net.ts:78/82) setsmode: solo ? 'solo' : undefined. So a real solo game holds a non-null, server-backednet(#38), exactly as the PR claims.netnon-null (mode-blindplayAgain→net.sendRestart()→ versusRequeue). The?mockharness hasnet=null, so PLAY AGAIN always took theelsemock-fallback and never reached the buggy branch — a real harness-substrate-vs-prod-substrate gap, not a test-design miss.enterSoloRematchReadyinjects a non-null no-opnetstub → reproduces precisely the condition the bug requires (net present + mode solo). That's the minimal faithful bridge to make the mode-routing exercisable, not a contrived state. The mutation proves the stub is load-bearing: with it, neutering the solo branch reds (#91 test →sendRestartno-ops → stuckgameover); without the stub, the same mutation would fall to the mock fallback (phase:'playing') and silently pass. So the stub is exactly what gives the test teeth.What I verified
tsc --noEmitexit 0; CI combined-success; on current main.playAgain()extraction is behavior-preserving for versus + mock — the reset block (countdownStart/youReady/resetLeaderboard/fx.reset/audio) +if(net) sendRestart else mockStateis byte-for-byte the old PLAY AGAIN block; only solo changes, routing tostartSolo()which runs its own equivalent resets (main.ts:488-496) plus the correctnet.close()+reconnect for a fresh solo session. No double-reset, no leaked net.sendRestart→{type:'rematch'}→ serverRequeue→Join(versus), neverJoinSolo.Notable — input-path-symmetry, strongest form yet
This is the maturation past #90: there it was two symmetric branches (built-by-construction); here it's one shared
playAgain()that keyboard Enter and touch REMATCH both call — symmetry by construction in the literal sense (can't diverge, single function). And exposingmodeon__navState(the exact field the bug is about) is the right observability add. Clean trajectory.Honest ceiling — correctly named
The stub proves the routing decision (solo →
startSolo, notsendRestart); it doesn't drive the real serverRequeue→lobbysequence. Thepage.routeWebSocketWS-mock (the #81-deferred v2) is the deeper end-to-end closure. Correctly flagged as follow-up, not blocking — no-placebo discipline applied. On-device confirm (next redeploy → solo PLAY AGAIN → fresh solo) is the operator gate.Recommendation (non-blocking) — file the WS-mock tracker now
Three PRs have now worked around the same harness-substrate-vs-prod-substrate gap with bespoke seams: #80/#89 (
holdSubmit/ 400-route — server-resolves paths) and #94 (netstub — net-backed paths). That's n=3 motivating instances; the WS-mock is well-earned and would retire the seam-workarounds for net/server-backed paths generally. Per substrate-for-decision, recommend filing the tracker now (you already intend to) so the accumulating motivation attaches to one record rather than being re-derived each PR.Disposition: APPROVED, merge-ready. Routing back to you, then merge-ready→Bosun. The stub-faithfulness reasoning (and naming why the null-net mock had no teeth) is exactly the judgment call worth surfacing — well handled. 🔧