mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
293 lines
7.7 KiB
Go
293 lines
7.7 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
MaxPlacementRTT = 100.0
|
|
MinRatingTolerance = 100.0
|
|
MaxRatingTolerance = 400.0
|
|
RatingWidenStep = 25.0
|
|
RatingWidenPeriod = 30.0
|
|
)
|
|
|
|
// QueueSpec is the compatibility contract selected by the authenticated
|
|
// client. CandidateProviderV2 may use it to resolve a server-owned projection
|
|
// from the verified account and current deployment configuration.
|
|
type QueueSpec struct {
|
|
Playlist Playlist
|
|
ClientBuild string
|
|
ProtocolVersion int
|
|
}
|
|
|
|
// Candidate is the server-side projection of a verified, live queue ticket.
|
|
// RTT values come from backend probes, never from the client request body.
|
|
type Candidate struct {
|
|
TicketID string
|
|
PlayerID string
|
|
Playlist Playlist
|
|
ClientBuild string
|
|
ProtocolVersion int
|
|
Rating float64
|
|
EnqueuedAt time.Time
|
|
PredictedRTT map[string]float64
|
|
}
|
|
|
|
type Selection struct {
|
|
Players []Candidate
|
|
Region string
|
|
WorstRTT float64
|
|
TotalRTT float64
|
|
RatingRange float64
|
|
TotalWaitSeconds float64
|
|
}
|
|
|
|
type MatchFormation struct {
|
|
Selection Selection
|
|
Teams Teams
|
|
}
|
|
|
|
// FormFromQueue is the queue-backed matcher boundary. Queue.Candidates owns
|
|
// expiry and ordering; this method chooses the oldest projected candidate as
|
|
// the anchor, then forms and partitions one deterministic match.
|
|
func FormFromQueue(queue *Queue, size int, now time.Time) (MatchFormation, error) {
|
|
if queue == nil {
|
|
return MatchFormation{}, fmt.Errorf("queue is required")
|
|
}
|
|
candidates := queue.Candidates(now)
|
|
if len(candidates) == 0 {
|
|
return MatchFormation{}, fmt.Errorf("queue is empty")
|
|
}
|
|
selection, err := SelectCandidates(candidates[0], candidates[1:], size, now)
|
|
if err != nil {
|
|
return MatchFormation{}, err
|
|
}
|
|
teams, err := PartitionTeams(selection.Players)
|
|
if err != nil {
|
|
return MatchFormation{}, err
|
|
}
|
|
return MatchFormation{Selection: selection, Teams: teams}, nil
|
|
}
|
|
|
|
func RatingTolerance(waitSeconds float64) float64 {
|
|
if waitSeconds < 0 {
|
|
waitSeconds = 0
|
|
}
|
|
value := MinRatingTolerance + RatingWidenStep*float64(int(waitSeconds/RatingWidenPeriod))
|
|
if value > MaxRatingTolerance {
|
|
return MaxRatingTolerance
|
|
}
|
|
return value
|
|
}
|
|
|
|
func SelectCandidates(anchor Candidate, candidates []Candidate, size int, now time.Time) (Selection, error) {
|
|
if size < 1 {
|
|
return Selection{}, fmt.Errorf("candidate size must be positive")
|
|
}
|
|
pool := make([]Candidate, 0, len(candidates)+1)
|
|
seen := map[string]bool{}
|
|
seenPlayers := map[string]bool{}
|
|
add := func(candidate Candidate) {
|
|
if validCandidate(candidate) && compatibleMetadata(anchor, candidate) && !seen[candidate.TicketID] && !seenPlayers[candidate.PlayerID] {
|
|
seen[candidate.TicketID] = true
|
|
seenPlayers[candidate.PlayerID] = true
|
|
pool = append(pool, candidate)
|
|
}
|
|
}
|
|
add(anchor)
|
|
for _, candidate := range candidates {
|
|
add(candidate)
|
|
}
|
|
if len(pool) < size {
|
|
return Selection{}, fmt.Errorf("only %d compatible candidates available for size %d", len(pool), size)
|
|
}
|
|
|
|
best := Selection{}
|
|
found := false
|
|
chosen := make([]Candidate, 0, size)
|
|
var visit func(int)
|
|
visit = func(start int) {
|
|
if len(chosen) == size {
|
|
if !containsTicket(chosen, anchor.TicketID) || !compatibleSet(chosen, now) {
|
|
return
|
|
}
|
|
selection, ok := scoreSelection(chosen, now)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !found || betterSelection(selection, best) {
|
|
best = selection
|
|
found = true
|
|
}
|
|
return
|
|
}
|
|
for i := start; i < len(pool); i++ {
|
|
chosen = append(chosen, pool[i])
|
|
visit(i + 1)
|
|
chosen = chosen[:len(chosen)-1]
|
|
}
|
|
}
|
|
visit(0)
|
|
if !found {
|
|
return Selection{}, fmt.Errorf("no candidate set satisfies latency and mutual rating limits")
|
|
}
|
|
sort.Slice(best.Players, func(i, j int) bool { return best.Players[i].TicketID < best.Players[j].TicketID })
|
|
return best, nil
|
|
}
|
|
|
|
// compatibleMetadata prevents a queue projection from crossing playlist or
|
|
// protocol/build boundaries. Empty anchor metadata is retained for older
|
|
// direct/community callers; once the queue has selected a compatibility
|
|
// contract, every participant must carry the exact same values.
|
|
func compatibleMetadata(anchor, candidate Candidate) bool {
|
|
if anchor.Playlist != "" && candidate.Playlist != anchor.Playlist {
|
|
return false
|
|
}
|
|
if anchor.ClientBuild != "" && candidate.ClientBuild != anchor.ClientBuild {
|
|
return false
|
|
}
|
|
if anchor.ProtocolVersion > 0 && candidate.ProtocolVersion != anchor.ProtocolVersion {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validCandidate(candidate Candidate) bool {
|
|
if candidate.TicketID == "" || candidate.PlayerID == "" || candidate.EnqueuedAt.IsZero() || math.IsNaN(candidate.Rating) || math.IsInf(candidate.Rating, 0) {
|
|
return false
|
|
}
|
|
for region, rtt := range candidate.PredictedRTT {
|
|
if region != "EU" && region != "NA" || math.IsNaN(rtt) || math.IsInf(rtt, 0) || rtt < 0 {
|
|
return false
|
|
}
|
|
}
|
|
return len(candidate.PredictedRTT) > 0
|
|
}
|
|
|
|
func compatibleSet(players []Candidate, now time.Time) bool {
|
|
regions := commonRegions(players)
|
|
if len(regions) == 0 {
|
|
return false
|
|
}
|
|
for i := range players {
|
|
for j := i + 1; j < len(players); j++ {
|
|
waitI := now.Sub(players[i].EnqueuedAt).Seconds()
|
|
waitJ := now.Sub(players[j].EnqueuedAt).Seconds()
|
|
delta := abs(players[i].Rating - players[j].Rating)
|
|
if delta > RatingTolerance(waitI) || delta > RatingTolerance(waitJ) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func commonRegions(players []Candidate) []string {
|
|
if len(players) == 0 {
|
|
return nil
|
|
}
|
|
regions := make(map[string]bool)
|
|
for region, rtt := range players[0].PredictedRTT {
|
|
if rtt <= MaxPlacementRTT {
|
|
regions[region] = true
|
|
}
|
|
}
|
|
for _, player := range players[1:] {
|
|
for region := range regions {
|
|
rtt, ok := player.PredictedRTT[region]
|
|
if !ok || rtt > MaxPlacementRTT {
|
|
delete(regions, region)
|
|
}
|
|
}
|
|
}
|
|
out := make([]string, 0, len(regions))
|
|
for region := range regions {
|
|
out = append(out, region)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func scoreSelection(players []Candidate, now time.Time) (Selection, bool) {
|
|
regions := commonRegions(players)
|
|
if len(regions) == 0 {
|
|
return Selection{}, false
|
|
}
|
|
best := Selection{}
|
|
for _, region := range regions {
|
|
worst, total := 0.0, 0.0
|
|
minRating, maxRating := players[0].Rating, players[0].Rating
|
|
wait := 0.0
|
|
for _, player := range players {
|
|
rtt := player.PredictedRTT[region]
|
|
if rtt > worst {
|
|
worst = rtt
|
|
}
|
|
total += rtt
|
|
if player.Rating < minRating {
|
|
minRating = player.Rating
|
|
}
|
|
if player.Rating > maxRating {
|
|
maxRating = player.Rating
|
|
}
|
|
if seconds := now.Sub(player.EnqueuedAt).Seconds(); seconds > 0 {
|
|
wait += seconds
|
|
}
|
|
}
|
|
candidate := Selection{Players: append([]Candidate(nil), players...), Region: region, WorstRTT: worst, TotalRTT: total, RatingRange: maxRating - minRating, TotalWaitSeconds: wait}
|
|
if best.Players == nil || betterSelection(candidate, best) {
|
|
best = candidate
|
|
}
|
|
}
|
|
return best, true
|
|
}
|
|
|
|
func betterSelection(a, b Selection) bool {
|
|
if a.WorstRTT != b.WorstRTT {
|
|
return a.WorstRTT < b.WorstRTT
|
|
}
|
|
if a.TotalRTT != b.TotalRTT {
|
|
return a.TotalRTT < b.TotalRTT
|
|
}
|
|
if a.RatingRange != b.RatingRange {
|
|
return a.RatingRange < b.RatingRange
|
|
}
|
|
if a.TotalWaitSeconds != b.TotalWaitSeconds {
|
|
return a.TotalWaitSeconds > b.TotalWaitSeconds
|
|
}
|
|
return ticketIDs(a.Players) < ticketIDs(b.Players)
|
|
}
|
|
|
|
func containsTicket(players []Candidate, ticketID string) bool {
|
|
for _, player := range players {
|
|
if player.TicketID == ticketID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ticketIDs(players []Candidate) string {
|
|
ids := make([]string, 0, len(players))
|
|
for _, player := range players {
|
|
ids = append(ids, player.TicketID)
|
|
}
|
|
sort.Strings(ids)
|
|
result := ""
|
|
for _, id := range ids {
|
|
result += id + "\x00"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func abs(value float64) float64 {
|
|
if value < 0 {
|
|
return -value
|
|
}
|
|
return value
|
|
}
|