package domain import ( "crypto/sha256" "errors" "fmt" "sort" "strings" "sync" "time" ) const ( QueueHeartbeatInterval = 10 * time.Second QueueExpiryWindow = 30 * time.Second ) var ( ErrPlayerQueued = errors.New("player already owns an active queue ticket") ErrTicketNotFound = errors.New("queue ticket not found") ErrNotTicketOwner = errors.New("queue ticket is owned by another player") ErrTicketExpired = errors.New("queue ticket expired") ErrPlayerCooldown = errors.New("player is on matchmaking cooldown") ) type QueueTicket struct { TicketID string PlayerID string ProposalID string MatchID string Candidate Candidate Playlist Playlist State State Revision uint64 EnqueuedAt time.Time ExpiresAt time.Time } type queueMutation struct { digest [32]byte ticket QueueTicket } type Queue struct { mu sync.Mutex tickets map[string]QueueTicket byPlayer map[string]string mutations map[string]queueMutation } func NewQueue() *Queue { return &Queue{tickets: make(map[string]QueueTicket), byPlayer: make(map[string]string), mutations: make(map[string]queueMutation)} } // Create is the in-process equivalent of the PostgreSQL ownership fence. The // production adapter must perform the same check in one transaction and use // the same idempotency semantics. func (q *Queue) Create(playerID, ticketID, idempotencyKey string, candidate Candidate, now time.Time) (QueueTicket, error) { q.mu.Lock() defer q.mu.Unlock() digest := sha256.Sum256([]byte(createPayload(playerID, ticketID, candidate))) if prior, ok := q.mutations[idempotencyKey]; ok { if prior.digest != digest { return QueueTicket{}, fmt.Errorf("%w: create payload changed", ErrConflict) } return prior.ticket, nil } if idempotencyKey == "" || playerID == "" || ticketID == "" || candidate.TicketID != ticketID || candidate.PlayerID != playerID { return QueueTicket{}, fmt.Errorf("%w: invalid queue create", ErrConflict) } if _, ok := q.byPlayer[playerID]; ok { return QueueTicket{}, ErrPlayerQueued } if _, ok := q.tickets[ticketID]; ok { return QueueTicket{}, fmt.Errorf("%w: ticket ID already exists", ErrConflict) } ticket := QueueTicket{TicketID: ticketID, PlayerID: playerID, Candidate: candidate, Playlist: candidate.Playlist, State: Queued, EnqueuedAt: now, ExpiresAt: now.Add(QueueExpiryWindow)} q.tickets[ticketID] = ticket q.byPlayer[playerID] = ticketID q.mutations[idempotencyKey] = queueMutation{digest: digest, ticket: ticket} return ticket, nil } func (q *Queue) Heartbeat(playerID, ticketID, idempotencyKey string, expectedRevision uint64, now time.Time) (QueueTicket, error) { q.mu.Lock() defer q.mu.Unlock() digest := sha256.Sum256([]byte(fmt.Sprintf("heartbeat:%s:%d", ticketID, expectedRevision))) if prior, ok := q.mutations[idempotencyKey]; ok { if prior.digest != digest { return QueueTicket{}, fmt.Errorf("%w: heartbeat payload changed", ErrConflict) } return prior.ticket, nil } ticket, err := q.ownedTicket(playerID, ticketID) if err != nil { return QueueTicket{}, err } if now.After(ticket.ExpiresAt) || now.Equal(ticket.ExpiresAt) { return QueueTicket{}, ErrTicketExpired } if ticket.Revision != expectedRevision { return QueueTicket{}, ErrStaleRevision } if ticket.State != Queued && ticket.State != Proposed { return QueueTicket{}, fmt.Errorf("%w: heartbeat in %s", ErrConflict, ticket.State) } if idempotencyKey == "" { return QueueTicket{}, fmt.Errorf("%w: empty heartbeat key", ErrConflict) } ticket.Revision++ ticket.ExpiresAt = now.Add(QueueExpiryWindow) q.tickets[ticketID] = ticket q.mutations[idempotencyKey] = queueMutation{digest: digest, ticket: ticket} return ticket, nil } func (q *Queue) Cancel(playerID, ticketID, idempotencyKey string, expectedRevision uint64, now time.Time) (QueueTicket, error) { q.mu.Lock() defer q.mu.Unlock() digest := sha256.Sum256([]byte(fmt.Sprintf("cancel:%s:%d", ticketID, expectedRevision))) if prior, ok := q.mutations[idempotencyKey]; ok { if prior.digest != digest { return QueueTicket{}, fmt.Errorf("%w: cancel payload changed", ErrConflict) } return prior.ticket, nil } ticket, err := q.ownedTicket(playerID, ticketID) if err != nil { return QueueTicket{}, err } if ticket.Revision != expectedRevision { return QueueTicket{}, ErrStaleRevision } if idempotencyKey == "" { return QueueTicket{}, fmt.Errorf("%w: empty cancel key", ErrConflict) } ticket.State = Cancelled ticket.Revision++ ticket.ExpiresAt = now q.tickets[ticketID] = ticket delete(q.byPlayer, playerID) q.mutations[idempotencyKey] = queueMutation{digest: digest, ticket: ticket} return ticket, nil } // Get is the recovery read used after a client restart or missed event. It // never returns another player's ticket and expires stale queue presence before // deciding what the caller may resume. func (q *Queue) Get(playerID, ticketID string, now time.Time) (QueueTicket, error) { q.mu.Lock() defer q.mu.Unlock() q.expireLocked(now) ticket, err := q.ownedTicket(playerID, ticketID) if err != nil { return QueueTicket{}, err } if ticket.State == Expired { return QueueTicket{}, ErrTicketExpired } return ticket, nil } func (q *Queue) Expire(now time.Time) []QueueTicket { q.mu.Lock() defer q.mu.Unlock() return q.expireLocked(now) } // RecordProbe stores server-computed RTT metadata on the player's active // ticket. It never accepts client-provided latency and refuses expired or // non-queueable tickets. func (q *Queue) RecordProbe(playerID, region string, rtt time.Duration, now time.Time) error { if playerID == "" || (region != "EU" && region != "NA") || rtt < 0 || now.IsZero() { return fmt.Errorf("invalid probe recording") } q.mu.Lock() defer q.mu.Unlock() ticketID, ok := q.byPlayer[playerID] if !ok { return ErrTicketNotFound } ticket, ok := q.tickets[ticketID] if !ok || (ticket.State != Queued && ticket.State != Proposed) { return ErrTicketNotFound } if !now.Before(ticket.ExpiresAt) { return ErrTicketExpired } if ticket.Candidate.PredictedRTT == nil { ticket.Candidate.PredictedRTT = make(map[string]float64) } ticket.Candidate.PredictedRTT[region] = float64(rtt) / float64(time.Millisecond) q.tickets[ticketID] = ticket return nil } func (q *Queue) expireLocked(now time.Time) []QueueTicket { var expired []QueueTicket for id, ticket := range q.tickets { if (ticket.State == Queued || ticket.State == Proposed) && !now.Before(ticket.ExpiresAt) { ticket.State = Expired ticket.Revision++ q.tickets[id] = ticket delete(q.byPlayer, ticket.PlayerID) expired = append(expired, ticket) } } sort.Slice(expired, func(i, j int) bool { return expired[i].TicketID < expired[j].TicketID }) return expired } func (q *Queue) Candidates(now time.Time) []Candidate { q.mu.Lock() defer q.mu.Unlock() q.expireLocked(now) result := make([]Candidate, 0) for _, ticket := range q.tickets { if ticket.State == Queued { result = append(result, ticket.Candidate) } } sort.Slice(result, func(i, j int) bool { if !result[i].EnqueuedAt.Equal(result[j].EnqueuedAt) { return result[i].EnqueuedAt.Before(result[j].EnqueuedAt) } return result[i].TicketID < result[j].TicketID }) return result } func (q *Queue) ownedTicket(playerID, ticketID string) (QueueTicket, error) { ticket, ok := q.tickets[ticketID] if !ok { return QueueTicket{}, ErrTicketNotFound } if ticket.PlayerID != playerID { return QueueTicket{}, ErrNotTicketOwner } return ticket, nil } func createPayload(playerID, ticketID string, candidate Candidate) string { regions := make([]string, 0, len(candidate.PredictedRTT)) for region := range candidate.PredictedRTT { regions = append(regions, region) } sort.Strings(regions) rtts := make([]string, 0, len(regions)) for _, region := range regions { rtts = append(rtts, fmt.Sprintf("%s=%.9f", region, candidate.PredictedRTT[region])) } return strings.Join([]string{playerID, ticketID, candidate.PlayerID, candidate.TicketID, string(candidate.Playlist), candidate.ClientBuild, fmt.Sprintf("%d", candidate.ProtocolVersion), fmt.Sprintf("%.9f", candidate.Rating), candidate.EnqueuedAt.UTC().Format(time.RFC3339Nano), strings.Join(rtts, ",")}, "\x00") }