mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 07:02:07 +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
|
requeued for recovery, but a subsequent queue create is rejected until the
|
||||||
playlist-specific cooldown computed by `domain.CooldownUntil` expires. The
|
playlist-specific cooldown computed by `domain.CooldownUntil` expires. The
|
||||||
operation is idempotent and does not affect the other participants' requeue;
|
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)
|
### Current local completion index (2026-09-01)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||||
@@ -141,7 +142,12 @@ const ProposalCooldownInsertSQL = `INSERT INTO penalties
|
|||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
ON CONFLICT (penalty_id) DO NOTHING`
|
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))
|
rows, err := tx.QueryContext(ctx, ProposalCooldownEventsSQL, playerID, string(playlist), now.Add(-30*time.Minute))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -163,12 +169,44 @@ func recordProposalDeclineCooldown(ctx context.Context, tx *sql.Tx, playerID str
|
|||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return err
|
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)
|
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
|
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
|
const ProposalRevisionBumpSQL = `UPDATE proposals
|
||||||
SET revision = revision + 1
|
SET revision = revision + 1
|
||||||
WHERE proposal_id = $1 AND state = 'OPEN'`
|
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 {
|
if _, err := tx.ExecContext(ctx, ProposalParticipantExpireSQL, proposalID, now); err != nil {
|
||||||
return domain.Proposal{}, err
|
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 {
|
if _, err := tx.ExecContext(ctx, ProposalExpireRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow)); err != nil {
|
||||||
return domain.Proposal{}, err
|
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 {
|
if _, err := tx.ExecContext(ctx, ProposalParticipantExpireSQL, proposalID, now); err != nil {
|
||||||
return err
|
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 {
|
if _, err := tx.ExecContext(ctx, ProposalExpireRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func TestProposalRecoverySQLBindsParticipantAndExpiresAtReadBoundary(t *testing.
|
|||||||
ProposalExpireRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "state = 'EXPIRED'"},
|
ProposalExpireRequeueSQL: {"SET state = 'QUEUED'", "state = 'PROPOSED'", "state = 'EXPIRED'"},
|
||||||
ProposalCooldownEventsSQL: {"kind IN ('PROPOSAL_DECLINED', 'PROPOSAL_TIMEOUT')", "starts_at >= $3", "ORDER BY starts_at"},
|
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"},
|
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'"},
|
OpenProposalForCancelledTicketSQL: {"proposal_participants", "state = 'OPEN'"},
|
||||||
} {
|
} {
|
||||||
for _, fragment := range fragments {
|
for _, fragment := range fragments {
|
||||||
|
|||||||
Reference in New Issue
Block a user