package store import ( "context" "database/sql" "fmt" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) const ProposalInsertSQL = `INSERT INTO proposals (proposal_id, playlist, state, expires_at, revision, match_region, match_protocol) VALUES ($1, $2, 'OPEN', $3, 0, NULLIF($4, ''), NULLIF($5, 0))` // CreateProposal atomically claims the queue tickets and creates the proposal. // Every statement runs inside the same SERIALIZABLE retry callback; callers // must never publish a proposal from a cache-only candidate list. func CreateProposal(ctx context.Context, db *sql.DB, proposal domain.Proposal, ticketIDs map[string]string, now time.Time) error { if proposal.ProposalID == "" || len(proposal.Participants) == 0 { return fmt.Errorf("invalid proposal transaction") } if !validProposalMatchPlan(proposal) { return fmt.Errorf("invalid proposal match plan") } return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { if _, err := tx.ExecContext(ctx, ProposalInsertSQL, proposal.ProposalID, proposal.Playlist, proposal.ExpiresAt, proposal.Region, proposal.Protocol); err != nil { return err } for _, participant := range proposal.Participants { ticketID := ticketIDs[participant.PlayerID] if participant.PlayerID == "" || ticketID == "" { return fmt.Errorf("missing proposal ticket mapping") } if _, err := tx.ExecContext(ctx, ProposalParticipantInsertSQL, proposal.ProposalID, participant.PlayerID, ticketID, nullablePlanField(proposal.Region != "", participant.Team), nullablePlanField(proposal.Region != "", participant.Slot)); err != nil { return err } result, err := tx.ExecContext(ctx, QueueTicketProposeSQL, ticketID, participant.PlayerID, string(proposal.Playlist), now) if err != nil { return err } changed, err := result.RowsAffected() if err != nil { return err } if changed != 1 { return fmt.Errorf("queue ticket claim lost") } } return nil }) } func validProposalMatchPlan(proposal domain.Proposal) bool { if proposal.Region == "" && proposal.Protocol == 0 { return true // Legacy/direct callers have no matcher formation to persist. } if (proposal.Region != "EU" && proposal.Region != "NA") || proposal.Protocol < 1 { return false } seenSlots := make(map[int]struct{}, len(proposal.Participants)) teams := [2]int{} for _, participant := range proposal.Participants { if participant.Team < 0 || participant.Team > 1 || participant.Slot < 0 || participant.Slot > 5 { return false } if _, exists := seenSlots[participant.Slot]; exists { return false } seenSlots[participant.Slot] = struct{}{} teams[participant.Team]++ } return teams[0] > 0 && teams[1] > 0 } func nullablePlanField(enabled bool, value int) any { if !enabled { return nil } return value }