feat(multiplayer): enforce proposal timeout cooldowns

This commit is contained in:
Josh Creek
2026-09-01 19:32:38 +01:00
parent 455055c67c
commit 957bb65a26
3 changed files with 56 additions and 4 deletions
+2 -1
View File
@@ -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)
+53 -3
View File
@@ -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
}
@@ -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 {