diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 1a8035c5..0cb392b8 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1193,7 +1193,7 @@ the local/CI/community transport, not a silent production fallback. |---|---|---| | 8.14 `[D:8.4,8.5,8.8]` | One durable PostgreSQL queue owner/player plus Redis candidate index: 10 s heartbeat, 30 s expiry, retry-safe create/cancel/resume and repair after cache loss | Replicas/duplicates never place a player twice; failover may delay/rematerialise an index but durable ownership and active-participation fences converge | | 8.15 `[D:7.8,8.3]` | Submit opaque Steam ping location plus nonce-bound probes; backend computes estimates, enforces 30 s freshness and quarantines 3 discrepancies >25 ms or 30% until 5 clean matches | A client cannot directly choose its placement RTT; stale/forged evidence is rejected; quarantine behavior and server-observed comparison are deterministic | -| 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion and deterministic set/region scoring | `server/domain/matcher.go` and adversarial fixtures cover no-common-region, tolerance boundaries and lexical ties; team partitioning, queue-backed candidate loading and full population fixtures remain | +| 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance and input-order independence; queue-backed candidate loading and full population fixtures remain | | 8.17 `[D:8.14,8.16]` | Ten-second proposal to **every selected human**: ranked 6; casual largest compatible 6→2 after 60 s with disclosed teams/bots; apply exact decline/timeout/no-show cooldown and queue-precedence rules | Allocation starts only after selected humans accept; 2–5-human casual is reachable; accepter timestamps restore exactly; ranked pre-match no-show has cooldown but no rating loss | | 8.18 `[D:8.5,8.14,8.17]` | Horizontally replicated matcher: Redis candidates, then PostgreSQL serializable proposal/participant fence, then cache cleanup/repair | Forced loss of the last acknowledged Redis write, retries, worker death and failover cannot claim a player into two proposals/matches | | 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 | diff --git a/server/domain/teams.go b/server/domain/teams.go new file mode 100644 index 00000000..8e59fdad --- /dev/null +++ b/server/domain/teams.go @@ -0,0 +1,92 @@ +package domain + +import ( + "fmt" + "sort" +) + +type Teams struct { + Team0 []Candidate + Team1 []Candidate +} + +// PartitionTeams exhaustively evaluates balanced two-team assignments. The +// first team is anchored to the lexically smallest player to remove the +// equivalent team-0/team-1 mirror; this makes the result stable across worker +// order and database row order. +func PartitionTeams(players []Candidate) (Teams, error) { + if len(players) < 2 || len(players) > 6 || len(players)%2 != 0 { + return Teams{}, fmt.Errorf("team partition requires an even player count from 2 through 6") + } + ordered := append([]Candidate(nil), players...) + sort.Slice(ordered, func(i, j int) bool { return ordered[i].PlayerID < ordered[j].PlayerID }) + teamSize := len(ordered) / 2 + anchor := ordered[0].PlayerID + best := Teams{} + found := false + chosen := make([]Candidate, 0, teamSize) + var visit func(int) + visit = func(start int) { + if len(chosen) == teamSize { + if !containsPlayer(chosen, anchor) { return } + team1 := make([]Candidate, 0, teamSize) + for _, player := range ordered { + if !containsPlayer(chosen, player.PlayerID) { team1 = append(team1, player) } + } + if !found || betterTeams(chosen, team1, best) { + best = Teams{Team0: append([]Candidate(nil), chosen...), Team1: team1} + found = true + } + return + } + for i := start; i < len(ordered); i++ { + chosen = append(chosen, ordered[i]) + visit(i + 1) + chosen = chosen[:len(chosen)-1] + } + } + visit(0) + 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 } + meanDelta, maxOpposing := teamScore(team0, team1) + bestMean, bestMaxOpposing := teamScore(best.Team0, best.Team1) + if meanDelta != bestMean { return meanDelta < bestMean } + if maxOpposing != bestMaxOpposing { return maxOpposing < bestMaxOpposing } + return playerIDs(team0) < playerIDs(best.Team0) +} + +func teamScore(team0, team1 []Candidate) (float64, float64) { + mean0, mean1 := meanRating(team0), meanRating(team1) + maxOpposing := 0.0 + for _, left := range team0 { + for _, right := range team1 { + delta := abs(left.Rating - right.Rating) + if delta > maxOpposing { maxOpposing = delta } + } + } + return abs(mean0 - mean1), maxOpposing +} + +func meanRating(players []Candidate) float64 { + total := 0.0 + 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 } } + return false +} + +func playerIDs(players []Candidate) string { + ids := make([]string, 0, len(players)) + for _, player := range players { ids = append(ids, player.PlayerID) } + sort.Strings(ids) + result := "" + for _, id := range ids { result += id + "\x00" } + return result +} diff --git a/server/domain/teams_test.go b/server/domain/teams_test.go new file mode 100644 index 00000000..a814d689 --- /dev/null +++ b/server/domain/teams_test.go @@ -0,0 +1,36 @@ +package domain + +import "testing" + +func TestPartitionTeamsBalancesMeanRatingBeforeOpposingSpread(t *testing.T) { + players := []Candidate{ + {PlayerID: "a", Rating: 1000}, {PlayerID: "b", Rating: 1100}, + {PlayerID: "c", Rating: 1900}, {PlayerID: "d", Rating: 2000}, + } + teams, err := PartitionTeams(players) + 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)) + } +} + +func TestPartitionTeamsIsIndependentOfInputOrder(t *testing.T) { + players := []Candidate{ + {PlayerID: "d", Rating: 1500}, {PlayerID: "b", Rating: 1500}, + {PlayerID: "c", Rating: 1500}, {PlayerID: "a", Rating: 1500}, + } + first, err := PartitionTeams(players) + 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 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)) + } +} + +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) } + } +}