feat: add ranked season rollover policy

This commit is contained in:
Josh Creek
2026-08-31 20:29:11 +01:00
parent 1793eb7621
commit cc2cd80a01
16 changed files with 688 additions and 249 deletions
+2 -2
View File
@@ -1199,8 +1199,8 @@ the local/CI/community transport, not a silent production fallback.
| 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 26-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]` | **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.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]` | 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 |
| 8.25 `[D:8.10,8.24]` | Separate result delivery delay from match-integrity failure; signed Agones-annotation spool, retries, 5 m alert/30 m review, suppression only for lost/corrupt authority or measured unfair regional incident | API outage preserves rating/result; clients cannot request exemption; node/pod/integrity faults take the documented suppression/refund path |
+57 -26
View File
@@ -7,29 +7,29 @@ import (
)
const (
MaxPlacementRTT = 100.0
MaxPlacementRTT = 100.0
MinRatingTolerance = 100.0
MaxRatingTolerance = 400.0
RatingWidenStep = 25.0
RatingWidenPeriod = 30.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
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
Players []Candidate
Region string
WorstRTT float64
TotalRTT float64
RatingRange float64
TotalWaitSeconds float64
}
@@ -153,38 +153,69 @@ func scoreSelection(players []Candidate, now time.Time) (Selection, bool) {
wait := 0.0
for _, player := range players {
rtt := player.PredictedRTT[region]
if rtt > worst { worst = rtt }
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 }
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
}
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 }
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 } }
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) }
for _, player := range players {
ids = append(ids, player.TicketID)
}
sort.Strings(ids)
result := ""
for _, id := range ids { result += id + "\x00" }
for _, id := range ids {
result += id + "\x00"
}
return result
}
func abs(value float64) float64 { if value < 0 { return -value }; return value }
func abs(value float64) float64 {
if value < 0 {
return -value
}
return value
}
+27 -9
View File
@@ -18,9 +18,15 @@ func TestSelectCandidatesNeverCrossesRTTOrMutualRatingCeilings(t *testing.T) {
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)) }
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) {
@@ -32,19 +38,31 @@ func TestSelectCandidatesRequiresAnchorAndUsesDeterministicTieBreak(t *testing.T
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)) }
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") }
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") }
if _, err := SelectCandidates(anchor, []Candidate{other}, 2, now); err == nil {
t.Fatal("selected players without a common <=100ms region")
}
}
+27 -17
View File
@@ -8,16 +8,16 @@ import (
)
const (
ProbeFreshness = 30 * time.Second
ProbeFutureSkew = 5 * time.Second
ProbeFreshness = 30 * time.Second
ProbeFutureSkew = 5 * time.Second
MaxOpaqueLocationBytes = 512
DiscrepancyWindow = 24 * time.Hour
DiscrepancyLimit = 3
CleanSamplesToRelease = 5
DiscrepancyWindow = 24 * time.Hour
DiscrepancyLimit = 3
CleanSamplesToRelease = 5
)
var (
ErrInvalidProbe = errors.New("invalid latency probe evidence")
ErrInvalidProbe = errors.New("invalid latency probe evidence")
ErrProbeQuarantined = errors.New("latency samples are quarantined")
)
@@ -26,10 +26,10 @@ var (
// no client-provided RTT is used for placement.
type ProbeEvidence struct {
OpaqueLocation []byte
Nonce []byte
IssuedAt time.Time
Region string
ServerRTT time.Duration
Nonce []byte
IssuedAt time.Time
Region string
ServerRTT time.Duration
}
func ValidateProbe(evidence ProbeEvidence, expectedNonce []byte, now time.Time) error {
@@ -52,9 +52,9 @@ func ValidateProbe(evidence ProbeEvidence, expectedNonce []byte, now time.Time)
}
type DiscrepancyTracker struct {
BadSamples []time.Time
BadSamples []time.Time
CleanSamples int
Quarantined bool
Quarantined bool
}
// RecordComparison compares backend-computed predicted and observed RTT. A
@@ -65,14 +65,20 @@ func (tracker *DiscrepancyTracker) RecordComparison(predicted, observed time.Dur
maxAllowed := 25 * time.Millisecond
if predicted > 0 {
percent := time.Duration(float64(predicted) * 0.30)
if percent > maxAllowed { maxAllowed = percent }
if percent > maxAllowed {
maxAllowed = percent
}
}
delta := predicted - observed
if delta < 0 { delta = -delta }
if delta < 0 {
delta = -delta
}
if delta > maxAllowed {
tracker.BadSamples = append(tracker.BadSamples, now)
tracker.CleanSamples = 0
if len(tracker.BadSamples) >= DiscrepancyLimit { tracker.Quarantined = true }
if len(tracker.BadSamples) >= DiscrepancyLimit {
tracker.Quarantined = true
}
return
}
if tracker.Quarantined {
@@ -89,12 +95,16 @@ func (tracker *DiscrepancyTracker) prune(now time.Time) {
cutoff := now.Add(-DiscrepancyWindow)
kept := tracker.BadSamples[:0]
for _, sample := range tracker.BadSamples {
if !sample.Before(cutoff) { kept = append(kept, sample) }
if !sample.Before(cutoff) {
kept = append(kept, sample)
}
}
tracker.BadSamples = kept
}
func (tracker DiscrepancyTracker) PlacementAllowed() error {
if tracker.Quarantined { return ErrProbeQuarantined }
if tracker.Quarantined {
return ErrProbeQuarantined
}
return nil
}
+30 -12
View File
@@ -9,32 +9,50 @@ import (
func TestValidateProbeRequiresOpaqueFreshNonceAndServerRTT(t *testing.T) {
now := time.Unix(100000, 0)
valid := ProbeEvidence{OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now, Region: "EU", ServerRTT: 40 * time.Millisecond}
if err := ValidateProbe(valid, []byte("nonce"), now); err != nil { t.Fatal(err) }
if err := ValidateProbe(valid, []byte("nonce"), now); err != nil {
t.Fatal(err)
}
for name, invalid := range map[string]ProbeEvidence{
"empty location": {Nonce: []byte("nonce"), IssuedAt: now, Region: "EU"},
"wrong nonce": {OpaqueLocation: []byte("opaque"), Nonce: []byte("other"), IssuedAt: now, Region: "EU"},
"stale": {OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now.Add(-ProbeFreshness - time.Nanosecond), Region: "EU"},
"empty location": {Nonce: []byte("nonce"), IssuedAt: now, Region: "EU"},
"wrong nonce": {OpaqueLocation: []byte("opaque"), Nonce: []byte("other"), IssuedAt: now, Region: "EU"},
"stale": {OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now.Add(-ProbeFreshness - time.Nanosecond), Region: "EU"},
"client chosen negative RTT": {OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now, Region: "EU", ServerRTT: -time.Millisecond},
} {
if err := ValidateProbe(invalid, []byte("nonce"), now); !errors.Is(err, ErrInvalidProbe) { t.Fatalf("%s error = %v", name, err) }
if err := ValidateProbe(invalid, []byte("nonce"), now); !errors.Is(err, ErrInvalidProbe) {
t.Fatalf("%s error = %v", name, err)
}
}
}
func TestDiscrepancyQuarantineAndFiveCleanRelease(t *testing.T) {
now := time.Unix(100000, 0)
var tracker DiscrepancyTracker
for i := 0; i < DiscrepancyLimit; i++ { tracker.RecordComparison(40*time.Millisecond, 100*time.Millisecond, now.Add(time.Duration(i)*time.Minute)) }
if !tracker.Quarantined { t.Fatal("three discrepancies did not quarantine samples") }
if err := tracker.PlacementAllowed(); !errors.Is(err, ErrProbeQuarantined) { t.Fatal("quarantine not enforced") }
for i := 0; i < CleanSamplesToRelease; i++ { tracker.RecordComparison(40*time.Millisecond, 45*time.Millisecond, now.Add(time.Hour+time.Duration(i)*time.Minute)) }
if tracker.Quarantined { t.Fatal("five clean samples did not release quarantine") }
for i := 0; i < DiscrepancyLimit; i++ {
tracker.RecordComparison(40*time.Millisecond, 100*time.Millisecond, now.Add(time.Duration(i)*time.Minute))
}
if !tracker.Quarantined {
t.Fatal("three discrepancies did not quarantine samples")
}
if err := tracker.PlacementAllowed(); !errors.Is(err, ErrProbeQuarantined) {
t.Fatal("quarantine not enforced")
}
for i := 0; i < CleanSamplesToRelease; i++ {
tracker.RecordComparison(40*time.Millisecond, 45*time.Millisecond, now.Add(time.Hour+time.Duration(i)*time.Minute))
}
if tracker.Quarantined {
t.Fatal("five clean samples did not release quarantine")
}
}
func TestDiscrepancyThresholdUsesLargerOfAbsoluteAndRelativeLimit(t *testing.T) {
now := time.Unix(100000, 0)
var tracker DiscrepancyTracker
tracker.RecordComparison(200*time.Millisecond, 250*time.Millisecond, now)
if tracker.Quarantined { t.Fatal("50ms discrepancy should be allowed when 30%% limit is 60ms") }
if tracker.Quarantined {
t.Fatal("50ms discrepancy should be allowed when 30%% limit is 60ms")
}
tracker.RecordComparison(200*time.Millisecond, 270*time.Millisecond, now.Add(time.Minute))
if len(tracker.BadSamples) != 1 { t.Fatal("70ms discrepancy should be recorded") }
if len(tracker.BadSamples) != 1 {
t.Fatal("70ms discrepancy should be recorded")
}
}
+93 -30
View File
@@ -25,7 +25,7 @@ const (
type Response string
const (
Pending Response = "PENDING"
Pending Response = "PENDING"
AcceptedResponse Response = "ACCEPTED"
DeclinedResponse Response = "DECLINED"
TimedOutResponse Response = "TIMED_OUT"
@@ -37,28 +37,36 @@ type ProposalParticipant struct {
}
type Proposal struct {
ProposalID string
Playlist Playlist
ProposalID string
Playlist Playlist
Participants []ProposalParticipant
State State
Revision uint64
ExpiresAt time.Time
idempotent map[string]proposalMutation
State State
Revision uint64
ExpiresAt time.Time
idempotent map[string]proposalMutation
}
type proposalMutation struct {
digest [32]byte
digest [32]byte
proposal Proposal
}
func NewProposal(id string, playlist Playlist, playerIDs []string, now time.Time) (Proposal, error) {
if id == "" || (playlist != Casual && playlist != Ranked) { return Proposal{}, fmt.Errorf("%w: invalid proposal", ErrConflict) }
if playlist == Ranked && len(playerIDs) != 6 { return Proposal{}, fmt.Errorf("%w: ranked requires exactly six players", ErrConflict) }
if len(playerIDs) < 2 || len(playerIDs) > 6 { return Proposal{}, fmt.Errorf("%w: proposal requires 2 through 6 players", ErrConflict) }
if id == "" || (playlist != Casual && playlist != Ranked) {
return Proposal{}, fmt.Errorf("%w: invalid proposal", ErrConflict)
}
if playlist == Ranked && len(playerIDs) != 6 {
return Proposal{}, fmt.Errorf("%w: ranked requires exactly six players", ErrConflict)
}
if len(playerIDs) < 2 || len(playerIDs) > 6 {
return Proposal{}, fmt.Errorf("%w: proposal requires 2 through 6 players", ErrConflict)
}
seen := make(map[string]bool, len(playerIDs))
participants := make([]ProposalParticipant, 0, len(playerIDs))
for _, playerID := range playerIDs {
if playerID == "" || seen[playerID] { return Proposal{}, fmt.Errorf("%w: duplicate or empty player", ErrConflict) }
if playerID == "" || seen[playerID] {
return Proposal{}, fmt.Errorf("%w: duplicate or empty player", ErrConflict)
}
seen[playerID] = true
participants = append(participants, ProposalParticipant{PlayerID: playerID, Response: Pending})
}
@@ -69,37 +77,70 @@ func NewProposal(id string, playlist Playlist, playerIDs []string, now time.Time
func (p *Proposal) Respond(playerID, idempotencyKey string, accept bool, expectedRevision uint64, now time.Time) (Proposal, error) {
digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%t:%d", playerID, accept, expectedRevision)))
if prior, ok := p.idempotent[idempotencyKey]; ok {
if prior.digest != digest { return Proposal{}, fmt.Errorf("%w: proposal response payload changed", ErrConflict) }
if prior.digest != digest {
return Proposal{}, fmt.Errorf("%w: proposal response payload changed", ErrConflict)
}
return prior.proposal, nil
}
if idempotencyKey == "" { return Proposal{}, fmt.Errorf("%w: empty proposal response key", ErrConflict) }
if p.State != Open || !now.Before(p.ExpiresAt) { return Proposal{}, ErrProposalClosed }
if p.Revision != expectedRevision { return Proposal{}, ErrStaleRevision }
if idempotencyKey == "" {
return Proposal{}, fmt.Errorf("%w: empty proposal response key", ErrConflict)
}
if p.State != Open || !now.Before(p.ExpiresAt) {
return Proposal{}, ErrProposalClosed
}
if p.Revision != expectedRevision {
return Proposal{}, ErrStaleRevision
}
index := p.participantIndex(playerID)
if index < 0 { return Proposal{}, ErrNotParticipant }
if p.Participants[index].Response != Pending { return Proposal{}, fmt.Errorf("%w: participant already responded", ErrConflict) }
if accept { p.Participants[index].Response = AcceptedResponse } else { p.Participants[index].Response = DeclinedResponse; p.State = Declined }
if accept && p.allAccepted() { p.State = Accepted }
if index < 0 {
return Proposal{}, ErrNotParticipant
}
if p.Participants[index].Response != Pending {
return Proposal{}, fmt.Errorf("%w: participant already responded", ErrConflict)
}
if accept {
p.Participants[index].Response = AcceptedResponse
} else {
p.Participants[index].Response = DeclinedResponse
p.State = Declined
}
if accept && p.allAccepted() {
p.State = Accepted
}
p.Revision++
p.idempotent[idempotencyKey] = proposalMutation{digest: digest, proposal: p.copy()}
return p.copy(), nil
}
func (p *Proposal) Expire(now time.Time) bool {
if p.State != Open || now.Before(p.ExpiresAt) { return false }
for i := range p.Participants { if p.Participants[i].Response == Pending { p.Participants[i].Response = TimedOutResponse } }
if p.State != Open || now.Before(p.ExpiresAt) {
return false
}
for i := range p.Participants {
if p.Participants[i].Response == Pending {
p.Participants[i].Response = TimedOutResponse
}
}
p.State = Expired
p.Revision++
return true
}
func (p *Proposal) participantIndex(playerID string) int {
for i, participant := range p.Participants { if participant.PlayerID == playerID { return i } }
for i, participant := range p.Participants {
if participant.PlayerID == playerID {
return i
}
}
return -1
}
func (p *Proposal) allAccepted() bool {
for _, participant := range p.Participants { if participant.Response != AcceptedResponse { return false } }
for _, participant := range p.Participants {
if participant.Response != AcceptedResponse {
return false
}
}
return true
}
@@ -110,21 +151,43 @@ func (p *Proposal) copy() Proposal {
return clone
}
type CooldownEvent struct { At time.Time; Playlist Playlist; Kind Response }
type CooldownEvent struct {
At time.Time
Playlist Playlist
Kind Response
}
func CooldownUntil(events []CooldownEvent, playlist Playlist, now time.Time) time.Time {
window := 30 * time.Minute
cutoff := now.Add(-window)
filtered := make([]CooldownEvent, 0, len(events))
for _, event := range events { if event.Playlist == playlist && !event.At.Before(cutoff) { filtered = append(filtered, event) } }
for _, event := range events {
if event.Playlist == playlist && !event.At.Before(cutoff) {
filtered = append(filtered, event)
}
}
sort.Slice(filtered, func(i, j int) bool { return filtered[i].At.Before(filtered[j].At) })
var duration time.Duration
if len(filtered) > 0 {
if playlist == Casual { if filtered[len(filtered)-1].Kind == DeclinedResponse { duration = 30 * time.Second } else { duration = 60 * time.Second } } else {
if filtered[len(filtered)-1].Kind == DeclinedResponse { duration = 2 * time.Minute } else { duration = 5 * time.Minute }
if len(filtered) >= 3 { duration = 15 * time.Minute }
if playlist == Casual {
if filtered[len(filtered)-1].Kind == DeclinedResponse {
duration = 30 * time.Second
} else {
duration = 60 * time.Second
}
} else {
if filtered[len(filtered)-1].Kind == DeclinedResponse {
duration = 2 * time.Minute
} else {
duration = 5 * time.Minute
}
if len(filtered) >= 3 {
duration = 15 * time.Minute
}
}
}
if duration == 0 { return time.Time{} }
if duration == 0 {
return time.Time{}
}
return filtered[len(filtered)-1].At.Add(duration)
}
+51 -17
View File
@@ -9,37 +9,71 @@ import (
func TestProposalRequiresUnanimousAcceptance(t *testing.T) {
now := time.Unix(1000, 0)
p, err := NewProposal("proposal-123456789", Casual, []string{"b", "a"}, now)
if err != nil { t.Fatal(err) }
if _, err = p.Respond("a", "response-a-123456", true, 0, now); err != nil { t.Fatal(err) }
if p.State != Open || p.Revision != 1 { t.Fatalf("partial acceptance closed proposal: %+v", p) }
if _, err = p.Respond("b", "response-b-123456", true, 1, now); err != nil { t.Fatal(err) }
if p.State != Accepted || p.Revision != 2 { t.Fatalf("unanimous acceptance not committed: %+v", p) }
if err != nil {
t.Fatal(err)
}
if _, err = p.Respond("a", "response-a-123456", true, 0, now); err != nil {
t.Fatal(err)
}
if p.State != Open || p.Revision != 1 {
t.Fatalf("partial acceptance closed proposal: %+v", p)
}
if _, err = p.Respond("b", "response-b-123456", true, 1, now); err != nil {
t.Fatal(err)
}
if p.State != Accepted || p.Revision != 2 {
t.Fatalf("unanimous acceptance not committed: %+v", p)
}
}
func TestProposalResponseReplayIsStableAndPayloadReuseConflicts(t *testing.T) {
now := time.Unix(1000, 0)
p, err := NewProposal("proposal-123456789", Casual, []string{"a", "b"}, now)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
first, err := p.Respond("a", "response-a-123456", true, 0, now)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
replay, err := p.Respond("a", "response-a-123456", true, 0, now.Add(20*time.Second))
if err != nil || replay.Revision != first.Revision { t.Fatalf("replay = %+v, %v", replay, err) }
if _, err = p.Respond("a", "response-a-123456", false, 1, now); !errors.Is(err, ErrConflict) { t.Fatalf("payload reuse error = %v", err) }
if err != nil || replay.Revision != first.Revision {
t.Fatalf("replay = %+v, %v", replay, err)
}
if _, err = p.Respond("a", "response-a-123456", false, 1, now); !errors.Is(err, ErrConflict) {
t.Fatalf("payload reuse error = %v", err)
}
}
func TestProposalExpiryTimesOutPendingParticipantsAndClosesRace(t *testing.T) {
now := time.Unix(1000, 0)
p, err := NewProposal("proposal-123456789", Ranked, []string{"a", "b", "c", "d", "e", "f"}, now)
if err != nil { t.Fatal(err) }
if !p.Expire(now.Add(ProposalWindow)) || p.State != Expired || p.Revision != 1 { t.Fatalf("expiry failed: %+v", p) }
for _, participant := range p.Participants { if participant.Response != TimedOutResponse { t.Fatalf("pending participant not timed out: %+v", participant) } }
if _, err = p.Respond("a", "late-response-123", true, 1, now.Add(ProposalWindow)); !errors.Is(err, ErrProposalClosed) { t.Fatalf("late response error = %v", err) }
if err != nil {
t.Fatal(err)
}
if !p.Expire(now.Add(ProposalWindow)) || p.State != Expired || p.Revision != 1 {
t.Fatalf("expiry failed: %+v", p)
}
for _, participant := range p.Participants {
if participant.Response != TimedOutResponse {
t.Fatalf("pending participant not timed out: %+v", participant)
}
}
if _, err = p.Respond("a", "late-response-123", true, 1, now.Add(ProposalWindow)); !errors.Is(err, ErrProposalClosed) {
t.Fatalf("late response error = %v", err)
}
}
func TestRankedProposalAndCooldownEscalation(t *testing.T) {
now := time.Unix(1000, 0)
if _, err := NewProposal("proposal-123456789", Ranked, []string{"a", "b"}, now); err == nil { t.Fatal("ranked proposal accepted fewer than six") }
if got := CooldownUntil([]CooldownEvent{{At: now, Playlist: Ranked, Kind: DeclinedResponse}}, Ranked, now); !got.Equal(now.Add(2*time.Minute)) { t.Fatalf("ranked decline cooldown = %v", got) }
events := []CooldownEvent{{At: now, Playlist: Ranked, Kind: TimedOutResponse}, {At: now.Add(time.Minute), Playlist: Ranked, Kind: DeclinedResponse}, {At: now.Add(2*time.Minute), Playlist: Ranked, Kind: TimedOutResponse}}
if got := CooldownUntil(events, Ranked, now.Add(2*time.Minute)); !got.Equal(now.Add(17*time.Minute)) { t.Fatalf("ranked escalation cooldown = %v", got) }
if _, err := NewProposal("proposal-123456789", Ranked, []string{"a", "b"}, now); err == nil {
t.Fatal("ranked proposal accepted fewer than six")
}
if got := CooldownUntil([]CooldownEvent{{At: now, Playlist: Ranked, Kind: DeclinedResponse}}, Ranked, now); !got.Equal(now.Add(2 * time.Minute)) {
t.Fatalf("ranked decline cooldown = %v", got)
}
events := []CooldownEvent{{At: now, Playlist: Ranked, Kind: TimedOutResponse}, {At: now.Add(time.Minute), Playlist: Ranked, Kind: DeclinedResponse}, {At: now.Add(2 * time.Minute), Playlist: Ranked, Kind: TimedOutResponse}}
if got := CooldownUntil(events, Ranked, now.Add(2*time.Minute)); !got.Equal(now.Add(17 * time.Minute)) {
t.Fatalf("ranked escalation cooldown = %v", got)
}
}
+68 -30
View File
@@ -11,24 +11,24 @@ import (
const (
QueueHeartbeatInterval = 10 * time.Second
QueueExpiryWindow = 30 * time.Second
QueueExpiryWindow = 30 * time.Second
)
var (
ErrPlayerQueued = errors.New("player already owns an active queue ticket")
ErrPlayerQueued = errors.New("player already owns an active queue ticket")
ErrTicketNotFound = errors.New("queue ticket not found")
ErrNotTicketOwner = errors.New("queue ticket is owned by another player")
ErrTicketExpired = errors.New("queue ticket expired")
ErrTicketExpired = errors.New("queue ticket expired")
)
type QueueTicket struct {
TicketID string
PlayerID string
Candidate Candidate
State State
Revision uint64
TicketID string
PlayerID string
Candidate Candidate
State State
Revision uint64
EnqueuedAt time.Time
ExpiresAt time.Time
ExpiresAt time.Time
}
type queueMutation struct {
@@ -37,8 +37,8 @@ type queueMutation struct {
}
type Queue struct {
tickets map[string]QueueTicket
byPlayer map[string]string
tickets map[string]QueueTicket
byPlayer map[string]string
mutations map[string]queueMutation
}
@@ -52,14 +52,20 @@ func NewQueue() *Queue {
func (q *Queue) Create(playerID, ticketID, idempotencyKey string, candidate Candidate, now time.Time) (QueueTicket, error) {
digest := sha256.Sum256([]byte(createPayload(playerID, ticketID, candidate)))
if prior, ok := q.mutations[idempotencyKey]; ok {
if prior.digest != digest { return QueueTicket{}, fmt.Errorf("%w: create payload changed", ErrConflict) }
if prior.digest != digest {
return QueueTicket{}, fmt.Errorf("%w: create payload changed", ErrConflict)
}
return prior.ticket, nil
}
if idempotencyKey == "" || playerID == "" || ticketID == "" || candidate.TicketID != ticketID {
return QueueTicket{}, fmt.Errorf("%w: invalid queue create", ErrConflict)
}
if _, ok := q.byPlayer[playerID]; ok { return QueueTicket{}, ErrPlayerQueued }
if _, ok := q.tickets[ticketID]; ok { return QueueTicket{}, fmt.Errorf("%w: ticket ID already exists", ErrConflict) }
if _, ok := q.byPlayer[playerID]; ok {
return QueueTicket{}, ErrPlayerQueued
}
if _, ok := q.tickets[ticketID]; ok {
return QueueTicket{}, fmt.Errorf("%w: ticket ID already exists", ErrConflict)
}
ticket := QueueTicket{TicketID: ticketID, PlayerID: playerID, Candidate: candidate, State: Queued, EnqueuedAt: now, ExpiresAt: now.Add(QueueExpiryWindow)}
q.tickets[ticketID] = ticket
q.byPlayer[playerID] = ticketID
@@ -70,15 +76,27 @@ func (q *Queue) Create(playerID, ticketID, idempotencyKey string, candidate Cand
func (q *Queue) Heartbeat(playerID, ticketID, idempotencyKey string, expectedRevision uint64, now time.Time) (QueueTicket, error) {
digest := sha256.Sum256([]byte(fmt.Sprintf("heartbeat:%s:%d", ticketID, expectedRevision)))
if prior, ok := q.mutations[idempotencyKey]; ok {
if prior.digest != digest { return QueueTicket{}, fmt.Errorf("%w: heartbeat payload changed", ErrConflict) }
if prior.digest != digest {
return QueueTicket{}, fmt.Errorf("%w: heartbeat payload changed", ErrConflict)
}
return prior.ticket, nil
}
ticket, err := q.ownedTicket(playerID, ticketID)
if err != nil { return QueueTicket{}, err }
if now.After(ticket.ExpiresAt) || now.Equal(ticket.ExpiresAt) { return QueueTicket{}, ErrTicketExpired }
if ticket.Revision != expectedRevision { return QueueTicket{}, ErrStaleRevision }
if ticket.State != Queued && ticket.State != Proposed { return QueueTicket{}, fmt.Errorf("%w: heartbeat in %s", ErrConflict, ticket.State) }
if idempotencyKey == "" { return QueueTicket{}, fmt.Errorf("%w: empty heartbeat key", ErrConflict) }
if err != nil {
return QueueTicket{}, err
}
if now.After(ticket.ExpiresAt) || now.Equal(ticket.ExpiresAt) {
return QueueTicket{}, ErrTicketExpired
}
if ticket.Revision != expectedRevision {
return QueueTicket{}, ErrStaleRevision
}
if ticket.State != Queued && ticket.State != Proposed {
return QueueTicket{}, fmt.Errorf("%w: heartbeat in %s", ErrConflict, ticket.State)
}
if idempotencyKey == "" {
return QueueTicket{}, fmt.Errorf("%w: empty heartbeat key", ErrConflict)
}
ticket.Revision++
ticket.ExpiresAt = now.Add(QueueExpiryWindow)
q.tickets[ticketID] = ticket
@@ -89,13 +107,21 @@ func (q *Queue) Heartbeat(playerID, ticketID, idempotencyKey string, expectedRev
func (q *Queue) Cancel(playerID, ticketID, idempotencyKey string, expectedRevision uint64, now time.Time) (QueueTicket, error) {
digest := sha256.Sum256([]byte(fmt.Sprintf("cancel:%s:%d", ticketID, expectedRevision)))
if prior, ok := q.mutations[idempotencyKey]; ok {
if prior.digest != digest { return QueueTicket{}, fmt.Errorf("%w: cancel payload changed", ErrConflict) }
if prior.digest != digest {
return QueueTicket{}, fmt.Errorf("%w: cancel payload changed", ErrConflict)
}
return prior.ticket, nil
}
ticket, err := q.ownedTicket(playerID, ticketID)
if err != nil { return QueueTicket{}, err }
if ticket.Revision != expectedRevision { return QueueTicket{}, ErrStaleRevision }
if idempotencyKey == "" { return QueueTicket{}, fmt.Errorf("%w: empty cancel key", ErrConflict) }
if err != nil {
return QueueTicket{}, err
}
if ticket.Revision != expectedRevision {
return QueueTicket{}, ErrStaleRevision
}
if idempotencyKey == "" {
return QueueTicket{}, fmt.Errorf("%w: empty cancel key", ErrConflict)
}
ticket.State = Cancelled
ticket.Revision++
ticket.ExpiresAt = now
@@ -124,10 +150,14 @@ func (q *Queue) Candidates(now time.Time) []Candidate {
q.Expire(now)
result := make([]Candidate, 0)
for _, ticket := range q.tickets {
if ticket.State == Queued { result = append(result, ticket.Candidate) }
if ticket.State == Queued {
result = append(result, ticket.Candidate)
}
}
sort.Slice(result, func(i, j int) bool {
if !result[i].EnqueuedAt.Equal(result[j].EnqueuedAt) { return result[i].EnqueuedAt.Before(result[j].EnqueuedAt) }
if !result[i].EnqueuedAt.Equal(result[j].EnqueuedAt) {
return result[i].EnqueuedAt.Before(result[j].EnqueuedAt)
}
return result[i].TicketID < result[j].TicketID
})
return result
@@ -135,16 +165,24 @@ func (q *Queue) Candidates(now time.Time) []Candidate {
func (q *Queue) ownedTicket(playerID, ticketID string) (QueueTicket, error) {
ticket, ok := q.tickets[ticketID]
if !ok { return QueueTicket{}, ErrTicketNotFound }
if ticket.PlayerID != playerID { return QueueTicket{}, ErrNotTicketOwner }
if !ok {
return QueueTicket{}, ErrTicketNotFound
}
if ticket.PlayerID != playerID {
return QueueTicket{}, ErrNotTicketOwner
}
return ticket, nil
}
func createPayload(playerID, ticketID string, candidate Candidate) string {
regions := make([]string, 0, len(candidate.PredictedRTT))
for region := range candidate.PredictedRTT { regions = append(regions, region) }
for region := range candidate.PredictedRTT {
regions = append(regions, region)
}
sort.Strings(regions)
rtts := make([]string, 0, len(regions))
for _, region := range regions { rtts = append(rtts, fmt.Sprintf("%s=%.9f", region, candidate.PredictedRTT[region])) }
for _, region := range regions {
rtts = append(rtts, fmt.Sprintf("%s=%.9f", region, candidate.PredictedRTT[region]))
}
return strings.Join([]string{playerID, ticketID, candidate.PlayerID, candidate.TicketID, fmt.Sprintf("%.9f", candidate.Rating), candidate.EnqueuedAt.UTC().Format(time.RFC3339Nano), strings.Join(rtts, ",")}, "\x00")
}
+50 -18
View File
@@ -2,6 +2,7 @@ package domain
import (
"errors"
"reflect"
"testing"
"time"
)
@@ -11,37 +12,68 @@ func TestQueueFencesOneActiveTicketPerPlayerAndReplaysCreate(t *testing.T) {
now := time.Unix(1000, 0)
c := Candidate{TicketID: "ticket-a", PlayerID: "player-a", EnqueuedAt: now, PredictedRTT: map[string]float64{"EU": 20}}
first, err := q.Create("player-a", "ticket-a", "create-key-123456", c, now)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
replay, err := q.Create("player-a", "ticket-a", "create-key-123456", c, now.Add(time.Second))
if err != nil || replay != first { t.Fatalf("create replay = %+v, %v", replay, err) }
other := c; other.TicketID = "ticket-b"
if _, err := q.Create("player-a", "ticket-b", "create-key-654321", other, now); !errors.Is(err, ErrPlayerQueued) { t.Fatalf("second active ticket error = %v", err) }
if err != nil || !reflect.DeepEqual(replay, first) {
t.Fatalf("create replay = %+v, %v", replay, err)
}
other := c
other.TicketID = "ticket-b"
if _, err := q.Create("player-a", "ticket-b", "create-key-654321", other, now); !errors.Is(err, ErrPlayerQueued) {
t.Fatalf("second active ticket error = %v", err)
}
}
func TestQueueHeartbeatExtendsExpiryExactlyAndRejectsStaleReplay(t *testing.T) {
q := NewQueue(); now := time.Unix(1000, 0)
q := NewQueue()
now := time.Unix(1000, 0)
c := Candidate{TicketID: "ticket-a", PlayerID: "player-a", EnqueuedAt: now}
if _, err := q.Create("player-a", "ticket-a", "create-key-123456", c, now); err != nil { t.Fatal(err) }
if _, err := q.Create("player-a", "ticket-a", "create-key-123456", c, now); err != nil {
t.Fatal(err)
}
updated, err := q.Heartbeat("player-a", "ticket-a", "heartbeat-key-123", 0, now.Add(10*time.Second))
if err != nil { t.Fatal(err) }
if !updated.ExpiresAt.Equal(now.Add(40 * time.Second)) || updated.Revision != 1 { t.Fatalf("bad heartbeat: %+v", updated) }
if err != nil {
t.Fatal(err)
}
if !updated.ExpiresAt.Equal(now.Add(40*time.Second)) || updated.Revision != 1 {
t.Fatalf("bad heartbeat: %+v", updated)
}
replay, err := q.Heartbeat("player-a", "ticket-a", "heartbeat-key-123", 0, now.Add(50*time.Second))
if err != nil || replay != updated { t.Fatalf("heartbeat replay = %+v, %v", replay, err) }
if _, err := q.Heartbeat("player-a", "ticket-a", "heartbeat-key-456", 0, now.Add(20*time.Second)); !errors.Is(err, ErrStaleRevision) { t.Fatalf("stale heartbeat error = %v", err) }
if err != nil || !reflect.DeepEqual(replay, updated) {
t.Fatalf("heartbeat replay = %+v, %v", replay, err)
}
if _, err := q.Heartbeat("player-a", "ticket-a", "heartbeat-key-456", 0, now.Add(20*time.Second)); !errors.Is(err, ErrStaleRevision) {
t.Fatalf("stale heartbeat error = %v", err)
}
}
func TestQueueExpiryReleasesOwnershipAndDoesNotReturnExpiredCandidates(t *testing.T) {
q := NewQueue(); now := time.Unix(1000, 0)
q := NewQueue()
now := time.Unix(1000, 0)
c := Candidate{TicketID: "ticket-a", PlayerID: "player-a", EnqueuedAt: now}
if _, err := q.Create("player-a", "ticket-a", "create-key-123456", c, now); err != nil { t.Fatal(err) }
if got := q.Candidates(now.Add(QueueExpiryWindow)); len(got) != 0 { t.Fatalf("expired candidate returned: %+v", got) }
if _, err := q.Create("player-a", "ticket-b", "create-key-654321", Candidate{TicketID: "ticket-b", PlayerID: "player-a"}, now.Add(QueueExpiryWindow)); err != nil { t.Fatalf("ownership was not released: %v", err) }
if _, err := q.Create("player-a", "ticket-a", "create-key-123456", c, now); err != nil {
t.Fatal(err)
}
if got := q.Candidates(now.Add(QueueExpiryWindow)); len(got) != 0 {
t.Fatalf("expired candidate returned: %+v", got)
}
if _, err := q.Create("player-a", "ticket-b", "create-key-654321", Candidate{TicketID: "ticket-b", PlayerID: "player-a"}, now.Add(QueueExpiryWindow)); err != nil {
t.Fatalf("ownership was not released: %v", err)
}
}
func TestQueueCreateIdempotencyIncludesCandidatePayload(t *testing.T) {
q := NewQueue(); now := time.Unix(1000, 0)
q := NewQueue()
now := time.Unix(1000, 0)
base := Candidate{TicketID: "ticket-a", PlayerID: "player-a", Rating: 1500, EnqueuedAt: now, PredictedRTT: map[string]float64{"EU": 20}}
if _, err := q.Create("player-a", "ticket-a", "create-key-123456", base, now); err != nil { t.Fatal(err) }
changed := base; changed.Rating = 1800
if _, err := q.Create("player-a", "ticket-a", "create-key-123456", changed, now); !errors.Is(err, ErrConflict) { t.Fatalf("changed create payload error = %v", err) }
if _, err := q.Create("player-a", "ticket-a", "create-key-123456", base, now); err != nil {
t.Fatal(err)
}
changed := base
changed.Rating = 1800
if _, err := q.Create("player-a", "ticket-a", "create-key-123456", changed, now); !errors.Is(err, ErrConflict) {
t.Fatalf("changed create payload error = %v", err)
}
}
+112 -32
View File
@@ -8,37 +8,87 @@ import (
)
const (
GlickoScale = 173.7178
GlickoTau = 0.5
GlickoEpsilon = 0.000001
GlickoInitialRating = 1500.0
GlickoInitialRD = 350.0
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
Value float64
RD float64
Volatility float64
LastRatedAt time.Time
}
type Opponent struct {
PlayerID string
Rating Rating
Weight float64
Score float64
Rating Rating
Weight float64
Score float64
}
type RankedProfile struct {
Rating
RankedGames int
LastSeasonID string
SeasonHistory []string
}
func RankedIsProvisional(profile RankedProfile) bool { return profile.RankedGames < 10 }
// ApplySeasonRollover is idempotent by season ID. It intentionally accepts a
// ranked profile, not the shared/casual rating type, so callers cannot reset a
// casual rating accidentally. The transaction adapter must persist the
// returned profile and season ID atomically with its idempotency key.
func ApplySeasonRollover(profile RankedProfile, seasonID string) (RankedProfile, bool, error) {
if seasonID == "" {
return RankedProfile{}, false, fmt.Errorf("season ID is required")
}
if profile.RankedGames < 0 {
return RankedProfile{}, false, fmt.Errorf("ranked games cannot be negative")
}
if err := validateRating(profile.Rating); err != nil {
return RankedProfile{}, false, err
}
if profile.LastSeasonID == seasonID || containsSeason(profile.SeasonHistory, seasonID) {
return profile, false, nil
}
profile.Value = GlickoInitialRating + 0.75*(profile.Value-GlickoInitialRating)
profile.RD = math.Min(GlickoInitialRD, math.Max(200.0, profile.RD))
profile.LastSeasonID = seasonID
profile.SeasonHistory = append(append([]string(nil), profile.SeasonHistory...), seasonID)
return profile, true, nil
}
func containsSeason(history []string, seasonID string) bool {
for _, prior := range history {
if prior == seasonID {
return true
}
}
return false
}
// 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 }
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") }
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)
@@ -50,11 +100,15 @@ func UpdateRating(current Rating, opponents []Opponent, now time.Time) (Rating,
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") }
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 }
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
@@ -62,35 +116,47 @@ func UpdateRating(current Rating, opponents []Opponent, now time.Time) (Rating,
}
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") }
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 }
if r.LastRatedAt.IsZero() || !now.After(r.LastRatedAt) {
return r
}
periods := int(now.Sub(r.LastRatedAt) / (24 * time.Hour))
if periods <= 0 { return r }
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 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 {
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") }
if b < -100 {
return 0, fmt.Errorf("volatility bracket not found")
}
}
}
fa := volatilityFunction(a, a, phi, v, delta)
@@ -98,9 +164,15 @@ func solveVolatility(phi, v, delta, volatility float64) (float64, error) {
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 }
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") }
if math.IsNaN(b) || math.IsInf(b, 0) {
return 0, fmt.Errorf("volatility iteration diverged")
}
}
return math.Exp(a / 2), nil
}
@@ -115,21 +187,29 @@ func volatilityFunction(x, a, phi, v, delta float64) float64 {
// 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") }
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 }
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 }
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 }
for i := range result {
result[i].Weight = weight
}
return result
}
+35 -11
View File
@@ -14,7 +14,9 @@ func TestUpdateRatingMatchesCanonicalGlicko2Example(t *testing.T) {
{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 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)
}
@@ -24,26 +26,48 @@ 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) }
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) }
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") } }
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) }
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") }
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]") }
if err == nil {
t.Fatal("accepted score outside [0,1]")
}
}
+59
View File
@@ -0,0 +1,59 @@
package domain
import "testing"
func TestRankedProvisionalBoundaryIsFirstTenGames(t *testing.T) {
for games := 0; games < 10; games++ {
if !RankedIsProvisional(RankedProfile{RankedGames: games}) {
t.Fatalf("game %d should be provisional", games)
}
}
if RankedIsProvisional(RankedProfile{RankedGames: 10}) {
t.Fatal("game ten should be fully ranked")
}
}
func TestSeasonRolloverCompressesRatingAndPreservesHistory(t *testing.T) {
profile := RankedProfile{Rating: Rating{Value: 1900, RD: 100, Volatility: 0.12}, RankedGames: 25, SeasonHistory: []string{"season-0"}}
updated, applied, err := ApplySeasonRollover(profile, "season-1")
if err != nil || !applied {
t.Fatalf("rollover failed: %+v applied=%v err=%v", updated, applied, err)
}
if updated.Value != 1800 || updated.RD != 200 || updated.Volatility != profile.Volatility || updated.RankedGames != profile.RankedGames {
t.Fatalf("rollover changed wrong fields: %+v", updated)
}
if len(updated.SeasonHistory) != 2 || updated.SeasonHistory[0] != "season-0" || updated.SeasonHistory[1] != "season-1" {
t.Fatalf("history not preserved: %+v", updated.SeasonHistory)
}
}
func TestSeasonRolloverIsExactlyOnceAndCapsRD(t *testing.T) {
profile := RankedProfile{Rating: Rating{Value: 1200, RD: 350, Volatility: 0.06}, RankedGames: 4}
updated, applied, err := ApplySeasonRollover(profile, "season-1")
if err != nil || !applied || updated.Value != 1275 || updated.RD != 350 {
t.Fatalf("first rollover wrong: %+v applied=%v err=%v", updated, applied, err)
}
replay, applied, err := ApplySeasonRollover(updated, "season-1")
if err != nil || applied || replay.Value != updated.Value || replay.RD != updated.RD || len(replay.SeasonHistory) != 1 {
t.Fatalf("duplicate rollover was not inert: %+v applied=%v err=%v", replay, applied, err)
}
}
func TestSeasonRolloverRejectsInvalidProfileAndReplaysAnyRecordedSeason(t *testing.T) {
if _, _, err := ApplySeasonRollover(RankedProfile{RankedGames: -1, Rating: Rating{Value: 1500, RD: 350, Volatility: 0.06}}, "season-1"); err == nil {
t.Fatal("negative ranked games should be rejected")
}
profile := RankedProfile{Rating: Rating{Value: 1600, RD: 250, Volatility: 0.06}, SeasonHistory: []string{"season-1", "season-2"}}
updated, applied, err := ApplySeasonRollover(profile, "season-1")
if err != nil || applied || updated.Value != profile.Value || updated.RD != profile.RD {
t.Fatalf("recorded season replay was not inert: %+v applied=%v err=%v", updated, applied, err)
}
}
func TestCasualRatingHasNoSeasonOperation(t *testing.T) {
// The API accepts only RankedProfile, making casual season reset impossible
// without an explicit type/compile-time boundary violation.
if RankedIsProvisional(RankedProfile{RankedGames: 10}) {
t.Fatal("casual boundary test fixture unexpectedly provisional")
}
}
+26 -26
View File
@@ -13,9 +13,9 @@ import (
type ResourceKind string
const (
QueueTicket ResourceKind = "queue_ticket"
Proposal ResourceKind = "proposal"
Match ResourceKind = "match"
ResourceQueueTicket ResourceKind = "queue_ticket"
ResourceProposal ResourceKind = "proposal"
ResourceMatch ResourceKind = "match"
)
type State string
@@ -40,8 +40,8 @@ const (
)
var (
ErrConflict = errors.New("mutation conflict")
ErrStaleRevision = errors.New("stale revision")
ErrConflict = errors.New("mutation conflict")
ErrStaleRevision = errors.New("stale revision")
ErrIllegalTransition = errors.New("illegal state transition")
)
@@ -101,11 +101,11 @@ func (r *Record) Apply(idempotencyKey string, payload []byte, expectedRevision u
func legalTransition(kind ResourceKind, from, to State) bool {
var targets []State
switch kind {
case QueueTicket:
case ResourceQueueTicket:
targets = queueTransitions[from]
case Proposal:
case ResourceProposal:
targets = proposalTransitions[from]
case Match:
case ResourceMatch:
targets = matchTransitions[from]
default:
return false
@@ -119,31 +119,31 @@ func legalTransition(kind ResourceKind, from, to State) bool {
}
var queueTransitions = map[State][]State{
Queued: {Proposed, Cancelled, Expired},
Proposed: {Queued, Accepted, Cancelled, Expired},
Accepted: {Queued, Allocating, Cancelled, Failed},
Allocating: {ProcessReady, Failed, Cancelled},
ProcessReady: {AssignmentReady, Failed, Cancelled},
Queued: {Proposed, Cancelled, Expired},
Proposed: {Queued, Accepted, Cancelled, Expired},
Accepted: {Queued, Allocating, Cancelled, Failed},
Allocating: {ProcessReady, Failed, Cancelled},
ProcessReady: {AssignmentReady, Failed, Cancelled},
AssignmentReady: {Assigned, Failed, Cancelled},
Assigned: {Connecting, Failed, Cancelled},
Connecting: {Live, Failed, Expired},
Live: {ResultPending, Failed},
ResultPending: {Completed, Failed},
Completed: {}, Cancelled: {}, Expired: {}, Failed: {},
Assigned: {Connecting, Failed, Cancelled},
Connecting: {Live, Failed, Expired},
Live: {ResultPending, Failed},
ResultPending: {Completed, Failed},
Completed: {}, Cancelled: {}, Expired: {}, Failed: {},
}
var proposalTransitions = map[State][]State{
Open: {Accepted, Declined, Expired, Cancelled},
Open: {Accepted, Declined, Expired, Cancelled},
Accepted: {}, Declined: {}, Expired: {}, Cancelled: {},
}
var matchTransitions = map[State][]State{
Allocating: {ProcessReady, Failed, Cancelled},
ProcessReady: {AssignmentReady, Failed, Cancelled},
Allocating: {ProcessReady, Failed, Cancelled},
ProcessReady: {AssignmentReady, Failed, Cancelled},
AssignmentReady: {Assigned, Failed, Cancelled},
Assigned: {Connecting, Failed, Cancelled},
Connecting: {Live, Failed, Cancelled},
Live: {ResultPending, Failed},
ResultPending: {Completed, Failed},
Completed: {}, Cancelled: {}, Failed: {},
Assigned: {Connecting, Failed, Cancelled},
Connecting: {Live, Failed, Cancelled},
Live: {ResultPending, Failed},
ResultPending: {Completed, Failed},
Completed: {}, Cancelled: {}, Failed: {},
}
+4 -4
View File
@@ -6,7 +6,7 @@ import (
)
func TestApplyIsAtomicOnIllegalTransitionAndStaleRevision(t *testing.T) {
r := NewRecord(QueueTicket, "ticket_1234567890123456", Queued)
r := NewRecord(ResourceQueueTicket, "ticket_1234567890123456", Queued)
if _, err := r.Apply("k1", []byte(`{"state":"LIVE"}`), 0, Live); !errors.Is(err, ErrIllegalTransition) {
t.Fatalf("illegal transition error = %v", err)
}
@@ -22,7 +22,7 @@ func TestApplyIsAtomicOnIllegalTransitionAndStaleRevision(t *testing.T) {
}
func TestApplyReplaysIdenticalIdempotencyWithoutNewRevision(t *testing.T) {
r := NewRecord(QueueTicket, "ticket_1234567890123456", Queued)
r := NewRecord(ResourceQueueTicket, "ticket_1234567890123456", Queued)
payload := []byte(`{"state":"PROPOSED"}`)
first, err := r.Apply("same-key-123456", payload, 0, Proposed)
if err != nil {
@@ -38,7 +38,7 @@ func TestApplyReplaysIdenticalIdempotencyWithoutNewRevision(t *testing.T) {
}
func TestApplyRejectsIdempotencyKeyPayloadConfusion(t *testing.T) {
r := NewRecord(QueueTicket, "ticket_1234567890123456", Queued)
r := NewRecord(ResourceQueueTicket, "ticket_1234567890123456", Queued)
if _, err := r.Apply("same-key-123456", []byte("a"), 0, Proposed); err != nil {
t.Fatal(err)
}
@@ -52,7 +52,7 @@ func TestApplyRejectsIdempotencyKeyPayloadConfusion(t *testing.T) {
func TestTerminalStatesCannotAdvance(t *testing.T) {
for _, state := range []State{Completed, Cancelled, Expired, Failed} {
r := NewRecord(QueueTicket, "ticket_1234567890123456", state)
r := NewRecord(ResourceQueueTicket, "ticket_1234567890123456", state)
if _, err := r.Apply("terminal-key-123", []byte("x"), 0, Live); !errors.Is(err, ErrIllegalTransition) {
t.Fatalf("%s transition error = %v", state, err)
}
+35 -11
View File
@@ -28,10 +28,14 @@ func PartitionTeams(players []Candidate) (Teams, error) {
var visit func(int)
visit = func(start int) {
if len(chosen) == teamSize {
if !containsPlayer(chosen, anchor) { return }
if !containsPlayer(chosen, anchor) {
return
}
team1 := make([]Candidate, 0, teamSize)
for _, player := range ordered {
if !containsPlayer(chosen, player.PlayerID) { team1 = append(team1, player) }
if !containsPlayer(chosen, player.PlayerID) {
team1 = append(team1, player)
}
}
if !found || betterTeams(chosen, team1, best) {
best = Teams{Team0: append([]Candidate(nil), chosen...), Team1: team1}
@@ -46,16 +50,24 @@ func PartitionTeams(players []Candidate) (Teams, error) {
}
}
visit(0)
if !found { return Teams{}, fmt.Errorf("no balanced team partition") }
if !found {
return Teams{}, fmt.Errorf("no balanced team partition")
}
return best, nil
}
func betterTeams(team0, team1 []Candidate, best Teams) bool {
if best.Team0 == nil { return true }
if best.Team0 == nil {
return true
}
meanDelta, maxOpposing := teamScore(team0, team1)
bestMean, bestMaxOpposing := teamScore(best.Team0, best.Team1)
if meanDelta != bestMean { return meanDelta < bestMean }
if maxOpposing != bestMaxOpposing { return maxOpposing < bestMaxOpposing }
if meanDelta != bestMean {
return meanDelta < bestMean
}
if maxOpposing != bestMaxOpposing {
return maxOpposing < bestMaxOpposing
}
return playerIDs(team0) < playerIDs(best.Team0)
}
@@ -65,7 +77,9 @@ func teamScore(team0, team1 []Candidate) (float64, float64) {
for _, left := range team0 {
for _, right := range team1 {
delta := abs(left.Rating - right.Rating)
if delta > maxOpposing { maxOpposing = delta }
if delta > maxOpposing {
maxOpposing = delta
}
}
}
return abs(mean0 - mean1), maxOpposing
@@ -73,20 +87,30 @@ func teamScore(team0, team1 []Candidate) (float64, float64) {
func meanRating(players []Candidate) float64 {
total := 0.0
for _, player := range players { total += player.Rating }
for _, player := range players {
total += player.Rating
}
return total / float64(len(players))
}
func containsPlayer(players []Candidate, playerID string) bool {
for _, player := range players { if player.PlayerID == playerID { return true } }
for _, player := range players {
if player.PlayerID == playerID {
return true
}
}
return false
}
func playerIDs(players []Candidate) string {
ids := make([]string, 0, len(players))
for _, player := range players { ids = append(ids, player.PlayerID) }
for _, player := range players {
ids = append(ids, player.PlayerID)
}
sort.Strings(ids)
result := ""
for _, id := range ids { result += id + "\x00" }
for _, id := range ids {
result += id + "\x00"
}
return result
}
+12 -4
View File
@@ -8,7 +8,9 @@ func TestPartitionTeamsBalancesMeanRatingBeforeOpposingSpread(t *testing.T) {
{PlayerID: "c", Rating: 1900}, {PlayerID: "d", Rating: 2000},
}
teams, err := PartitionTeams(players)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
if playerIDs(teams.Team0) != "a\x00d\x00" || playerIDs(teams.Team1) != "b\x00c\x00" {
t.Fatalf("unexpected balanced partition: team0=%q team1=%q", playerIDs(teams.Team0), playerIDs(teams.Team1))
}
@@ -20,9 +22,13 @@ func TestPartitionTeamsIsIndependentOfInputOrder(t *testing.T) {
{PlayerID: "c", Rating: 1500}, {PlayerID: "a", Rating: 1500},
}
first, err := PartitionTeams(players)
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
second, err := PartitionTeams([]Candidate{players[2], players[0], players[3], players[1]})
if err != nil { t.Fatal(err) }
if err != nil {
t.Fatal(err)
}
if playerIDs(first.Team0) != playerIDs(second.Team0) || playerIDs(first.Team1) != playerIDs(second.Team1) {
t.Fatalf("input order changed partition: first=%q/%q second=%q/%q", playerIDs(first.Team0), playerIDs(first.Team1), playerIDs(second.Team0), playerIDs(second.Team1))
}
@@ -31,6 +37,8 @@ func TestPartitionTeamsIsIndependentOfInputOrder(t *testing.T) {
func TestPartitionTeamsRejectsUnsupportedShapes(t *testing.T) {
for _, count := range []int{0, 1, 3, 7} {
players := make([]Candidate, count)
if _, err := PartitionTeams(players); err == nil { t.Fatalf("accepted %d players", count) }
if _, err := PartitionTeams(players); err == nil {
t.Fatalf("accepted %d players", count)
}
}
}