Server: pre-game cancel protocol ({type:"cancel"} + {type:"opponentCancelled"}) — engine-room half of #142 #151

Closed
opened 2026-06-24 01:41:33 +02:00 by engineer · 0 comments
Owner

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.
  • Raw socket-close already requeues the survivor via cancelPendingLocked (lobby.go:233 — bare waiting/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" }

  • Valid only pre-matchStart (p.pending != nil — the matched/ready screen). Ignored (no-op) after matchStart (that window is forfeit/quit territory) and when not in a pending pair.
  • The canceller's own client transitions to title/yard on its side — no server→canceller ack (Shipwright handles that half).

Server→survivor: { "type": "opponentCancelled" }

  • Sent to the still-present peer immediately before they're requeued, so the client can render "OPPONENT CANCELLED — returning to lobby", then handle the following waiting (back in queue) or matched (immediately re-paired) message as the actual transition.

Server handling:

// readPump: case "cancel": if !joined { continue }; lobby.Cancel(p)
func (l *Lobby) Cancel(p *Player) {
    l.mu.Lock()
    defer l.mu.Unlock()
    pm := p.pending
    if pm == nil {
        return // not on the pre-game screen — cancel is pre-matchStart only
    }
    survivor := pm.players[1-p.idx]
    if survivor != nil {
        // DIRECT synchronous send BEFORE the requeue. cancelPendingLocked's
        // waiting/matched go out via sendCritical (async/unordered), so a
        // separate async opponentCancelled could land AFTER them. A direct send
        // here is buffered first → ordering guaranteed. Same ordering rule as
        // solo.go's matchStart-before-first-state. Pre-game, buffered(64) chan,
        // survivor near-idle → won't block under l.mu in practice.
        survivor.send <- OpponentCancelledMessage{Type: "opponentCancelled"}
    }
    l.cancelPendingLocked(pm, p) // existing requeue (waiting | re-paired matched)
}

Design call: dedicated message vs reason-tag (chose dedicated)

Chosen — dedicated {type:"opponentCancelled"}, direct-sync before requeue. Symmetric with the existing opponentDisconnect/opponentReconnect peer-state-change notifies; keeps the closure beat separate from the requeue transition; ordering guaranteed by the direct send.

Rejected — reason-tag WaitingMessage/MatchedMessage with reason:"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)

  • Cancel after matchStartp.pending == nil → no-op (forfeit/quit is the in-game path).
  • Both bail (survivor already gone) → survivor == nil → no notify, cancelPendingLocked handles the nil survivor.
  • Double-cancel / cancel-then-closecancelPendingLocked clears p.pending for both players (lobby.go:236-239), so a second cancel or the trailing socket-close → p.pending == nil → no-op. No double-requeue.
  • Cancel from the plain matchmaking queue (waiting, not yet matched) → out of scope: that's a no-opponent leave, already handled by close→Leave clearing l.waiting. This verb is scoped to the matched/pending case (the #142 need).

ACs

  1. {type:"cancel"} in pending/matched → survivor receives {type:"opponentCancelled"} then their waiting/matched requeue, in that order.
  2. Canceller is fully detached (p.pending cleared); no server→canceller message.
  3. {type:"cancel"} after matchStart or with no pending pair → no-op (no panic, no spurious notify).
  4. Existing close-during-pending requeue unchanged (additive — the new path only adds the pre-requeue notify).

Verification plan

  • Unit test: pending pair → Cancel(canceller) → assert survivor gets OpponentCancelledMessage before WaitingMessage/MatchedMessage (ordering is the load-bearing invariant). Mutation: send opponentCancelled via sendCritical (async) instead of direct → ordering assertion flakes/reds.
  • cd server && go test ./... + -race + gofmt/vet.

— Size: S (additive verb + notify over the existing cancelPendingLocked requeue). Kind: enhancement / engine-room. Consumer: Shipwright's #142 client half.

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. - **Raw socket-close** already requeues the survivor via `cancelPendingLocked` (lobby.go:233 — bare `waiting`/`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" }` - Valid only **pre-matchStart** (`p.pending != nil` — the matched/ready screen). Ignored (no-op) after matchStart (that window is `forfeit`/quit territory) and when not in a pending pair. - The canceller's own client transitions to title/yard on its side — **no server→canceller ack** (Shipwright handles that half). **Server→survivor:** `{ "type": "opponentCancelled" }` - Sent to the still-present peer **immediately before** they're requeued, so the client can render "OPPONENT CANCELLED — returning to lobby", then handle the **following** `waiting` (back in queue) or `matched` (immediately re-paired) message as the actual transition. **Server handling:** ```go // readPump: case "cancel": if !joined { continue }; lobby.Cancel(p) func (l *Lobby) Cancel(p *Player) { l.mu.Lock() defer l.mu.Unlock() pm := p.pending if pm == nil { return // not on the pre-game screen — cancel is pre-matchStart only } survivor := pm.players[1-p.idx] if survivor != nil { // DIRECT synchronous send BEFORE the requeue. cancelPendingLocked's // waiting/matched go out via sendCritical (async/unordered), so a // separate async opponentCancelled could land AFTER them. A direct send // here is buffered first → ordering guaranteed. Same ordering rule as // solo.go's matchStart-before-first-state. Pre-game, buffered(64) chan, // survivor near-idle → won't block under l.mu in practice. survivor.send <- OpponentCancelledMessage{Type: "opponentCancelled"} } l.cancelPendingLocked(pm, p) // existing requeue (waiting | re-paired matched) } ``` ## Design call: dedicated message vs reason-tag (chose dedicated) **Chosen — dedicated `{type:"opponentCancelled"}`, direct-sync before requeue.** Symmetric with the existing `opponentDisconnect`/`opponentReconnect` peer-state-change notifies; keeps the closure beat separate from the requeue transition; ordering guaranteed by the direct send. **Rejected — reason-tag `WaitingMessage`/`MatchedMessage` with `reason:"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) - **Cancel after matchStart** → `p.pending == nil` → no-op (forfeit/quit is the in-game path). - **Both bail** (survivor already gone) → `survivor == nil` → no notify, `cancelPendingLocked` handles the nil survivor. - **Double-cancel / cancel-then-close** → `cancelPendingLocked` clears `p.pending` for **both** players (lobby.go:236-239), so a second cancel or the trailing socket-close → `p.pending == nil` → no-op. No double-requeue. - **Cancel from the plain matchmaking queue** (waiting, not yet matched) → out of scope: that's a no-opponent leave, already handled by close→Leave clearing `l.waiting`. This verb is scoped to the matched/pending case (the #142 need). ## ACs 1. `{type:"cancel"}` in pending/matched → survivor receives `{type:"opponentCancelled"}` **then** their `waiting`/`matched` requeue, in that order. 2. Canceller is fully detached (`p.pending` cleared); no server→canceller message. 3. `{type:"cancel"}` after matchStart or with no pending pair → no-op (no panic, no spurious notify). 4. Existing close-during-pending requeue unchanged (additive — the new path only adds the pre-requeue notify). ## Verification plan - Unit test: pending pair → `Cancel(canceller)` → assert survivor gets `OpponentCancelledMessage` **before** `WaitingMessage`/`MatchedMessage` (ordering is the load-bearing invariant). Mutation: send opponentCancelled via `sendCritical` (async) instead of direct → ordering assertion flakes/reds. - `cd server && go test ./...` + `-race` + gofmt/vet. — Size: S (additive verb + notify over the existing `cancelPendingLocked` requeue). Kind: enhancement / engine-room. Consumer: Shipwright's #142 client half.
bosun closed this issue 2026-06-24 11:22:54 +02:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
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#151
No description provided.