package store import ( "context" "database/sql" "fmt" "sort" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) // AcceptedMatchPlan is the durable hand-off from an accepted proposal to // allocation. Team and slot originate from the matcher formation and are // persisted before allocation so later roster issuance cannot re-partition a // match after players have accepted it. type AcceptedMatchPlan struct { MatchID string ProposalID string Region string Protocol int ArenaPath string Players []MatchPlayer } type MatchPlayer struct { PlayerID string Team int Slot int } const AcceptedProposalLockSQL = `SELECT playlist, state FROM proposals WHERE proposal_id = $1 FOR UPDATE` const AcceptedProposalParticipantsSQL = `SELECT player_id, ticket_id, response FROM proposal_participants WHERE proposal_id = $1 ORDER BY player_id FOR UPDATE` const AcceptedMatchInsertSQL = `INSERT INTO matches (match_id, playlist, state, region, protocol_version, arena_path) VALUES ($1, $2, 'ALLOCATING', $3, $4, NULLIF($5, '')) ON CONFLICT (match_id) DO NOTHING` const AcceptedMatchSelectSQL = `SELECT playlist, region, protocol_version, arena_path FROM matches WHERE match_id = $1 FOR UPDATE` const AcceptedMatchParticipantsSQL = `SELECT player_id, ticket_id, slot, team FROM match_participants WHERE match_id = $1 ORDER BY player_id` const AcceptedTicketSQL = `UPDATE queue_tickets SET state = 'ACCEPTED', revision = revision + 1 WHERE ticket_id = $1 AND player_id = $2 AND state = 'PROPOSED' RETURNING protocol_version` const AcceptedMatchParticipantInsertSQL = `INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team) VALUES ($1, $2, $3, $4, $5)` const StoredProposalMatchPlanSQL = `SELECT match_region, match_protocol, match_arena_path FROM proposals WHERE proposal_id = $1 AND state = 'ACCEPTED'` const StoredProposalMatchPlayersSQL = `SELECT player_id, team, slot FROM proposal_participants WHERE proposal_id = $1 AND response = 'ACCEPTED' ORDER BY player_id` // PromoteStoredAcceptedProposal materializes the exact topology persisted by // the matcher once every player has accepted. The deterministic match ID makes // a request retry converge after an API/worker interruption. func PromoteStoredAcceptedProposal(ctx context.Context, db *sql.DB, proposalID string, now time.Time) error { if db == nil || proposalID == "" || now.IsZero() { return fmt.Errorf("invalid stored proposal promotion arguments") } plan := AcceptedMatchPlan{MatchID: "match-" + proposalID, ProposalID: proposalID} if err := db.QueryRowContext(ctx, StoredProposalMatchPlanSQL, proposalID).Scan(&plan.Region, &plan.Protocol, &plan.ArenaPath); err != nil { return err } rows, err := db.QueryContext(ctx, StoredProposalMatchPlayersSQL, proposalID) if err != nil { return err } defer rows.Close() for rows.Next() { var player MatchPlayer if err := rows.Scan(&player.PlayerID, &player.Team, &player.Slot); err != nil { return err } plan.Players = append(plan.Players, player) } if err := rows.Err(); err != nil { return err } if err := rows.Close(); err != nil { return err } return CreateMatchFromAcceptedProposal(ctx, db, plan, now) } // CreateMatchFromAcceptedProposal atomically promotes the exact accepted // roster into an ALLOCATING match. An existing match ID is an idempotent retry // only if every durable field and participant assignment matches the request. func CreateMatchFromAcceptedProposal(ctx context.Context, db *sql.DB, plan AcceptedMatchPlan, now time.Time) error { if db == nil || now.IsZero() || !validAcceptedMatchPlan(plan) { return fmt.Errorf("invalid accepted match plan") } return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { var playlist, proposalState string if err := tx.QueryRowContext(ctx, AcceptedProposalLockSQL, plan.ProposalID).Scan(&playlist, &proposalState); err != nil { return err } if proposalState != string(domain.Accepted) { return fmt.Errorf("proposal is not accepted") } if !validAcceptedPlaylistCount(domain.Playlist(playlist), len(plan.Players)) { return fmt.Errorf("accepted proposal playlist does not match player count") } if domain.Playlist(playlist) == domain.Ranked && !domain.IsRankedArenaPath(plan.ArenaPath) { return fmt.Errorf("ranked accepted match plan has invalid arena") } participants, err := acceptedProposalParticipants(ctx, tx, plan) if err != nil { return err } inserted, err := tx.ExecContext(ctx, AcceptedMatchInsertSQL, plan.MatchID, playlist, plan.Region, plan.Protocol, plan.ArenaPath) if err != nil { return err } changed, err := inserted.RowsAffected() if err != nil { return err } if changed == 0 { return verifyAcceptedMatchReplay(ctx, tx, plan, domain.Playlist(playlist), participants) } for _, player := range plan.Players { ticketID := participants[player.PlayerID] var protocol int if err := tx.QueryRowContext(ctx, AcceptedTicketSQL, ticketID, player.PlayerID).Scan(&protocol); err != nil { return fmt.Errorf("accepted ticket transition: %w", err) } if protocol != plan.Protocol { return fmt.Errorf("accepted ticket protocol mismatch") } if _, err := tx.ExecContext(ctx, AcceptedMatchParticipantInsertSQL, plan.MatchID, player.PlayerID, ticketID, player.Slot, player.Team); err != nil { return err } } return nil }) } func validAcceptedPlaylistCount(playlist domain.Playlist, count int) bool { if playlist == domain.Ranked { return count == 6 } return playlist == domain.Casual && count >= 2 && count <= 6 } func validAcceptedMatchPlan(plan AcceptedMatchPlan) bool { if plan.MatchID == "" || plan.ProposalID == "" || (plan.Region != "EU" && plan.Region != "NA") || plan.Protocol < 1 || len(plan.Players) < 2 || len(plan.Players) > 6 { return false } players := make(map[string]struct{}, len(plan.Players)) slots := make(map[int]struct{}, len(plan.Players)) teams := [2]int{} for _, player := range plan.Players { if player.PlayerID == "" || player.Team < 0 || player.Team > 1 || player.Slot < 0 || player.Slot > 5 { return false } if _, exists := players[player.PlayerID]; exists { return false } if _, exists := slots[player.Slot]; exists { return false } players[player.PlayerID] = struct{}{} slots[player.Slot] = struct{}{} teams[player.Team]++ } return teams[0] > 0 && teams[1] > 0 } func acceptedProposalParticipants(ctx context.Context, tx *sql.Tx, plan AcceptedMatchPlan) (map[string]string, error) { rows, err := tx.QueryContext(ctx, AcceptedProposalParticipantsSQL, plan.ProposalID) if err != nil { return nil, err } defer rows.Close() participants := make(map[string]string, len(plan.Players)) for rows.Next() { var playerID, ticketID, response string if err := rows.Scan(&playerID, &ticketID, &response); err != nil { return nil, err } if response != string(domain.AcceptedResponse) { return nil, fmt.Errorf("proposal participant has not accepted") } participants[playerID] = ticketID } if err := rows.Err(); err != nil { return nil, err } if err := rows.Close(); err != nil { return nil, err } if len(participants) != len(plan.Players) { return nil, fmt.Errorf("proposal participants do not match accepted plan") } for _, player := range plan.Players { if participants[player.PlayerID] == "" { return nil, fmt.Errorf("accepted plan includes non-participant") } } return participants, nil } func verifyAcceptedMatchReplay(ctx context.Context, tx *sql.Tx, plan AcceptedMatchPlan, playlist domain.Playlist, tickets map[string]string) error { var existingPlaylist, region string var protocol int var arenaPath sql.NullString if err := tx.QueryRowContext(ctx, AcceptedMatchSelectSQL, plan.MatchID).Scan(&existingPlaylist, ®ion, &protocol, &arenaPath); err != nil { return err } // Match state and server ownership are intentionally absent: allocation may // advance immediately after the first promotion commits. A retry after a // lost API response is valid whenever the immutable topology still matches. if existingPlaylist != string(playlist) || region != plan.Region || protocol != plan.Protocol || arenaPath.String != plan.ArenaPath || arenaPath.Valid != (plan.ArenaPath != "") { return domain.ErrConflict } rows, err := tx.QueryContext(ctx, AcceptedMatchParticipantsSQL, plan.MatchID) if err != nil { return err } defer rows.Close() existing := make(map[string]MatchPlayer, len(plan.Players)) for rows.Next() { var player MatchPlayer var ticketID string if err := rows.Scan(&player.PlayerID, &ticketID, &player.Slot, &player.Team); err != nil { return err } if tickets[player.PlayerID] != ticketID { return domain.ErrConflict } existing[player.PlayerID] = player } if err := rows.Err(); err != nil { return err } if len(existing) != len(plan.Players) { return domain.ErrConflict } for _, player := range plan.Players { if existing[player.PlayerID] != player { return domain.ErrConflict } } return nil } // MatchPlayersFromTeams turns the deterministic matcher partition into the // persisted six-slot topology. Each team is sorted by player ID first, so slot // assignment does not depend on cache/database row order. func MatchPlayersFromTeams(teams domain.Teams) ([]MatchPlayer, error) { if len(teams.Team0) == 0 || len(teams.Team1) == 0 || len(teams.Team0)+len(teams.Team1) > 6 { return nil, fmt.Errorf("invalid match teams") } result := make([]MatchPlayer, 0, len(teams.Team0)+len(teams.Team1)) add := func(team int, players []domain.Candidate) { ordered := append([]domain.Candidate(nil), players...) sort.Slice(ordered, func(i, j int) bool { return ordered[i].PlayerID < ordered[j].PlayerID }) for index, player := range ordered { result = append(result, MatchPlayer{PlayerID: player.PlayerID, Team: team, Slot: team*3 + index}) } } add(0, teams.Team0) add(1, teams.Team1) return result, nil }