Files
CosmicClash/server/domain/matcher.go
T
2026-08-31 20:29:11 +01:00

222 lines
5.3 KiB
Go

package domain
import (
"fmt"
"sort"
"time"
)
const (
MaxPlacementRTT = 100.0
MinRatingTolerance = 100.0
MaxRatingTolerance = 400.0
RatingWidenStep = 25.0
RatingWidenPeriod = 30.0
)
// 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
Rating float64
EnqueuedAt time.Time
PredictedRTT map[string]float64
}
type Selection struct {
Players []Candidate
Region string
WorstRTT float64
TotalRTT float64
RatingRange float64
TotalWaitSeconds float64
}
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{}
add := func(candidate Candidate) {
if candidate.TicketID != "" && !seen[candidate.TicketID] {
seen[candidate.TicketID] = 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
}
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
}