From 455055c67c6bfb02045be6e34dd5caa74745b8ec Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:30:45 +0100 Subject: [PATCH] feat(multiplayer): enforce proposal decline cooldowns --- multiplayer-next.md | 6 +++ server/api/service.go | 2 + server/domain/queue.go | 1 + server/store/proposal_recovery_sql.go | 43 ++++++++++++++++++++++ server/store/proposal_recovery_sql_test.go | 2 + server/store/queue_sql.go | 14 +++++++ 6 files changed, 68 insertions(+) diff --git a/multiplayer-next.md b/multiplayer-next.md index 451c8986..9bd96107 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1463,6 +1463,12 @@ simultaneous connections, releasing capacity on disconnect; this complements the bounded per-player event queue and prevents connection fan-out from becoming an unbounded account-level resource cost. +Proposal explicit-decline cooldowns are now durable: the declining player is +requeued for recovery, but a subsequent queue create is rejected until the +playlist-specific cooldown computed by `domain.CooldownUntil` expires. The +operation is idempotent and does not affect the other participants' requeue; +timeout-derived cooldown recording remains a follow-up slice. + ### Current local completion index (2026-09-01) The following Phase 8 slices have local implementation and verification evidence in this document: 8.29 dynamic allocated launch flags and endpoint handling; 8.30 allocator claim/reconciliation including provider-outcome recovery fencing; 8.31 signed assignment/roster validation; 8.35 initial-connect no-show and casual bot policy; 8.36 controlled drain and shutdown acknowledgment; 8.39–8.43 client state, assignment, profile, recovery, and idempotent action retry; 8.44 structured observability and content-aware redaction; 8.45 bounded API metrics export plus optional Prometheus scrape/alert rules; 8.46 normal/race/vet/fuzz coverage; 8.47–8.48 offline/testkit/Compose coverage; 8.50 atomic stalled-allocation recovery notifications; 8.51 the 10,000-client API load boundary; 8.52 the opt-in per-replica plus shared PostgreSQL regional allocator quota; and 8.53 the fail-closed promotion validator. Their remaining acceptance text is infrastructure or production dependent where explicitly noted below the corresponding row. diff --git a/server/api/service.go b/server/api/service.go index 22079758..5c8a454c 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -1109,6 +1109,8 @@ func writeDomainError(w http.ResponseWriter, err error) { switch { case errors.Is(err, domain.ErrPlayerQueued), errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrStaleRevision): writeError(w, http.StatusConflict, "conflict") + case errors.Is(err, domain.ErrPlayerCooldown): + writeError(w, http.StatusTooManyRequests, "matchmaking_cooldown") case errors.Is(err, domain.ErrTicketExpired): writeError(w, http.StatusGone, "expired") case errors.Is(err, domain.ErrNotTicketOwner): diff --git a/server/domain/queue.go b/server/domain/queue.go index cc229a85..59f4167d 100644 --- a/server/domain/queue.go +++ b/server/domain/queue.go @@ -20,6 +20,7 @@ var ( ErrTicketNotFound = errors.New("queue ticket not found") ErrNotTicketOwner = errors.New("queue ticket is owned by another player") ErrTicketExpired = errors.New("queue ticket expired") + ErrPlayerCooldown = errors.New("player is on matchmaking cooldown") ) type QueueTicket struct { diff --git a/server/store/proposal_recovery_sql.go b/server/store/proposal_recovery_sql.go index 7103f446..2dab156a 100644 --- a/server/store/proposal_recovery_sql.go +++ b/server/store/proposal_recovery_sql.go @@ -129,6 +129,46 @@ SET state = 'QUEUED', expires_at = $2, revision = revision + 1 FROM proposal_participants pp WHERE pp.proposal_id = $1 AND q.ticket_id = pp.ticket_id AND q.player_id = pp.player_id AND q.state = 'PROPOSED'` +const ProposalCooldownEventsSQL = `SELECT kind, starts_at +FROM penalties +WHERE player_id = $1 AND playlist = $2 + AND kind IN ('PROPOSAL_DECLINED', 'PROPOSAL_TIMEOUT') + AND starts_at >= $3 +ORDER BY starts_at` + +const ProposalCooldownInsertSQL = `INSERT INTO penalties + (penalty_id, player_id, playlist, kind, starts_at, ends_at) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (penalty_id) DO NOTHING` + +func recordProposalDeclineCooldown(ctx context.Context, tx *sql.Tx, playerID string, playlist domain.Playlist, proposalID string, now time.Time) error { + rows, err := tx.QueryContext(ctx, ProposalCooldownEventsSQL, playerID, string(playlist), now.Add(-30*time.Minute)) + if err != nil { + return err + } + defer rows.Close() + events := make([]domain.CooldownEvent, 0) + for rows.Next() { + var kind string + var at time.Time + if err := rows.Scan(&kind, &at); err != nil { + return err + } + response := domain.TimedOutResponse + if kind == "PROPOSAL_DECLINED" { + response = domain.DeclinedResponse + } + events = append(events, domain.CooldownEvent{At: at, Playlist: playlist, Kind: response}) + } + if err := rows.Err(); err != nil { + return err + } + events = append(events, domain.CooldownEvent{At: now, Playlist: playlist, Kind: domain.DeclinedResponse}) + until := domain.CooldownUntil(events, playlist, now) + _, err = tx.ExecContext(ctx, ProposalCooldownInsertSQL, "proposal-decline:"+proposalID+":"+playerID, playerID, string(playlist), "PROPOSAL_DECLINED", now, until) + return err +} + const ProposalRevisionBumpSQL = `UPDATE proposals SET revision = revision + 1 WHERE proposal_id = $1 AND state = 'OPEN'` @@ -286,6 +326,9 @@ func RespondToProposal(ctx context.Context, db *sql.DB, playerID, proposalID, id if err != nil { return err } + if err := recordProposalDeclineCooldown(ctx, tx, playerID, domain.Playlist(playlist), proposalID, now); err != nil { + return err + } _, err = tx.ExecContext(ctx, ProposalDeclineRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow)) } if err != nil { diff --git a/server/store/proposal_recovery_sql_test.go b/server/store/proposal_recovery_sql_test.go index b0b9028c..0e4e252d 100644 --- a/server/store/proposal_recovery_sql_test.go +++ b/server/store/proposal_recovery_sql_test.go @@ -18,6 +18,8 @@ func TestProposalRecoverySQLBindsParticipantAndExpiresAtReadBoundary(t *testing. ProposalRevisionBumpSQL: {"revision = revision + 1", "state = 'OPEN'"}, ProposalDeclineRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "proposal_participants"}, ProposalExpireRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "state = 'EXPIRED'"}, + ProposalCooldownEventsSQL: {"kind IN ('PROPOSAL_DECLINED', 'PROPOSAL_TIMEOUT')", "starts_at >= $3", "ORDER BY starts_at"}, + ProposalCooldownInsertSQL: {"INSERT INTO penalties", "starts_at", "ends_at", "ON CONFLICT (penalty_id) DO NOTHING"}, OpenProposalForCancelledTicketSQL: {"proposal_participants", "state = 'OPEN'"}, } { for _, fragment := range fragments { diff --git a/server/store/queue_sql.go b/server/store/queue_sql.go index 116a2ec7..ecab80f0 100644 --- a/server/store/queue_sql.go +++ b/server/store/queue_sql.go @@ -35,6 +35,13 @@ RETURNING ticket_id, player_id, playlist, state, client_build, protocol_version, WHERE ticket_id = $1 AND player_id = $2 AND revision = $3 AND state NOT IN ('COMPLETED', 'CANCELLED', 'EXPIRED', 'FAILED') RETURNING ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at, revision, predicted_rtt` + QueueCooldownSelectSQL = `SELECT ends_at +FROM penalties +WHERE player_id = $1 AND playlist = $2 + AND kind IN ('PROPOSAL_DECLINED', 'PROPOSAL_TIMEOUT') + AND ends_at > $3 +ORDER BY ends_at DESC +LIMIT 1` ) const QueueCandidateProjectionSQL = `SELECT ticket_id, player_id, playlist, client_build, @@ -146,6 +153,13 @@ func CreateQueueTicket(ctx context.Context, db *sql.DB, ticketID, playerID, idem ticket = queueTicketRecordToDomain(prior) return nil } + var cooldownEndsAt time.Time + if err := tx.QueryRowContext(ctx, QueueCooldownSelectSQL, playerID, string(spec.Playlist), now).Scan(&cooldownEndsAt); err != sql.ErrNoRows { + if err != nil { + return err + } + return fmt.Errorf("%w until %s", domain.ErrPlayerCooldown, cooldownEndsAt.UTC().Format(time.RFC3339)) + } predictedRTT, err := json.Marshal(candidate.PredictedRTT) if err != nil { return err