mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 11:32:02 +00:00
feat: add deterministic matchmaking candidate selection
This commit is contained in:
+1
-1
@@ -1193,7 +1193,7 @@ the local/CI/community transport, not a silent production fallback.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 8.14 `[D:8.4,8.5,8.8]` | One durable PostgreSQL queue owner/player plus Redis candidate index: 10 s heartbeat, 30 s expiry, retry-safe create/cancel/resume and repair after cache loss | Replicas/duplicates never place a player twice; failover may delay/rematerialise an index but durable ownership and active-participation fences converge |
|
| 8.14 `[D:8.4,8.5,8.8]` | One durable PostgreSQL queue owner/player plus Redis candidate index: 10 s heartbeat, 30 s expiry, retry-safe create/cancel/resume and repair after cache loss | Replicas/duplicates never place a player twice; failover may delay/rematerialise an index but durable ownership and active-participation fences converge |
|
||||||
| 8.15 `[D:7.8,8.3]` | Submit opaque Steam ping location plus nonce-bound probes; backend computes estimates, enforces 30 s freshness and quarantines 3 discrepancies >25 ms or 30% until 5 clean matches | A client cannot directly choose its placement RTT; stale/forged evidence is rejected; quarantine behavior and server-observed comparison are deterministic |
|
| 8.15 `[D:7.8,8.3]` | Submit opaque Steam ping location plus nonce-bound probes; backend computes estimates, enforces 30 s freshness and quarantines 3 discrepancies >25 ms or 30% until 5 clean matches | A client cannot directly choose its placement RTT; stale/forged evidence is rejected; quarantine behavior and server-observed comparison are deterministic |
|
||||||
| 8.16 `[D:8.14,8.15]` | Implement the locked candidate/team algorithm: <=100 ms, oldest anchor, `min(400,100+25*floor(wait/30))` mutual rating tolerance, documented set/region/team tie-breakers | Fixtures cover provisional players, EU/NA/no-common-region, widening caps, deterministic partitions and low population; no placement crosses 100 ms |
|
| 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion and deterministic set/region scoring | `server/domain/matcher.go` and adversarial fixtures cover no-common-region, tolerance boundaries and lexical ties; team partitioning, queue-backed candidate loading and full population fixtures remain |
|
||||||
| 8.17 `[D:8.14,8.16]` | Ten-second proposal to **every selected human**: ranked 6; casual largest compatible 6→2 after 60 s with disclosed teams/bots; apply exact decline/timeout/no-show cooldown and queue-precedence rules | Allocation starts only after selected humans accept; 2–5-human casual is reachable; accepter timestamps restore exactly; ranked pre-match no-show has cooldown but no rating loss |
|
| 8.17 `[D:8.14,8.16]` | Ten-second proposal to **every selected human**: ranked 6; casual largest compatible 6→2 after 60 s with disclosed teams/bots; apply exact decline/timeout/no-show cooldown and queue-precedence rules | Allocation starts only after selected humans accept; 2–5-human casual is reachable; accepter timestamps restore exactly; ranked pre-match no-show has cooldown but no rating loss |
|
||||||
| 8.18 `[D:8.5,8.14,8.17]` | Horizontally replicated matcher: Redis candidates, then PostgreSQL serializable proposal/participant fence, then cache cleanup/repair | Forced loss of the last acknowledged Redis write, retries, worker death and failover cannot claim a player into two proposals/matches |
|
| 8.18 `[D:8.5,8.14,8.17]` | Horizontally replicated matcher: Redis candidates, then PostgreSQL serializable proposal/participant fence, then cache cleanup/repair | Forced loss of the last acknowledged Redis write, retries, worker death and failover cannot claim a player into two proposals/matches |
|
||||||
| 8.19 `[D:8.18]` | Casual policy: proposal composition above; >=1 human/team, exhaustive rating-balanced teams, bots after 60 s, opt-in kickoff-only backfill, 30 s reconnect and defined backfill/casual penalties | Every 2–6-human shape is tested; no mid-play replacement; declined/backfill participant gets no excluded rating/cooldown; original leaver gets only documented outcome/cooldown |
|
| 8.19 `[D:8.18]` | Casual policy: proposal composition above; >=1 human/team, exhaustive rating-balanced teams, bots after 60 s, opt-in kickoff-only backfill, 30 s reconnect and defined backfill/casual penalties | Every 2–6-human shape is tested; no mid-play replacement; declined/backfill participant gets no excluded rating/cooldown; original leaver gets only documented outcome/cooldown |
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
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 }
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func candidate(id string, rating float64, wait time.Duration, eu, na float64, now time.Time) Candidate {
|
||||||
|
return Candidate{TicketID: id, PlayerID: "player-" + id, Rating: rating, EnqueuedAt: now.Add(-wait), PredictedRTT: map[string]float64{"EU": eu, "NA": na}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectCandidatesNeverCrossesRTTOrMutualRatingCeilings(t *testing.T) {
|
||||||
|
now := time.Unix(100000, 0)
|
||||||
|
anchor := candidate("a", 1500, 0, 40, 140, now)
|
||||||
|
players := []Candidate{
|
||||||
|
candidate("b", 1590, 10*time.Second, 45, 40, now),
|
||||||
|
candidate("c", 1590, 70*time.Second, 50, 50, now),
|
||||||
|
candidate("d", 1800, 70*time.Second, 40, 40, now),
|
||||||
|
}
|
||||||
|
selection, err := SelectCandidates(anchor, players, 3, now)
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
if selection.Region != "EU" || selection.WorstRTT > MaxPlacementRTT { t.Fatalf("bad region/RTT: %+v", selection) }
|
||||||
|
if ticketIDs(selection.Players) != "a\x00b\x00c\x00" { t.Fatalf("selected incompatible or non-optimal set: %q", ticketIDs(selection.Players)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectCandidatesRequiresAnchorAndUsesDeterministicTieBreak(t *testing.T) {
|
||||||
|
now := time.Unix(100000, 0)
|
||||||
|
anchor := candidate("anchor", 1500, 60*time.Second, 50, 50, now)
|
||||||
|
players := []Candidate{
|
||||||
|
candidate("z", 1500, 10*time.Second, 50, 50, now),
|
||||||
|
candidate("y", 1500, 10*time.Second, 50, 50, now),
|
||||||
|
candidate("x", 1500, 10*time.Second, 50, 50, now),
|
||||||
|
}
|
||||||
|
selection, err := SelectCandidates(anchor, players, 3, now)
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
if !containsTicket(selection.Players, "anchor") { t.Fatal("anchor was omitted") }
|
||||||
|
if ticketIDs(selection.Players) != "anchor\x00x\x00y\x00" { t.Fatalf("tie break was not lexical: %q", ticketIDs(selection.Players)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRatingToleranceWideningIsCapped(t *testing.T) {
|
||||||
|
if RatingTolerance(29) != 100 || RatingTolerance(30) != 125 { t.Fatal("30-second widening boundary is wrong") }
|
||||||
|
if RatingTolerance(1000) != MaxRatingTolerance { t.Fatal("rating tolerance is not capped") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectCandidatesRejectsNoCommonRegion(t *testing.T) {
|
||||||
|
now := time.Unix(100000, 0)
|
||||||
|
anchor := candidate("a", 1500, 0, 101, 40, now)
|
||||||
|
other := candidate("b", 1500, 0, 40, 101, now)
|
||||||
|
if _, err := SelectCandidates(anchor, []Candidate{other}, 2, now); err == nil { t.Fatal("selected players without a common <=100ms region") }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user