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