diff --git a/multiplayer-todo.md b/multiplayer-todo.md index b831661a..773fde79 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -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 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]` | **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 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]` | 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 | diff --git a/server/domain/matcher.go b/server/domain/matcher.go index 98c33bd2..6a22172a 100644 --- a/server/domain/matcher.go +++ b/server/domain/matcher.go @@ -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 +} diff --git a/server/domain/matcher_test.go b/server/domain/matcher_test.go index b1445f58..1d92ef38 100644 --- a/server/domain/matcher_test.go +++ b/server/domain/matcher_test.go @@ -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") + } } diff --git a/server/domain/probes.go b/server/domain/probes.go index 9c47156b..21e0134f 100644 --- a/server/domain/probes.go +++ b/server/domain/probes.go @@ -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 } diff --git a/server/domain/probes_test.go b/server/domain/probes_test.go index c8f7cbb7..443d2d57 100644 --- a/server/domain/probes_test.go +++ b/server/domain/probes_test.go @@ -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") + } } diff --git a/server/domain/proposal.go b/server/domain/proposal.go index 0a992567..5ad102d8 100644 --- a/server/domain/proposal.go +++ b/server/domain/proposal.go @@ -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) } diff --git a/server/domain/proposal_test.go b/server/domain/proposal_test.go index c4394b91..dd40578c 100644 --- a/server/domain/proposal_test.go +++ b/server/domain/proposal_test.go @@ -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) + } } diff --git a/server/domain/queue.go b/server/domain/queue.go index 2598beaf..a2fdf699 100644 --- a/server/domain/queue.go +++ b/server/domain/queue.go @@ -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") } diff --git a/server/domain/queue_test.go b/server/domain/queue_test.go index ae27a41f..4b8e2896 100644 --- a/server/domain/queue_test.go +++ b/server/domain/queue_test.go @@ -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) + } } diff --git a/server/domain/rating.go b/server/domain/rating.go index 83a585a4..123a5b62 100644 --- a/server/domain/rating.go +++ b/server/domain/rating.go @@ -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 } diff --git a/server/domain/rating_test.go b/server/domain/rating_test.go index 737c301f..ec630dc8 100644 --- a/server/domain/rating_test.go +++ b/server/domain/rating_test.go @@ -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]") + } } diff --git a/server/domain/season_test.go b/server/domain/season_test.go new file mode 100644 index 00000000..f4479c4f --- /dev/null +++ b/server/domain/season_test.go @@ -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") + } +} diff --git a/server/domain/state.go b/server/domain/state.go index 5032a3d9..4e32e8b5 100644 --- a/server/domain/state.go +++ b/server/domain/state.go @@ -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: {}, } diff --git a/server/domain/state_test.go b/server/domain/state_test.go index cc5d880a..42857f1e 100644 --- a/server/domain/state_test.go +++ b/server/domain/state_test.go @@ -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) } diff --git a/server/domain/teams.go b/server/domain/teams.go index 8e59fdad..ee01abb0 100644 --- a/server/domain/teams.go +++ b/server/domain/teams.go @@ -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 } diff --git a/server/domain/teams_test.go b/server/domain/teams_test.go index a814d689..1dec380c 100644 --- a/server/domain/teams_test.go +++ b/server/domain/teams_test.go @@ -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) + } } }