mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(domain): add casual backfill candidate selection
First slice of task 8.19. docs/MATCHMAKING.md specifies the choice precisely -- "the oldest ordinary casual ticket that meets the same build, region <=100 ms and current anchor-tolerance rules for the vacated human slot; ties use ticket ID" -- and that rule is needed whatever is decided about delivering a late authorisation to a running server, so it is worth landing on its own. Kept a pure function over an already-fetched candidate set: the choice is then reproducible and testable without a database, and claiming the ticket stays a durable transaction as it is for ordinary proposals. Ties break on ticket ID rather than scan order, so two replicas evaluating the same queue cannot offer one slot to different players. Region matching is stricter than ordinary formation: the server already exists in one region, so a candidate must have RTT evidence for that region specifically, not merely share some region with the others. Eligibility is re-checked here as well as at the durable boundary, so an ineligible mid-play or human-occupied slot never reaches selection at all. Ranked is refused outright. Corrected a comment I had written claiming the backfill window is shorter than an ordinary proposal's; both are 10 seconds. What makes a backfill offer separate is its payload and the absent decline penalty, not its timing. This does not yet make backfill work end to end -- see the roster delivery question raised alongside this commit.
This commit is contained in:
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user