From 957bb65a2606b9619ba4f71382628cedce5d7198 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:32:38 +0100 Subject: [PATCH] feat(multiplayer): enforce proposal timeout cooldowns --- multiplayer-next.md | 3 +- server/store/proposal_recovery_sql.go | 56 ++++++++++++++++++++-- server/store/proposal_recovery_sql_test.go | 1 + 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index 9bd96107..7e7e6c47 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1467,7 +1467,8 @@ 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. + timeout-derived cooldown recording now uses the same durable penalty path, + with deterministic per-proposal/player IDs for replay safety. ### Current local completion index (2026-09-01) diff --git a/server/store/proposal_recovery_sql.go b/server/store/proposal_recovery_sql.go index 2dab156a..1b3ee52c 100644 --- a/server/store/proposal_recovery_sql.go +++ b/server/store/proposal_recovery_sql.go @@ -7,6 +7,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strings" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" @@ -141,7 +142,12 @@ const ProposalCooldownInsertSQL = `INSERT INTO penalties 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 { +const ProposalTimedOutParticipantsSQL = `SELECT player_id +FROM proposal_participants +WHERE proposal_id = $1 AND response = 'TIMED_OUT' AND responded_at = $2 +ORDER BY player_id` + +func recordProposalCooldown(ctx context.Context, tx *sql.Tx, playerID string, playlist domain.Playlist, proposalID, kind string, response domain.Response, now time.Time) error { rows, err := tx.QueryContext(ctx, ProposalCooldownEventsSQL, playerID, string(playlist), now.Add(-30*time.Minute)) if err != nil { return err @@ -163,12 +169,44 @@ func recordProposalDeclineCooldown(ctx context.Context, tx *sql.Tx, playerID str if err := rows.Err(); err != nil { return err } - events = append(events, domain.CooldownEvent{At: now, Playlist: playlist, Kind: domain.DeclinedResponse}) + events = append(events, domain.CooldownEvent{At: now, Playlist: playlist, Kind: response}) until := domain.CooldownUntil(events, playlist, now) - _, err = tx.ExecContext(ctx, ProposalCooldownInsertSQL, "proposal-decline:"+proposalID+":"+playerID, playerID, string(playlist), "PROPOSAL_DECLINED", now, until) + _, err = tx.ExecContext(ctx, ProposalCooldownInsertSQL, "proposal-"+strings.ToLower(kind)+":"+proposalID+":"+playerID, playerID, string(playlist), kind, now, until) return err } +func recordProposalDeclineCooldown(ctx context.Context, tx *sql.Tx, playerID string, playlist domain.Playlist, proposalID string, now time.Time) error { + return recordProposalCooldown(ctx, tx, playerID, playlist, proposalID, "PROPOSAL_DECLINED", domain.DeclinedResponse, now) +} + +func recordProposalTimeoutCooldowns(ctx context.Context, tx *sql.Tx, proposalID string, playlist domain.Playlist, now time.Time) error { + rows, err := tx.QueryContext(ctx, ProposalTimedOutParticipantsSQL, proposalID, now) + if err != nil { + return err + } + defer rows.Close() + players := make([]string, 0) + for rows.Next() { + var playerID string + if err := rows.Scan(&playerID); err != nil { + return err + } + players = append(players, playerID) + } + if err := rows.Err(); err != nil { + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, playerID := range players { + if err := recordProposalCooldown(ctx, tx, playerID, playlist, proposalID, "PROPOSAL_TIMEOUT", domain.TimedOutResponse, now); err != nil { + return err + } + } + return nil +} + const ProposalRevisionBumpSQL = `UPDATE proposals SET revision = revision + 1 WHERE proposal_id = $1 AND state = 'OPEN'` @@ -193,6 +231,15 @@ func GetProposal(ctx context.Context, db *sql.DB, playerID, proposalID string, n if _, err := tx.ExecContext(ctx, ProposalParticipantExpireSQL, proposalID, now); err != nil { return domain.Proposal{}, err } + var cooldownPlaylist string + if err := tx.QueryRowContext(ctx, `SELECT playlist FROM proposals WHERE proposal_id = $1`, proposalID).Scan(&cooldownPlaylist); err != nil && err != sql.ErrNoRows { + return domain.Proposal{}, err + } + if cooldownPlaylist != "" { + if err := recordProposalTimeoutCooldowns(ctx, tx, proposalID, domain.Playlist(cooldownPlaylist), now); err != nil { + return domain.Proposal{}, err + } + } if _, err := tx.ExecContext(ctx, ProposalExpireRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow)); err != nil { return domain.Proposal{}, err } @@ -276,6 +323,9 @@ func RespondToProposal(ctx context.Context, db *sql.DB, playerID, proposalID, id if _, err := tx.ExecContext(ctx, ProposalParticipantExpireSQL, proposalID, now); err != nil { return err } + if err := recordProposalTimeoutCooldowns(ctx, tx, proposalID, domain.Playlist(playlist), now); err != nil { + return err + } if _, err := tx.ExecContext(ctx, ProposalExpireRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow)); err != nil { return err } diff --git a/server/store/proposal_recovery_sql_test.go b/server/store/proposal_recovery_sql_test.go index 0e4e252d..b0d3c032 100644 --- a/server/store/proposal_recovery_sql_test.go +++ b/server/store/proposal_recovery_sql_test.go @@ -20,6 +20,7 @@ func TestProposalRecoverySQLBindsParticipantAndExpiresAtReadBoundary(t *testing. 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"}, + ProposalTimedOutParticipantsSQL: {"response = 'TIMED_OUT'", "responded_at = $2", "ORDER BY player_id"}, OpenProposalForCancelledTicketSQL: {"proposal_participants", "state = 'OPEN'"}, } { for _, fragment := range fragments {