From e13a64756f0427328c0c1eee38b96b915b703f55 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:07:04 +0100 Subject: [PATCH] feat: add authoritative rating outcome scoring --- multiplayer-todo.md | 2 +- server/domain/rating.go | 27 +++++++++++++++++++++++++++ server/domain/rating_test.go | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index b75feb50..4c86664e 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]` | **IN PROGRESS.** Go store layer defines PostgreSQL SERIALIZABLE whole-transaction retries and queue candidate/proposal claim SQL using `FOR UPDATE SKIP LOCKED` plus durable uniqueness/revision fences | `server/store/serializable.go` and tests cover retry classification and claim-boundary invariants; live PostgreSQL adapter/row decoding, Redis candidate index/repair, worker-failure and concurrent two-matcher integration tests remain | | 8.19 `[D:8.18]` | **IN PROGRESS.** Pure Go casual lineup requires 2–6 humans with at least one per team, fills missing slots with explicit bots, permits kickoff-only bot-slot backfill and assigns no backfill penalty/rating update | `server/domain/casual.go` covers both-team minimum, bot shape, live-play rejection and zero-penalty backfill; queue candidate selection, opt-in 10 s backfill proposals, reconnect/leave penalties and live integration remain | | 8.20 `[D:8.18]` | **IN PROGRESS.** Pure Go ranked admission requires six unique verified solo humans, rejects bots/backfill/parties, and allows only random-enabled non-elevated arenas | `server/domain/ranked.go` covers count, identity, party, bot/backfill and arena eligibility rejection; `ArenaRegistry` integration, proposal/allocation wiring and innocent-ticket restoration remain | -| 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.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, deterministic opponent ordering, and authoritative draw/overtime/abandon scoring | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input plus draw/OT/abandon fixtures; PostgreSQL snapshot locking, rating transaction integration, seasons and concurrent result transaction tests remain | | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional and keeps casual ratings outside the API | `RankedIsProvisional` covers the 0–9/10 boundary; authoritative tier derivation and UI remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season rollover compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history and is idempotent by season ID | `ApplySeasonRollover` covers compression, floor/cap and duplicate replay; PostgreSQL transaction locking and 12-week scheduler remain | | 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain | diff --git a/server/domain/rating.go b/server/domain/rating.go index 123a5b62..404106e8 100644 --- a/server/domain/rating.go +++ b/server/domain/rating.go @@ -30,6 +30,33 @@ type Opponent struct { Score float64 } +type MatchOutcome struct { + Team0Score int + Team1Score int + Overtime bool + Abandoners map[string]bool +} + +func ScoreForPlayer(outcome MatchOutcome, playerID string, team int) (float64, error) { + if playerID == "" || (team != 0 && team != 1) || outcome.Team0Score < 0 || outcome.Team1Score < 0 { + return 0, fmt.Errorf("invalid match outcome") + } + if outcome.Abandoners[playerID] { + return 0, nil + } + if outcome.Team0Score == outcome.Team1Score { + return 0.5, nil + } + winner := 0 + if outcome.Team1Score > outcome.Team0Score { + winner = 1 + } + if team == winner { + return 1, nil + } + return 0, nil +} + type RankedProfile struct { Rating RankedGames int diff --git a/server/domain/rating_test.go b/server/domain/rating_test.go index ec630dc8..2d1de674 100644 --- a/server/domain/rating_test.go +++ b/server/domain/rating_test.go @@ -6,6 +6,27 @@ import ( "time" ) +func TestScoreForPlayerHandlesDrawOvertimeAndAbandon(t *testing.T) { + draw := MatchOutcome{Team0Score: 2, Team1Score: 2} + if score, err := ScoreForPlayer(draw, "player-1", 0); err != nil || score != 0.5 { + t.Fatalf("draw score = %v, %v", score, err) + } + overtime := MatchOutcome{Team0Score: 2, Team1Score: 3, Overtime: true} + if score, err := ScoreForPlayer(overtime, "player-1", 0); err != nil || score != 0 { + t.Fatalf("overtime loser score = %v, %v", score, err) + } + if score, err := ScoreForPlayer(overtime, "player-2", 1); err != nil || score != 1 { + t.Fatalf("overtime winner score = %v, %v", score, err) + } + abandon := MatchOutcome{Team0Score: 0, Team1Score: 10, Abandoners: map[string]bool{"player-1": true}} + if score, err := ScoreForPlayer(abandon, "player-1", 0); err != nil || score != 0 { + t.Fatalf("abandoner score = %v, %v", score, err) + } + if _, err := ScoreForPlayer(draw, "", 0); err == nil { + t.Fatal("empty player accepted") + } +} + func TestUpdateRatingMatchesCanonicalGlicko2Example(t *testing.T) { current := Rating{Value: 1500, RD: 200, Volatility: 0.06} opponents := []Opponent{