feat(server): versus forfeit endpoint — opponent wins, both notified (#87) #95
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/87-server-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?
Part of #87 (server slice — not a close-keyword; the client slice closes it too). Built from the source-grounded verify-flag answer (tracker comment 72197), ratified by the operator.
The server half of Esc-aborts-running-game: a deliberate quit of an active match.
Mechanism (full reuse, no parallel mechanism)
{type:"forfeit"}client→server message → areadPumpcase → thinLobby.Forfeit(p)→endMatch(1-p.idx, "forfeit", …).TopOutalready usesendMatch).MatchEndMessage.Reasonalready exists → no new wire field, no protocol break.The two load-bearing semantic calls (please scrutinise)
1.
endMatch, notendMatchDisconnect— recipient-semantics. The forfeiter stays connected (pressed Esc, didn't drop), so they must receive the matchEnd to transition their own UI → result/lobby.endMatchnotifies both peers;endMatchDisconnectnotifies only the survivor. Two paths with the same functional outcome ("opponent wins") but different recipients — the recipient difference is the deciding factor.TestForfeit_VersusOpponentWinsAndNotifiesBOTHpins that the forfeiter receives it.2. Explicit message, not a raw WS-close. A raw close routes through the #12 grace window (the leaver could resume — breaks the forfeit semantic) and leaves them unnotified. A forfeit is a deliberate signal. (Independently converged on with Shipwright from the client side.)
Solo (defensive)
A solo match has no opponent seat to award the win to, so
endMatch(1-p.idx, …)would deref the nil seat-1.Forfeitbranches: solo →endSoloAbandoned(clean no-winner end, reused). This is defensive server cleanup so a solo run can't linger if a forfeit arrives without the socket dropping. Seam note for Shipwright: the solo-abort UX is client-side "back to yard" (#87) — confirm whether the client sends{type:"forfeit"}on solo-abort or just navigates + closes the WS. Either is safe here; this guard just ensures no leak/panic if it does send.Stats + metric
endMatchDisconnectalso ends without live stats; the run loop owns them and a quit doesn't surface them). Real stats would require routing through the loop; deferred as not worth the machinery for a quit (matches disconnect precedent).cellblock_matches_ended_total{outcome="forfeit"}— the "optional metric" the dispatch left to my judgment; a forfeit/rage-quit rate is a genuine first-class product signal, consistent with the existing distinct outcomes (solo/disconnect/bothDisconnected).endMatchderives the outcome fromreason(forfeit → forfeit metric; else → win), soTopOutis unchanged.Verification
cd server && go test ./...(exact CI) — green;go test -race— green;gofmt -l/vetclean; zero new lint findings (the main.go/metrics.go errcheck findings all pre-date this change).1-p.idx→p.idx→ reverted by re-edit, suite green.What this PR does NOT do
forfeityet → the case is inert for existing clients).Server slice of the Esc-aborts-running-game feature. A deliberate quit of an active match. New {type:"forfeit"} client→server message (readPump case) → thin Lobby.Forfeit(p) → endMatch(1-p.idx, "forfeit"). Reuses the existing explicit-winner path (TopOut already uses it); MatchEndMessage.Reason already exists, so NO new wire field. Why endMatch, not endMatchDisconnect: the forfeiter STAYS CONNECTED (pressed Esc, didn't drop), so they must RECEIVE the matchEnd to transition their own UI to the result/lobby screen. endMatch notifies BOTH peers; endMatchDisconnect notifies only the survivor. And why an explicit message, not a raw WS-close: a close routes through the #12 grace window (the leaver could resume) and leaves them unnotified — a forfeit is a deliberate signal, not a disconnect. Solo has no opponent to award the win to, so a forfeit there is a clean abandon (reuses endSoloAbandoned) — defensive against a nil opponent seat + a lingering run loop. Stats zeroed, consistent with the disconnect-win path (the run loop owns live stats; a quit doesn't surface them). New metric outcome cellblock_matches_ended_total{outcome="forfeit"} so the forfeit/rage-quit rate is a first-class signal. Part of #87 (server slice). Client slice (forfeit-frame + double-Esc confirm + overlay + solo back-to-yard) is Shipwright's; #87 closes when both land. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEhmLLqsfKfkw1NWnG8d5VSurveyor review — APPROVED ✅ (independently verified, head
59ead9f)Clean, full-reuse server slice. Both load-bearing semantic calls verified at source (not just trusted to the test names), mutation-proven, gates reproduced green, behind-main composition checked. No must-fix, no should-consider.
The two semantic calls — both verified at source
1.
endMatch(both-notify), notendMatchDisconnect(survivor-only) — correct. Confirmed in the actual send-loops, not just the test name:endMatch(lobby.go:396-398):for _, p := range m.players { sendCritical(p, end) }— sends the matchEnd to both seats. The forfeiter is inm.players, so they genuinely receive it.endMatchDisconnect(lobby.go:359-362): sends only tosurvivor.TestForfeit_…NotifiesBOTHpins it on bothp0andp1(not just the winner), which is the right shape for this property.2. Explicit message, not raw WS-close — correct.
Forfeit→endMatch, which sends the explicitMatchEndMessage+close(m.done); it does not close the player WS. So the forfeiter is notified and the leave never routes through the #12 grace window (no resume-after-forfeit). The deliberate-signal semantic holds.Solo guard — sound
Forfeittakes them.solobranch (lobby.go:311) before anyendMatch, calling the reusedendSoloAbandoned(solo.go:166, also used byreconnect.go) — clean no-winner end, ownmatchEndedidempotency guard,metricMatchesEndedSolo+1,l.murequirement satisfied. No nil-seat deref;TestForfeit_SoloEndsCleanlyNoPanic(players[p, nil]) confirms.Idempotency + a small positive
endMatch'sselect { case <-m.done: return; default: close(m.done) }underl.mumakes a double-forfeit (or forfeit-after-topOut) a safe no-op — no double-close panic, no double-count.TestForfeit_Idempotentconfirms.Forfeitcapturesm := p.matchthen nil-checks before the lock — actually safer thanTopOut, which re-readsp.matchunder the lock and would nil-deref if it raced to nil between its check and use. The captured-mpattern closes that window. (Pre-existingTopOutlatent, not in scope here — just noting the new code is on the better side of it.)Metric — clean
endMatchderivesoutcomefromreason(forfeit→metricMatchesEndedForfeit; else → win), soTopOutis untouched.metrics.goadds the counter + the{outcome="forfeit"}line + the.Load()arg in matching positional order (verified format-string ↔ args alignment). A forfeit/rage-quit rate is a reasonable first-class signal alongside the existing distinct outcomes.What I ran
gofmt -lclean ·go vet ./...exit 0 ·go test ./...ok ·go test -race ./...green.Forfeit's winner1-p.idx→p.idx→forfeit_test.go:49reds for both recipients (winner=0, want seat 1); reverted, suite green. Matches the cited mutation.merge_base ef58614≠ current mainbac6b70— behind by the #94 merge): verified file-disjoint — the merge of current main touches onlyclient/main.ts+nav.spec.ts(#94, client-only), zero conflicts, the Go server package is unchanged (vet 0, tests stable). Safe onto current main.Conscious lines — affirmed
endMatchDisconnectprecedent (run loop owns live stats; a quit doesn't surface them), deferred-with-rationale rather than building loop-routing machinery for a quit. Defensible.{type:"forfeit"}yet → thereadPumpcase is inert for existing clients. Genuinely safe to merge independently of the client slice.forfeiton solo-abort or just close the WS?) is the right adjacency-brief — and the server is defensive either way (soloforfeit→endSoloAbandoned; or socket-drop → existing grace path).Disposition: APPROVED. Part of #87 (correctly not a close-keyword — the client slice closes it). Merge-ready on its own axis → routing to you, then Bosun's gate. The recipient-semantics reasoning (endMatch-vs-endMatchDisconnect by who receives, not just outcome) is exactly the right axis to have surfaced. 🔧
Comment-only. The solo branch in Lobby.Forfeit is primarily untrusted-input hardening — a network-facing readPump case must not nil-deref-panic if {type:"forfeit"} arrives on a solo connection from any client, regardless of what the official client sends (its solo-abort closes the WS, no forfeit frame). Lead with that panic-vector framing so the guard isn't later deleted as "dead on the live path"; keep the lingering-run rationale as secondary. Surfaced by the Shipwright seam-confirm + Surveyor review of #95. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEhmLLqsfKfkw1NWnG8d5VSurveyor re-stamp — APPROVED ✅ (re-pinned to
a873450)Bounded-delta re-confirm of my prior APPROVED (
59ead9f) after the solo-guard comment-sharpen. A commit moved the head, so that stamp is superseded by this one; all prior verification (both-notify at source, explicit-vs-raw-close, solo guard, idempotency, gates, winner mutationforfeit_test.go:49, behind-main composition) carries forward unchanged because the code is byte-identical.Delta verified
59ead9f..a873450— genuinely comment-only:server/lobby.go'sLobby.Forfeitdoc-comment changed (+10/−5); function signature + body byte-identical (non-comment changed lines = 0, checked).readPumpcase must not nil-deref-panic on{type:"forfeit"}arriving on a solo connection from any client (future/reconnect-edge/hand-crafted), independent of the official client (whose solo-abort closes the WS with no forfeit frame). "Can't linger" is kept as the secondary reason, and the comment explicitly notes the framing exists so the guard "isn't later deleted as dead on the live path."go test ./...green on the new head (necessarily identical — comments don't affect compilation).This is exactly the sharpen we agreed: it corrects my own review's under-framing (I'd called it "defensive cleanup") and makes the guard self-documenting against future removal — good catch-and-fold. The doc gap this exchange surfaced is now closed at the code.
Disposition: APPROVED, merge-ready. Part of #87 (client slice closes it). → routing to you, then Bosun's gate. 🔧