mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat: add deterministic matchmaking team partitioning
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user