mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
102 lines
3.6 KiB
Go
102 lines
3.6 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"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, match_arena_path)
|
|
VALUES ($1, $2, 'OPEN', $3, 0, NULLIF($4, ''), NULLIF($5, 0), NULLIF($6, ''))`
|
|
|
|
const ProposalOutboxInsertSQL = `INSERT INTO outbox
|
|
(event_id, aggregate_type, aggregate_id, revision, event_type, payload)
|
|
VALUES ($1, 'proposal', $2, 0, 'proposal_changed', $3)`
|
|
|
|
// 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, proposal.ArenaPath); err != nil {
|
|
return err
|
|
}
|
|
players := make([]string, 0, len(proposal.Participants))
|
|
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")
|
|
}
|
|
players = append(players, participant.PlayerID)
|
|
}
|
|
payload, err := json.Marshal(map[string]any{
|
|
"event": "proposal_changed", "revision": uint64(0), "resource_id": proposal.ProposalID,
|
|
"occurred_at": now, "state": string(proposal.State), "player_ids": players,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, ProposalOutboxInsertSQL, proposal.ProposalID, proposal.ProposalID, payload); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
if proposal.Playlist == domain.Ranked && !domain.IsRankedArenaPath(proposal.ArenaPath) {
|
|
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
|
|
}
|