mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: add ranked season rollover policy
This commit is contained in:
+2
-2
@@ -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 |
|
||||
|
||||
|
||||
+57
-26
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: {},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user