mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): enforce proposal decline cooldowns
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user