feat(server): pre-game cancel protocol — {type:"cancel"} + opponentCancelled (#151) #164
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/151-cancel-protocol"
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 #151. Engine-room half of #142 (CELLMATE-FOUND back/cancel); consumed by Shipwright's #142 client half (PR #161). #151 + #161 ship together.
A player on the matched/ready-up CELLMATE FOUND screen needs a graceful bail, and the opponent needs an explicit "OPPONENT CANCELLED — returning to lobby" closure — not a silent re-pair that reads as a crash.
Why a new verb (neither existing signal fit)
forfeitmeans "opponent wins a game" (endMatch/gameover) — wrong, no game has started on the ready screen.cancelPendingLocked, but with no cancel-reason, so the survivor can't tell "peer bailed" from "matchmaking re-paired me".Contract
{type:"cancel"}p.pending != nil); no-op after matchStart + when unpaired. Canceller returns to title on its own side (no server ack).{type:"opponentCancelled"}opponentCancelledthen exactly one ofwaiting/matched.Ordering — the load-bearing invariant
cancelPendingLockedemits the survivor'swaiting/matchedviasendCritical(async/unordered). SoopponentCancelledis a direct synchronous send before the requeue — buffered first → the survivor sees the closure before the transition, deterministically (the direct send occupies the channel slot before the async requeue goroutine is even spawned). Same ordering rule assolo.go's matchStart-before-first-state. Pre-game + buffered(64) + near-idle survivor → the direct send doesn't block underl.mu.Dedicated message (not a reason-tag)
Symmetric with the existing
opponentDisconnect/opponentReconnectnotifies; keeps the closure beat clean; avoids muddying the re-pair case ("opponent cancelled" + "here's your new match" in one frame). reason-tag would only win if the client couldn't hold a transient closure-beat distinct from the transition — it can (Shipwright owns the brief-beat-before-instant-repair render timing).ACs
opponentCancelledthenwaiting/matched, in order ✓ (tested both requeue branches).p.pendingcleared, no server→canceller msg) ✓.Verification
cd server && go test ./...(exact CI) — green;-race— green;gofmt -l/vetclean.opponentCancelledsend → the survivor's first message becomes the requeue: reverted by re-edit; suite green (cache-confirmed byte-identical).What this PR does NOT do
cancelPendingLockedis reused untouched; the only addition is the pre-requeue notify.✅ APPROVED — pre-game cancel protocol (#151) · joint-merge with #161 now unblocked
Reviewed at head
08f99ea(on current maind81d2a8). This is the engine-room half of #142, and it matches the contract I verified #161's client against — so both halves are now approved and the joint-land can proceed.The ordering invariant — correct, and mutation-proven on both branches
The load-bearing bit is right:
Canceldoes the direct synchronoussurvivor.send <- OpponentCancelledMessagebefore callingcancelPendingLocked, so it buffers into the survivor's channel slot before the async (sendCritical) requeue goroutine is even spawned — making "opponentCancelled first" deterministic, not timing-dependent. I traced it: the direct send completes beforecancelPendingLockedis called, so the channel order is opponentCancelled (N) → waiting/matched (N+1) regardless of when the async send runs. The solo.go matchStart-before-first-state precedent is the right analogy. I reproduced the mutation — dropping the notify reds bothTestCancel_NotifiesSurvivorBeforeRequeue(→ WaitingMessage first) andTestCancel_RepairsSurvivorAfterNotify(→ MatchedMessage first). So the ordering is genuinely guarded on the waiting and matched requeue branches.Contract match — verified end-to-end against #161
The two halves compose correctly: #164 server emits
{type:'cancel'}→handler and{type:'opponentCancelled'}; #161 client sends{cancel}and consumes{opponentCancelled}(event-driven beat). Same wire on both sides. TheOpponentCancelledMessageis a clean peer-state sibling of opponentDisconnect/opponentReconnect, matching the client's proto.ts.Correctness + edges
pm == nil→ no-op (pre-matchStart only) —TestCancel_NoPendingIsNoOppins no-panic/no-spurious-notify (AC3).survivor == nil(both-bail) → no notify,cancelPendingLockedhandles nil.cancelPendingLockedreused untouched (additive — the only addition is the pre-requeue notify);joinedgate in readPump.Verification
go test ./...green;-racegreen; gofmt + vet clean. All ACs covered.Joint-merge note
Both halves approved. #164 is on current main; #161 needs a clean rebase (it's behind by #163, which was nav.spec.ts-only — disjoint from #161's versus.spec/client files, so the rebase is clean). #164 (server/) and #161 (client/) are file-disjoint, so they compose trivially. Sequence is Bosun's call — merge #164 + rebase-merge #161 lands the full #142.
Clean, contract-honest, ordering-proven. Closes #151 (+ #142 with #161). Merge-ready → Bosun (joint-land with #161).