package store import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/json" "fmt" "sort" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) const InitialConnectIdempotencyScope = "match.initial_connect" const initialConnectMatchLockSQL = `SELECT playlist, state, revision FROM matches WHERE match_id = $1 FOR UPDATE` const initialConnectParticipantsSQL = `SELECT player_id, ticket_id, team, slot, connected_at, participation_active FROM match_participants WHERE match_id = $1 ORDER BY player_id FOR UPDATE` const initialConnectIdempotencyInsertSQL = `INSERT INTO idempotency_keys (scope, idempotency_key, payload_digest, result) VALUES ($1, $2, $3, '{}'::jsonb) ON CONFLICT (scope, idempotency_key) DO NOTHING` const initialConnectIdempotencySelectSQL = `SELECT payload_digest, result FROM idempotency_keys WHERE scope = $1 AND idempotency_key = $2 FOR UPDATE` const initialConnectMatchUpdateSQL = `UPDATE matches SET state = $2, revision = revision + 1 WHERE match_id = $1 RETURNING revision` const initialConnectDeactivateSQL = `UPDATE match_participants SET participation_active = FALSE, abandoned_at = $3 WHERE match_id = $1 AND player_id = ANY($2)` const initialConnectReleaseAllSQL = `UPDATE match_participants SET participation_active = FALSE WHERE match_id = $1 AND participation_active` const initialConnectTicketNoShowSQL = `UPDATE queue_tickets q SET state = 'FAILED', revision = revision + 1 FROM match_participants mp WHERE mp.match_id = $1 AND mp.player_id = ANY($2) AND q.ticket_id = mp.ticket_id AND q.player_id = mp.player_id AND q.state IN ('ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING')` const initialConnectTicketInnocentCancelSQL = `UPDATE queue_tickets q SET state = 'QUEUED', expires_at = $2, revision = revision + 1 FROM match_participants mp WHERE mp.match_id = $1 AND mp.player_id = ANY($3) AND q.ticket_id = mp.ticket_id AND q.player_id = mp.player_id AND q.state IN ('ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING')` const initialConnectTicketConnectedLiveSQL = `UPDATE queue_tickets q SET state = 'LIVE', revision = revision + 1 FROM match_participants mp WHERE mp.match_id = $1 AND mp.player_id = ANY($2) AND q.ticket_id = mp.ticket_id AND q.player_id = mp.player_id AND q.state IN ('ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING')` const initialConnectPenaltySQL = `INSERT INTO penalties (penalty_id, player_id, match_id, playlist, kind, starts_at, ends_at) VALUES ($1, $2, $3, $4, 'INITIAL_CONNECT_NO_SHOW', $5, $6) ON CONFLICT (penalty_id) DO NOTHING` const initialConnectOutboxSQL = `INSERT INTO outbox (event_id, aggregate_type, aggregate_id, revision, event_type, payload) VALUES ($1, 'match', $2, $3, 'state_changed', $4) ON CONFLICT DO NOTHING` type initialConnectParticipant struct { PlayerID string TicketID string Team int Slot int ConnectedAt sql.NullTime Active bool } // ApplyInitialConnectPlan atomically reconciles the pre-live connect window. // It is deliberately a store operation: no-show penalties and innocent-ticket // requeue must commit with the match transition or neither may commit. func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempotencyKey string, plan domain.InitialConnectPlan, now time.Time) error { validActionState := (plan.Action == domain.InitialConnectStart || plan.Action == domain.InitialConnectStartWithBot) && plan.MatchState == domain.Live || plan.Action == domain.InitialConnectCancel && plan.MatchState == domain.Cancelled if db == nil || matchID == "" || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() || !validActionState { return fmt.Errorf("invalid initial-connect transaction arguments") } digest, err := initialConnectDigest(matchID, plan) if err != nil { return err } return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { inserted, err := tx.ExecContext(ctx, initialConnectIdempotencyInsertSQL, InitialConnectIdempotencyScope, idempotencyKey, digest[:]) if err != nil { return err } count, err := inserted.RowsAffected() if err != nil { return err } if count == 0 { var prior []byte var result []byte if err := tx.QueryRowContext(ctx, initialConnectIdempotencySelectSQL, InitialConnectIdempotencyScope, idempotencyKey).Scan(&prior, &result); err != nil { return err } if !bytes.Equal(prior, digest[:]) { return fmt.Errorf("%w: conflicting initial-connect request", domain.ErrConflict) } return nil } var playlist, state string var revision int64 if err := tx.QueryRowContext(ctx, initialConnectMatchLockSQL, matchID).Scan(&playlist, &state, &revision); err != nil { return err } if state != string(domain.AssignmentReady) && state != string(domain.Assigned) && state != string(domain.Connecting) { return fmt.Errorf("%w: match is not awaiting initial connect: %s", domain.ErrConflict, state) } participants, err := loadInitialConnectParticipants(ctx, tx, matchID) if err != nil { return err } if err := validateInitialConnectPlan(plan, participants, domain.Playlist(playlist)); err != nil { return fmt.Errorf("%w: %v", domain.ErrConflict, err) } if plan.Action == domain.InitialConnectCancel { if _, err := tx.ExecContext(ctx, initialConnectReleaseAllSQL, matchID); err != nil { return err } } if _, err := tx.ExecContext(ctx, initialConnectDeactivateSQL, matchID, initialConnectNoShowIDs(plan), now); err != nil { return err } if _, err := tx.ExecContext(ctx, initialConnectTicketNoShowSQL, matchID, initialConnectNoShowIDs(plan)); err != nil { return err } if plan.Action == domain.InitialConnectCancel { if _, err := tx.ExecContext(ctx, initialConnectTicketInnocentCancelSQL, matchID, now.Add(domain.QueueExpiryWindow), plan.Connected); err != nil { return err } } else if _, err := tx.ExecContext(ctx, initialConnectTicketConnectedLiveSQL, matchID, plan.Connected); err != nil { return err } for _, noShow := range plan.NoShows { penaltyID := "initial-connect:" + matchID + ":" + noShow.PlayerID if _, err := tx.ExecContext(ctx, initialConnectPenaltySQL, penaltyID, noShow.PlayerID, matchID, playlist, noShow.AbandonedAt, noShow.AbandonedAt.Add(noShow.Cooldown)); err != nil { return err } } var finalRevision int64 if err := tx.QueryRowContext(ctx, initialConnectMatchUpdateSQL, matchID, string(plan.MatchState)).Scan(&finalRevision); err != nil { return err } payload, _ := json.Marshal(map[string]any{"match_id": matchID, "state": plan.MatchState, "action": plan.Action}) if _, err := tx.ExecContext(ctx, initialConnectOutboxSQL, "initial-connect:"+matchID+fmt.Sprintf(":%d", finalRevision), matchID, finalRevision, payload); err != nil { return err } stored, _ := json.Marshal(map[string]any{"match_id": matchID, "state": plan.MatchState, "revision": finalRevision}) _, err = tx.ExecContext(ctx, `UPDATE idempotency_keys SET result = $3 WHERE scope = $1 AND idempotency_key = $2`, InitialConnectIdempotencyScope, idempotencyKey, stored) return err }) } func initialConnectNoShowIDs(plan domain.InitialConnectPlan) []string { result := make([]string, len(plan.NoShows)) for i := range plan.NoShows { result[i] = plan.NoShows[i].PlayerID } return result } func initialConnectDigest(matchID string, plan domain.InitialConnectPlan) ([32]byte, error) { copyPlan := plan sort.Strings(copyPlan.Connected) sort.Slice(copyPlan.NoShows, func(i, j int) bool { return copyPlan.NoShows[i].PlayerID < copyPlan.NoShows[j].PlayerID }) b, err := json.Marshal(struct { MatchID string Plan domain.InitialConnectPlan }{matchID, copyPlan}) if err != nil { return [32]byte{}, err } return sha256.Sum256(b), nil } func loadInitialConnectParticipants(ctx context.Context, tx *sql.Tx, matchID string) ([]initialConnectParticipant, error) { rows, err := tx.QueryContext(ctx, initialConnectParticipantsSQL, matchID) if err != nil { return nil, err } defer rows.Close() var result []initialConnectParticipant for rows.Next() { var p initialConnectParticipant if err := rows.Scan(&p.PlayerID, &p.TicketID, &p.Team, &p.Slot, &p.ConnectedAt, &p.Active); err != nil { return nil, err } result = append(result, p) } return result, rows.Err() } func validateInitialConnectPlan(plan domain.InitialConnectPlan, participants []initialConnectParticipant, playlist domain.Playlist) error { if len(participants) == 0 || (plan.Action == domain.InitialConnectStartWithBot && playlist != domain.Casual) || (plan.Action == domain.InitialConnectCancel && plan.MatchState != domain.Cancelled) || (plan.Action == domain.InitialConnectStart && (plan.MatchState != domain.Live || len(plan.NoShows) != 0 || len(plan.CasualLineup) != 0)) { return fmt.Errorf("invalid initial-connect plan") } known, connected, missing := map[string]bool{}, map[string]bool{}, map[string]bool{} stored := make(map[string]initialConnectParticipant, len(participants)) for _, p := range participants { if p.PlayerID == "" || !p.Active || p.Team < 0 || p.Team > 1 || p.Slot < 0 || p.Slot > 5 || p.Slot/3 != p.Team || known[p.PlayerID] { return fmt.Errorf("invalid stored participant roster") } known[p.PlayerID] = true stored[p.PlayerID] = p if p.ConnectedAt.Valid { connected[p.PlayerID] = true } } for _, id := range plan.Connected { if !known[id] || !connected[id] || missing[id] { return fmt.Errorf("invalid connected participant") } missing[id] = true } for _, noShow := range plan.NoShows { if !known[noShow.PlayerID] || connected[noShow.PlayerID] || missing[noShow.PlayerID] || noShow.Cooldown <= 0 || noShow.AbandonedAt.IsZero() { return fmt.Errorf("invalid no-show participant") } missing[noShow.PlayerID] = true } if len(missing) != len(known) { return fmt.Errorf("initial-connect plan does not cover roster") } if plan.Action == domain.InitialConnectStart && len(connected) != len(known) { return fmt.Errorf("initial-connect start requires complete connected roster") } if plan.Action == domain.InitialConnectStartWithBot { if len(plan.CasualLineup) != 6 { return fmt.Errorf("casual bot lineup must contain six players") } lineupSlots := make(map[int]bool, 6) lineupPlayers := make(map[string]bool, 6) for _, slot := range plan.CasualLineup { if slot.Slot < 0 || slot.Slot > 5 || slot.Team != slot.Slot/3 || lineupSlots[slot.Slot] || slot.PlayerID == "" || lineupPlayers[slot.PlayerID] { return fmt.Errorf("invalid casual bot lineup") } lineupSlots[slot.Slot] = true lineupPlayers[slot.PlayerID] = true if slot.IsBot { continue } if !connected[slot.PlayerID] { return fmt.Errorf("lineup contains non-connected human") } participant := stored[slot.PlayerID] if participant.Slot != slot.Slot || participant.Team != slot.Team { return fmt.Errorf("lineup moves connected human from assigned slot") } } for id := range connected { if !lineupPlayers[id] { return fmt.Errorf("lineup omits connected human") } } } return nil }