diff --git a/server/domain/backfill.go b/server/domain/backfill.go new file mode 100644 index 00000000..10a43278 --- /dev/null +++ b/server/domain/backfill.go @@ -0,0 +1,118 @@ +package domain + +import ( + "fmt" + "time" +) + +// BackfillProposalWindow is the response window for a backfill offer. The +// design specifies "a separate 10-second opt-in proposal", which is the same +// duration as an ordinary proposal -- it is named separately because what +// differs is the payload (score, time remaining, team and slot) and the +// absence of any decline penalty, not the timing. +const BackfillProposalWindow = ProposalWindow + +// BackfillTarget describes the vacated slot a backfill is trying to fill, plus +// the compatibility contract the running match already committed to. The +// backfilled player joins an existing server, so build, protocol and region +// are fixed by that match rather than negotiated. +type BackfillTarget struct { + MatchID string + ServerID string + Region string + // Anchor carries the match's build/protocol/playlist contract. Only the + // compatibility fields are read; rating and RTT come from the candidate. + Anchor Candidate + Slot CasualSlot + Phase CasualPhase + // AnchorRating is the match's representative rating, used for the same + // widening tolerance an ordinary proposal would apply. + AnchorRating float64 + // VacatedAt is when the slot became fillable. Tolerance widens with the + // wait, matching ordinary queue behaviour. + VacatedAt time.Time +} + +var ErrNoBackfillCandidate = fmt.Errorf("no eligible backfill candidate") + +// SelectCasualBackfillCandidate implements docs/MATCHMAKING.md's rule for the +// vacated human slot: the oldest ordinary casual ticket meeting the same +// build, a region RTT at or under the placement ceiling, and the current +// anchor-tolerance rule, with ties broken by ticket ID. +// +// It is deliberately a pure function over an already-fetched candidate set, so +// the choice is reproducible and testable without a database. It selects only; +// claiming the ticket remains a durable transaction, as with ordinary +// proposals. +func SelectCasualBackfillCandidate(target BackfillTarget, candidates []Candidate, now time.Time) (Candidate, error) { + if target.MatchID == "" || target.ServerID == "" || target.Region == "" || now.IsZero() { + return Candidate{}, fmt.Errorf("invalid backfill target") + } + // Backfill replaces a bot slot at a kickoff boundary only. Enforcing it + // here as well as at the durable boundary keeps an ineligible mid-play + // slot from ever reaching candidate selection. + if !CanCasualBackfill(target.Phase, target.Slot) { + return Candidate{}, ErrNoBackfillCandidate + } + if target.Anchor.Playlist != "" && target.Anchor.Playlist != Casual { + // Ranked is never backfilled: exactly six verified humans, never bots. + return Candidate{}, ErrNoBackfillCandidate + } + tolerance := RatingTolerance(now.Sub(target.VacatedAt).Seconds()) + + var best Candidate + found := false + for _, candidate := range candidates { + if !eligibleBackfillCandidate(target, candidate, tolerance) { + continue + } + if !found || betterBackfillCandidate(candidate, best) { + best = candidate + found = true + } + } + if !found { + return Candidate{}, ErrNoBackfillCandidate + } + return best, nil +} + +func eligibleBackfillCandidate(target BackfillTarget, candidate Candidate, tolerance float64) bool { + if !validCandidate(candidate) { + return false + } + // "ordinary casual ticket": a backfill offer is only ever made to someone + // queuing normally, never to another match's participant. + if candidate.Playlist != Casual { + return false + } + if !compatibleMetadata(target.Anchor, candidate) { + return false + } + // The server already exists in one region, so the candidate must reach + // that region specifically -- not merely share some region with others. + rtt, measured := candidate.PredictedRTT[target.Region] + if !measured || rtt > MaxPlacementRTT { + return false + } + return abs(candidate.Rating-target.AnchorRating) <= tolerance +} + +// betterBackfillCandidate is the design's ordering: oldest ticket first, ties +// broken by ticket ID so the choice is deterministic across replicas rather +// than dependent on scan order. +func betterBackfillCandidate(candidate, best Candidate) bool { + if candidate.EnqueuedAt.Before(best.EnqueuedAt) { + return true + } + if candidate.EnqueuedAt.After(best.EnqueuedAt) { + return false + } + return candidate.TicketID < best.TicketID +} + +// BackfillDeclinePenalty is zero by design. Declining or ignoring a backfill +// offer costs nothing: the player asked for an ordinary match and is being +// offered a partly-played one, so refusing is not antisocial the way declining +// an ordinary proposal is. +func BackfillDeclinePenalty() time.Duration { return 0 } diff --git a/server/domain/backfill_test.go b/server/domain/backfill_test.go new file mode 100644 index 00000000..b176aafb --- /dev/null +++ b/server/domain/backfill_test.go @@ -0,0 +1,153 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func backfillTarget() BackfillTarget { + return BackfillTarget{ + MatchID: "match-1", ServerID: "server-1", Region: "EU", + Anchor: Candidate{Playlist: Casual, ClientBuild: "build-1", ProtocolVersion: 1}, + Slot: CasualSlot{Slot: 2, Team: 0, PlayerID: "bot-slot-2", IsBot: true}, + Phase: CasualKickoff, + AnchorRating: 1500, + VacatedAt: time.Unix(1000, 0).UTC(), + } +} + +func backfillCandidate(ticketID string, enqueuedAt time.Time) Candidate { + return Candidate{ + TicketID: ticketID, PlayerID: "player-" + ticketID, Playlist: Casual, + ClientBuild: "build-1", ProtocolVersion: 1, Rating: 1500, + EnqueuedAt: enqueuedAt, PredictedRTT: map[string]float64{"EU": 40}, + } +} + +// docs/MATCHMAKING.md: "Choose the oldest ordinary casual ticket ... ties use +// ticket ID." +func TestBackfillPicksTheOldestTicketAndBreaksTiesByID(t *testing.T) { + now := time.Unix(1000, 0).UTC() + base := now.Add(-time.Minute) + candidates := []Candidate{ + backfillCandidate("ticket-c", base.Add(2*time.Second)), + backfillCandidate("ticket-b", base), // tie with ticket-a, loses on ID + backfillCandidate("ticket-a", base), // oldest, lowest ID + backfillCandidate("ticket-d", base.Add(time.Second)), + } + chosen, err := SelectCasualBackfillCandidate(backfillTarget(), candidates, now) + if err != nil { + t.Fatalf("select: %v", err) + } + if chosen.TicketID != "ticket-a" { + t.Fatalf("chose %q, want the oldest ticket with the lowest ID", chosen.TicketID) + } + + // Determinism: the result must not depend on scan order, or two replicas + // could offer the same slot to different players. + reversed := []Candidate{candidates[2], candidates[1], candidates[3], candidates[0]} + again, err := SelectCasualBackfillCandidate(backfillTarget(), reversed, now) + if err != nil || again.TicketID != chosen.TicketID { + t.Fatalf("selection depends on input order: %q vs %q (err=%v)", again.TicketID, chosen.TicketID, err) + } +} + +func TestBackfillRejectsIncompatibleCandidates(t *testing.T) { + now := time.Unix(1000, 0).UTC() + base := now.Add(-time.Minute) + for name, mutate := range map[string]func(*Candidate){ + "wrong build": func(c *Candidate) { c.ClientBuild = "build-2" }, + "wrong protocol": func(c *Candidate) { c.ProtocolVersion = 2 }, + "ranked ticket": func(c *Candidate) { c.Playlist = Ranked }, + // The server already exists in one region; sharing some other region + // is not enough. + "no RTT for the match region": func(c *Candidate) { c.PredictedRTT = map[string]float64{"NA": 20} }, + "over the placement ceiling": func(c *Candidate) { c.PredictedRTT = map[string]float64{"EU": MaxPlacementRTT + 1} }, + "no RTT evidence at all": func(c *Candidate) { c.PredictedRTT = nil }, + "rating far outside tolerance": func(c *Candidate) { c.Rating = 1500 + MaxRatingTolerance + 1 }, + } { + t.Run(name, func(t *testing.T) { + candidate := backfillCandidate("ticket-a", base) + mutate(&candidate) + if _, err := SelectCasualBackfillCandidate(backfillTarget(), []Candidate{candidate}, now); !errors.Is(err, ErrNoBackfillCandidate) { + t.Fatalf("err = %v, want ErrNoBackfillCandidate", err) + } + }) + } +} + +// Backfill replaces a bot slot at a kickoff boundary only, never a live human +// slot and never mid-play. +func TestBackfillOnlyFillsBotSlotsAtKickoff(t *testing.T) { + now := time.Unix(1000, 0).UTC() + candidates := []Candidate{backfillCandidate("ticket-a", now.Add(-time.Minute))} + for name, mutate := range map[string]func(*BackfillTarget){ + "mid-play": func(target *BackfillTarget) { target.Phase = CasualLive }, + "occupied by a human": func(target *BackfillTarget) { target.Slot.IsBot = false }, + "ranked match": func(target *BackfillTarget) { target.Anchor.Playlist = Ranked }, + } { + t.Run(name, func(t *testing.T) { + target := backfillTarget() + mutate(&target) + if _, err := SelectCasualBackfillCandidate(target, candidates, now); !errors.Is(err, ErrNoBackfillCandidate) { + t.Fatalf("err = %v, want ErrNoBackfillCandidate", err) + } + }) + } +} + +// Tolerance widens with the wait, exactly as it does for an ordinary queue, so +// a slot that has sat vacant longer accepts a wider rating spread. +func TestBackfillToleranceWidensWithTheVacancy(t *testing.T) { + base := time.Unix(1000, 0).UTC() + target := backfillTarget() + target.VacatedAt = base + distant := backfillCandidate("ticket-a", base.Add(-time.Minute)) + distant.Rating = target.AnchorRating + MinRatingTolerance + 1 + + if _, err := SelectCasualBackfillCandidate(target, []Candidate{distant}, base); !errors.Is(err, ErrNoBackfillCandidate) { + t.Fatalf("a candidate outside the initial tolerance was accepted: %v", err) + } + widened := base.Add(10 * time.Minute) + if _, err := SelectCasualBackfillCandidate(target, []Candidate{distant}, widened); err != nil { + t.Fatalf("tolerance did not widen with the vacancy: %v", err) + } +} + +func TestBackfillRejectsInvalidTargets(t *testing.T) { + now := time.Unix(1000, 0).UTC() + candidates := []Candidate{backfillCandidate("ticket-a", now.Add(-time.Minute))} + for name, mutate := range map[string]func(*BackfillTarget){ + "no match": func(target *BackfillTarget) { target.MatchID = "" }, + "no server": func(target *BackfillTarget) { target.ServerID = "" }, + "no region": func(target *BackfillTarget) { target.Region = "" }, + } { + t.Run(name, func(t *testing.T) { + target := backfillTarget() + mutate(&target) + if _, err := SelectCasualBackfillCandidate(target, candidates, now); err == nil { + t.Fatalf("invalid target %s was accepted", name) + } + }) + } + if _, err := SelectCasualBackfillCandidate(backfillTarget(), nil, time.Time{}); err == nil { + t.Fatal("zero time was accepted") + } +} + +// Declining or ignoring a backfill offer costs nothing: the player asked for +// an ordinary match and is being offered a partly-played one. +func TestBackfillCarriesNoDeclinePenaltyAndAShortWindow(t *testing.T) { + if BackfillDeclinePenalty() != 0 || CasualBackfillPenalty() != 0 { + t.Fatal("backfill must not carry a cooldown") + } + if BackfillProposalWindow != 10*time.Second { + t.Fatalf("backfill window = %v, want the documented 10s", BackfillProposalWindow) + } + // Same duration as an ordinary proposal. What makes a backfill offer + // "separate" is its payload and the absent penalty, not its timing. + if BackfillProposalWindow != ProposalWindow { + t.Fatalf("backfill window %v diverged from the ordinary proposal window %v", BackfillProposalWindow, ProposalWindow) + } +}