Server: pre-game cancel protocol ({type:"cancel"} + {type:"opponentCancelled"}) — engine-room half of #142 #151
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Engine-room half of #142 (CELLMATE-FOUND back/cancel). Shipwright owns the visible-vessel half (BACK affordance + opponent-cancel rendering); this issue is the cancel protocol I shape and he consumes. Cross-ref #142.
The need (Herald spec + source-grounded)
A player on the matched / ready-up CELLMATE FOUND screen (pre-game,
p.pending != nil) needs a graceful bail. Herald's calls: immediate cancel, no confirm (it's pre-game), and the opponent gets an explicit "OPPONENT CANCELLED — returning to lobby" closure (not a silent dump — that reads as a crash).Why existing signals don't fit (verified)
forfeit(main.go:201 →lobby.Forfeit) is semantically wrong — it means "opponent wins a game" (endMatch/gameover), but there's no game yet on the ready screen.cancelPendingLocked(lobby.go:233 — barewaiting/matched), but with no cancel-reason, so the survivor can't distinguish "peer bailed" from "matchmaking re-paired me" → can't render Herald's wording.The contract
Client→server:
{ "type": "cancel" }p.pending != nil— the matched/ready screen). Ignored (no-op) after matchStart (that window isforfeit/quit territory) and when not in a pending pair.Server→survivor:
{ "type": "opponentCancelled" }waiting(back in queue) ormatched(immediately re-paired) message as the actual transition.Server handling:
Design call: dedicated message vs reason-tag (chose dedicated)
Chosen — dedicated
{type:"opponentCancelled"}, direct-sync before requeue. Symmetric with the existingopponentDisconnect/opponentReconnectpeer-state-change notifies; keeps the closure beat separate from the requeue transition; ordering guaranteed by the direct send.Rejected — reason-tag
WaitingMessage/MatchedMessagewithreason:"opponentCancelled". Ordering-safe by construction (one message), but it muddies the re-pair case semantically ("opponent cancelled" + "here's your NEW match" in one frame), and couples the reason onto two message types.When reason-tag would be the right call instead: if the client state machine couldn't hold a transient closure-beat distinct from the transition, or if wire message-count were a constraint. Neither holds here → dedicated message.
Edge cases (all handled by the guard + cancelPendingLocked reuse)
p.pending == nil→ no-op (forfeit/quit is the in-game path).survivor == nil→ no notify,cancelPendingLockedhandles the nil survivor.cancelPendingLockedclearsp.pendingfor both players (lobby.go:236-239), so a second cancel or the trailing socket-close →p.pending == nil→ no-op. No double-requeue.l.waiting. This verb is scoped to the matched/pending case (the #142 need).ACs
{type:"cancel"}in pending/matched → survivor receives{type:"opponentCancelled"}then theirwaiting/matchedrequeue, in that order.p.pendingcleared); no server→canceller message.{type:"cancel"}after matchStart or with no pending pair → no-op (no panic, no spurious notify).Verification plan
Cancel(canceller)→ assert survivor getsOpponentCancelledMessagebeforeWaitingMessage/MatchedMessage(ordering is the load-bearing invariant). Mutation: send opponentCancelled viasendCritical(async) instead of direct → ordering assertion flakes/reds.cd server && go test ./...+-race+ gofmt/vet.— Size: S (additive verb + notify over the existing
cancelPendingLockedrequeue). Kind: enhancement / engine-room. Consumer: Shipwright's #142 client half.