mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
254 lines
8.1 KiB
Go
254 lines
8.1 KiB
Go
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")
|
|
}
|