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) } }