From 07fe144b2f08c4d2fdc16c00f3f92db1be820d22 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:17:53 +0100 Subject: [PATCH] feat: add deterministic matchmaking candidate selection --- multiplayer-todo.md | 2 +- server/domain/matcher.go | 190 ++++++++++++++++++++++++++++++++++ server/domain/matcher_test.go | 50 +++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 server/domain/matcher.go create mode 100644 server/domain/matcher_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 8b6c8f37..1a8035c5 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -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.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.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 | diff --git a/server/domain/matcher.go b/server/domain/matcher.go new file mode 100644 index 00000000..98c33bd2 --- /dev/null +++ b/server/domain/matcher.go @@ -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 } diff --git a/server/domain/matcher_test.go b/server/domain/matcher_test.go new file mode 100644 index 00000000..b1445f58 --- /dev/null +++ b/server/domain/matcher_test.go @@ -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") } +}