mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
117 lines
2.9 KiB
Go
117 lines
2.9 KiB
Go
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
|
|
}
|