feat: add authoritative rating outcome scoring

This commit is contained in:
Josh Creek
2026-08-31 21:07:04 +01:00
parent 63332435d2
commit e13a64756f
3 changed files with 49 additions and 1 deletions
+1 -1
View File
@@ -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 26 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 09/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 200350, 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 |
+27
View File
@@ -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
+21
View File
@@ -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{