feat(multiplayer): enforce proposal decline cooldowns

This commit is contained in:
Josh Creek
2026-09-01 19:30:45 +01:00
parent da92be73ef
commit 455055c67c
6 changed files with 68 additions and 0 deletions
+43
View File
@@ -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 {
+14
View File
@@ -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