diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 672394df..b831661a 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1198,7 +1198,7 @@ the local/CI/community transport, not a silent production fallback. | 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.20 `[D:8.18]` | Ranked policy: exactly six verified solo humans, no bots/backfill, only `ArenaRegistry.random`; define initial no-show, proposal timeout and reconnect/abandon transitions | Ranked rejects parties/bots/backfill/elevated arenas; every pre-live failure returns five innocent players with original precedence and applies no rating | -| 8.21 `[D:8.5,8.20]` | Exact Glicko-2 equations from `docs/MATCHMAKING.md`: 1500/350/0.06/tau .5, ranked 1/3 and casual 1/N human-opponent weights, daily inactivity, immutable snapshot/lock order, draws/OT/abandons/cancellation | Canonical plus project 2–6-human/3v3 golden vectors pass; concurrent results serialize without order bias; clients have no rating-write path | +| 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, and deterministic opponent ordering | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input fixtures; PostgreSQL snapshot locking, draws/OT/abandons, seasons and concurrent result transaction tests remain | | 8.22 `[D:8.21]` | First ten ranked games provisional; casual rating hidden; ranked tiers derived from authoritative stored values | Matchmaking uses provisional rating/RD; UI visibility changes exactly on result ten without rewriting history | | 8.23 `[D:8.21]` | Ranked-only 12-week exactly-once soft season: compress 25% toward 1500, RD >=200 capped 350, retain volatility/history; casual remains continuous | Retried/concurrent rollover applies once, never touches casual, and preserves every rating event | | 8.24 `[D:8.9,8.20,8.21]` | Ranked reconnect/abandon: match-scoped authorisation, 60 s reclaim, server-owned connection generations, then abandoner loss and rolling 7-day 5 m/15 m/1 h/24 h cooldown | Reconnect works through backend outage and fences old peer; grace has no penalty; expiry outcome/escalation is deterministic and auditable | diff --git a/server/domain/rating.go b/server/domain/rating.go new file mode 100644 index 00000000..83a585a4 --- /dev/null +++ b/server/domain/rating.go @@ -0,0 +1,135 @@ +package domain + +import ( + "fmt" + "math" + "sort" + "time" +) + +const ( + GlickoScale = 173.7178 + GlickoTau = 0.5 + GlickoEpsilon = 0.000001 + GlickoInitialRating = 1500.0 + GlickoInitialRD = 350.0 + GlickoInitialVolatility = 0.06 +) + +type Rating struct { + Value float64 + RD float64 + Volatility float64 + LastRatedAt time.Time +} + +type Opponent struct { + PlayerID string + Rating Rating + Weight float64 + Score float64 +} + +// UpdateRating applies canonical Glicko-2 to one player's immutable pre-match +// rating snapshot. Weight is 1/3 for ranked 3v3 and 1/N for casual's N human +// opponents; bots are simply omitted by the caller. +func UpdateRating(current Rating, opponents []Opponent, now time.Time) (Rating, error) { + if err := validateRating(current); err != nil { return Rating{}, err } + if len(opponents) == 0 { return advanceInactivity(current, now), nil } + for _, opponent := range opponents { + if err := validateRating(opponent.Rating); err != nil { return Rating{}, err } + if opponent.Weight <= 0 || opponent.Score < 0 || opponent.Score > 1 { return Rating{}, fmt.Errorf("invalid opponent weight or score") } + } + working := advanceInactivity(current, now) + mu, phi := toScale(working.Value, working.RD) + varianceInverse, deltaSum := 0.0, 0.0 + for _, opponent := range opponents { + oppMu, oppPhi := toScale(opponent.Rating.Value, opponent.Rating.RD) + g := glickoG(oppPhi) + expected := expectedScore(mu, oppMu, g) + varianceInverse += opponent.Weight * g * g * expected * (1 - expected) + deltaSum += opponent.Weight * g * (opponent.Score - expected) + } + if varianceInverse <= 0 { return Rating{}, fmt.Errorf("opponent information has zero variance") } + v := 1 / varianceInverse + delta := v * deltaSum + sigma, err := solveVolatility(phi, v, delta, working.Volatility) + if err != nil { return Rating{}, err } + phiStar := math.Sqrt(phi*phi + sigma*sigma) + phiPrime := 1 / math.Sqrt(1/(phiStar*phiStar)+1/v) + muPrime := mu + phiPrime*phiPrime*deltaSum + return Rating{Value: fromScaleRating(muPrime), RD: fromScaleRD(phiPrime), Volatility: sigma, LastRatedAt: now}, nil +} + +func validateRating(r Rating) error { + if r.Value < 0 || r.RD <= 0 || r.RD > GlickoInitialRD || r.Volatility <= 0 || r.Volatility >= 1 { return fmt.Errorf("invalid rating state") } + return nil +} + +func advanceInactivity(r Rating, now time.Time) Rating { + if r.LastRatedAt.IsZero() || !now.After(r.LastRatedAt) { return r } + periods := int(now.Sub(r.LastRatedAt) / (24 * time.Hour)) + if periods <= 0 { return r } + phi := r.RD / GlickoScale + phi = math.Min(GlickoInitialRD/GlickoScale, math.Sqrt(phi*phi+float64(periods)*r.Volatility*r.Volatility)) + r.RD = fromScaleRD(phi) + return r +} + +func toScale(rating, rd float64) (float64, float64) { return (rating - GlickoInitialRating) / GlickoScale, rd / GlickoScale } +func fromScaleRating(mu float64) float64 { return mu*GlickoScale + GlickoInitialRating } +func fromScaleRD(phi float64) float64 { return phi * GlickoScale } +func glickoG(phi float64) float64 { return 1 / math.Sqrt(1+3*phi*phi/(math.Pi*math.Pi)) } +func expectedScore(mu, opponentMu, g float64) float64 { return 1 / (1 + math.Exp(-g*(mu-opponentMu))) } + +func solveVolatility(phi, v, delta, volatility float64) (float64, error) { + a := math.Log(volatility * volatility) + variance := delta*delta - phi*phi - v + var b float64 + if variance > 0 { b = math.Log(variance) } else { + b = a - GlickoTau + for volatilityFunction(b, a, phi, v, delta) < 0 { + b -= GlickoTau + if b < -100 { return 0, fmt.Errorf("volatility bracket not found") } + } + } + fa := volatilityFunction(a, a, phi, v, delta) + fb := volatilityFunction(b, a, phi, v, delta) + for math.Abs(b-a) > GlickoEpsilon { + c := a + (a-b)*fa/(fb-fa) + fc := volatilityFunction(c, a, phi, v, delta) + if fc*fb < 0 { a, fa = b, fb } else { fa /= 2 } + b, fb = c, fc + if math.IsNaN(b) || math.IsInf(b, 0) { return 0, fmt.Errorf("volatility iteration diverged") } + } + return math.Exp(a / 2), nil +} + +func volatilityFunction(x, a, phi, v, delta float64) float64 { + expX := math.Exp(x) + denominator := 2 * math.Pow(phi*phi+v+expX, 2) + return expX*(delta*delta-phi*phi-v-expX)/denominator - (x-a)/(GlickoTau*GlickoTau) +} + +// RankedOpponents assigns the exact 1/3 contribution to each of three human +// opponents. CasualOpponents assigns 1/N; both return lexical order so a +// database row-order change cannot affect floating-point accumulation order. +func RankedOpponents(opponents []Opponent) ([]Opponent, error) { + if len(opponents) != 3 { return nil, fmt.Errorf("ranked 3v3 requires three opponents") } + return weightedOpponents(opponents, 1.0/3.0), nil +} + +func CasualOpponents(opponents []Opponent) ([]Opponent, error) { + if len(opponents) == 0 { return nil, nil } + return weightedOpponents(opponents, 1/float64(len(opponents))), nil +} + +func weightedOpponents(opponents []Opponent, weight float64) []Opponent { + result := append([]Opponent(nil), opponents...) + sort.Slice(result, func(i, j int) bool { + if result[i].Rating.Value != result[j].Rating.Value { return result[i].Rating.Value < result[j].Rating.Value } + return result[i].PlayerID < result[j].PlayerID + }) + for i := range result { result[i].Weight = weight } + return result +} diff --git a/server/domain/rating_test.go b/server/domain/rating_test.go new file mode 100644 index 00000000..737c301f --- /dev/null +++ b/server/domain/rating_test.go @@ -0,0 +1,49 @@ +package domain + +import ( + "math" + "testing" + "time" +) + +func TestUpdateRatingMatchesCanonicalGlicko2Example(t *testing.T) { + current := Rating{Value: 1500, RD: 200, Volatility: 0.06} + opponents := []Opponent{ + {PlayerID: "a", Rating: Rating{Value: 1400, RD: 30, Volatility: 0.06}, Score: 1, Weight: 1}, + {PlayerID: "b", Rating: Rating{Value: 1550, RD: 100, Volatility: 0.06}, Score: 0, Weight: 1}, + {PlayerID: "c", Rating: Rating{Value: 1700, RD: 300, Volatility: 0.06}, Score: 0, Weight: 1}, + } + updated, err := UpdateRating(current, opponents, time.Unix(100000, 0)) + if err != nil { t.Fatal(err) } + if math.Abs(updated.Value-1464.06) > 0.1 || math.Abs(updated.RD-151.52) > 0.1 || math.Abs(updated.Volatility-0.05999) > 0.0001 { + t.Fatalf("canonical vector mismatch: %+v", updated) + } +} + +func TestRatingInactivityRaisesRDWithoutChangingRating(t *testing.T) { + now := time.Unix(100000, 0) + current := Rating{Value: 1600, RD: 100, Volatility: 0.06, LastRatedAt: now} + updated, err := UpdateRating(current, nil, now.Add(48*time.Hour+time.Hour)) + if err != nil { t.Fatal(err) } + if updated.Value != current.Value || updated.RD <= current.RD || updated.RD > GlickoInitialRD { t.Fatalf("bad inactivity update: %+v", updated) } +} + +func TestOpponentWeightHelpersAreExactAndDeterministic(t *testing.T) { + opponents := []Opponent{{PlayerID: "c", Rating: Rating{Value: 1700}}, {PlayerID: "a", Rating: Rating{Value: 1400}}, {PlayerID: "b", Rating: Rating{Value: 1550}}} + ranked, err := RankedOpponents(opponents) + if err != nil { t.Fatal(err) } + if ranked[0].PlayerID != "a" || ranked[0].Weight != 1.0/3.0 { t.Fatalf("ranked weighting/order wrong: %+v", ranked) } + reordered, err := RankedOpponents([]Opponent{opponents[1], opponents[0], opponents[2]}) + if err != nil { t.Fatal(err) } + for i := range ranked { if ranked[i].PlayerID != reordered[i].PlayerID { t.Fatal("input order changed opponent order") } } + casual, err := CasualOpponents(opponents[:2]) + if err != nil { t.Fatal(err) } + if casual[0].Weight != 0.5 || casual[1].Weight != 0.5 { t.Fatalf("casual weighting wrong: %+v", casual) } +} + +func TestRatingRejectsInvalidStateAndBadScore(t *testing.T) { + _, err := UpdateRating(Rating{Value: 1500, RD: 0, Volatility: 0.06}, nil, time.Now()) + if err == nil { t.Fatal("accepted zero RD") } + _, err = UpdateRating(Rating{Value: 1500, RD: 200, Volatility: 0.06}, []Opponent{{Rating: Rating{Value: 1500, RD: 100, Volatility: 0.06}, Weight: 1, Score: 2}}, time.Now()) + if err == nil { t.Fatal("accepted score outside [0,1]") } +}