From bc7136b2cbadba96f9daca522dd80955dd9a8823 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:08:10 +0100 Subject: [PATCH] feat: add ranked season window policy --- multiplayer-todo.md | 2 +- server/domain/rating.go | 19 +++++++++++++++++++ server/domain/season_test.go | 20 +++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 4c86664e..a42e5b20 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1200,7 +1200,7 @@ the local/CI/community transport, not a silent production fallback. | 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, 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.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection | `server/domain/rating.go` and `season_test.go` cover compression, floor/cap, duplicate replay, window boundary and completed-season idempotence; PostgreSQL locking, persisted rollover transaction and maintenance 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 | | 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL defines conflict-safe receipt insert, deterministic match/rating locks and atomic completion/outbox boundaries | `server/domain/result.go` plus `server/store/result_sql.go` and adversarial fixtures cover binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit and lock ordering; production credential verification, Agones annotation persistence/reconciliation, live PostgreSQL execution and integrity evidence adapters remain | diff --git a/server/domain/rating.go b/server/domain/rating.go index 404106e8..a5afd4eb 100644 --- a/server/domain/rating.go +++ b/server/domain/rating.go @@ -14,6 +14,7 @@ const ( GlickoInitialRating = 1500.0 GlickoInitialRD = 350.0 GlickoInitialVolatility = 0.06 + RankedSeasonLength = 12 * 7 * 24 * time.Hour ) type Rating struct { @@ -64,6 +65,24 @@ type RankedProfile struct { SeasonHistory []string } +type RankedSeason struct { + SeasonID string + StartsAt time.Time + EndsAt time.Time + RolledOverAt time.Time +} + +func NewRankedSeason(seasonID string, startsAt time.Time) (RankedSeason, error) { + if seasonID == "" || startsAt.IsZero() { + return RankedSeason{}, fmt.Errorf("invalid ranked season") + } + return RankedSeason{SeasonID: seasonID, StartsAt: startsAt, EndsAt: startsAt.Add(RankedSeasonLength)}, nil +} + +func SeasonRolloverDue(season RankedSeason, now time.Time) bool { + return season.SeasonID != "" && !season.EndsAt.IsZero() && !now.Before(season.EndsAt) && season.RolledOverAt.IsZero() +} + func RankedIsProvisional(profile RankedProfile) bool { return profile.RankedGames < 10 } // ApplySeasonRollover is idempotent by season ID. It intentionally accepts a diff --git a/server/domain/season_test.go b/server/domain/season_test.go index f4479c4f..20258906 100644 --- a/server/domain/season_test.go +++ b/server/domain/season_test.go @@ -1,6 +1,9 @@ package domain -import "testing" +import ( + "testing" + "time" +) func TestRankedProvisionalBoundaryIsFirstTenGames(t *testing.T) { for games := 0; games < 10; games++ { @@ -57,3 +60,18 @@ func TestCasualRatingHasNoSeasonOperation(t *testing.T) { t.Fatal("casual boundary test fixture unexpectedly provisional") } } + +func TestRankedSeasonWindowIsExactlyTwelveWeeksAndDueIsIdempotent(t *testing.T) { + start := time.Unix(1000, 0) + season, err := NewRankedSeason("season-1", start) + if err != nil || season.EndsAt.Sub(start) != RankedSeasonLength { + t.Fatalf("season = %+v err=%v", season, err) + } + if SeasonRolloverDue(season, season.EndsAt.Add(-time.Nanosecond)) || !SeasonRolloverDue(season, season.EndsAt) { + t.Fatal("season due boundary is wrong") + } + season.RolledOverAt = season.EndsAt + if SeasonRolloverDue(season, season.EndsAt.Add(time.Hour)) { + t.Fatal("completed season remained due") + } +}