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 }