268 lines
8.0 KiB
Go
268 lines
8.0 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
type BalanceCandidate struct {
|
|
Teams []Team `json:"teams"`
|
|
Reserve []string `json:"reserve"`
|
|
Score int `json:"score"`
|
|
Explanation []string `json:"explanation"`
|
|
}
|
|
|
|
// Balance deterministically creates three alternatives. Players are assigned to
|
|
// their strongest role while preserving the 1/2/2 composition of every team.
|
|
func Balance(eventID string, players []Player) ([]BalanceCandidate, error) {
|
|
if len(players) < 10 {
|
|
return nil, fmt.Errorf("%w: at least ten players are required", ErrInvalid)
|
|
}
|
|
base := slices.Clone(players)
|
|
slices.SortFunc(base, func(a, b Player) int {
|
|
if a.ID < b.ID {
|
|
return -1
|
|
}
|
|
if a.ID > b.ID {
|
|
return 1
|
|
}
|
|
return 0
|
|
})
|
|
roleOrders := [][]Role{
|
|
{Tank, Damage, Support},
|
|
{Tank, Support, Damage},
|
|
{Damage, Tank, Support},
|
|
{Damage, Support, Tank},
|
|
{Support, Tank, Damage},
|
|
{Support, Damage, Tank},
|
|
}
|
|
all := make([]BalanceCandidate, 0, len(roleOrders)*2)
|
|
for orderIndex, order := range roleOrders {
|
|
for offset := 0; offset < 2; offset++ {
|
|
all = append(all, buildCandidate(eventID, base, orderIndex*2+offset, order, offset))
|
|
}
|
|
}
|
|
slices.SortStableFunc(all, func(a, b BalanceCandidate) int { return a.Score - b.Score })
|
|
result := make([]BalanceCandidate, 0, 3)
|
|
seen := map[string]bool{}
|
|
for _, candidate := range all {
|
|
signature := candidateSignature(candidate)
|
|
if seen[signature] {
|
|
continue
|
|
}
|
|
seen[signature] = true
|
|
result = append(result, candidate)
|
|
if len(result) == 3 {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func buildCandidate(eventID string, players []Player, variant int, roleOrder []Role, offset int) BalanceCandidate {
|
|
teamCount := len(players) / 5
|
|
used := teamCount * 5
|
|
c := BalanceCandidate{Reserve: make([]string, 0, len(players)-used)}
|
|
for _, p := range players[used:] {
|
|
c.Reserve = append(c.Reserve, p.ID)
|
|
}
|
|
active := slices.Clone(players[:used])
|
|
for i := 0; i < teamCount; i++ {
|
|
c.Teams = append(c.Teams, Team{
|
|
ID: fmt.Sprintf("%s-v%d-team-%d", eventID, variant+1, i+1),
|
|
EventID: eventID,
|
|
Name: fmt.Sprintf("Team %d", i+1),
|
|
})
|
|
}
|
|
for _, role := range roleOrder {
|
|
slices.SortStableFunc(active, func(a, b Player) int {
|
|
ar, br := rating(a, role), rating(b, role)
|
|
if ar != br {
|
|
return br - ar
|
|
}
|
|
if a.ID < b.ID {
|
|
return -1
|
|
}
|
|
return 1
|
|
})
|
|
perTeam := 2
|
|
if role == Tank {
|
|
perTeam = 1
|
|
}
|
|
count := teamCount * perTeam
|
|
selected := slices.Clone(active[:count])
|
|
active = active[count:]
|
|
for i, p := range selected {
|
|
teamIndex := (i + offset) % teamCount
|
|
if (i/teamCount)%2 == 1 {
|
|
teamIndex = teamCount - 1 - teamIndex
|
|
if teamIndex < 0 {
|
|
teamIndex += teamCount
|
|
}
|
|
}
|
|
c.Teams[teamIndex].Slots = append(c.Teams[teamIndex].Slots, Slot{PlayerID: p.ID, Role: role, Rating: rating(p, role)})
|
|
}
|
|
}
|
|
optimizeCandidate(c.Teams, players)
|
|
c.Score = balanceScore(c.Teams, players)
|
|
preferredRoles, preferredTeammates, avoidedTeammates := preferenceStats(c.Teams, players)
|
|
c.Explanation = []string{
|
|
fmt.Sprintf("%d preferred role assignments satisfied", preferredRoles),
|
|
fmt.Sprintf("%d preferred teammate choices satisfied", preferredTeammates),
|
|
fmt.Sprintf("%d avoided teammate choices satisfied", avoidedTeammates),
|
|
}
|
|
return c
|
|
}
|
|
|
|
func balanceScore(teams []Team, players []Player) int {
|
|
playerByID := make(map[string]Player, len(players))
|
|
for _, player := range players {
|
|
playerByID[player.ID] = player
|
|
}
|
|
minTotal, maxTotal := math.MaxInt, 0
|
|
roleTotals := map[Role][]int{Tank: {}, Damage: {}, Support: {}}
|
|
weakRolePenalty := 0
|
|
preferredRolePenalty := 0
|
|
teamByPlayer := make(map[string]int)
|
|
for teamIndex, team := range teams {
|
|
total := 0
|
|
perRole := map[Role]int{}
|
|
for _, slot := range team.Slots {
|
|
total += slot.Rating
|
|
perRole[slot.Role] += slot.Rating
|
|
player := playerByID[slot.PlayerID]
|
|
teamByPlayer[slot.PlayerID] = teamIndex
|
|
strongest := max(player.Ratings.Tank, player.Ratings.Damage, player.Ratings.Support)
|
|
weakRolePenalty += strongest - slot.Rating
|
|
if len(player.PreferredRoles) > 0 && !slices.Contains(player.PreferredRoles, slot.Role) {
|
|
preferredRolePenalty += 12
|
|
}
|
|
}
|
|
minTotal = min(minTotal, total)
|
|
maxTotal = max(maxTotal, total)
|
|
for role := range roleTotals {
|
|
roleTotals[role] = append(roleTotals[role], perRole[role])
|
|
}
|
|
}
|
|
preferredTeammatePenalty := 0
|
|
avoidedTeammatePenalty := 0
|
|
for _, player := range players {
|
|
teamIndex, active := teamByPlayer[player.ID]
|
|
if !active {
|
|
continue
|
|
}
|
|
for _, preferredID := range player.PreferredPlayerIDs {
|
|
if preferredTeam, preferredActive := teamByPlayer[preferredID]; preferredActive && preferredTeam != teamIndex {
|
|
preferredTeammatePenalty += 8
|
|
}
|
|
}
|
|
for _, avoidedID := range player.AvoidedPlayerIDs {
|
|
if avoidedTeam, avoidedActive := teamByPlayer[avoidedID]; avoidedActive && avoidedTeam == teamIndex {
|
|
avoidedTeammatePenalty += 14
|
|
}
|
|
}
|
|
}
|
|
score := (maxTotal-minTotal)*3 + weakRolePenalty + preferredRolePenalty + preferredTeammatePenalty + avoidedTeammatePenalty
|
|
for _, totals := range roleTotals {
|
|
minimum, maximum := math.MaxInt, 0
|
|
for _, total := range totals {
|
|
minimum = min(minimum, total)
|
|
maximum = max(maximum, total)
|
|
}
|
|
score += (maximum - minimum) * 2
|
|
}
|
|
return score
|
|
}
|
|
|
|
func optimizeCandidate(teams []Team, players []Player) {
|
|
for iteration := 0; iteration < 20; iteration++ {
|
|
bestScore := balanceScore(teams, players)
|
|
bestA, bestB, bestSlotA, bestSlotB := -1, -1, -1, -1
|
|
for teamA := 0; teamA < len(teams); teamA++ {
|
|
for teamB := teamA + 1; teamB < len(teams); teamB++ {
|
|
for slotA := range teams[teamA].Slots {
|
|
for slotB := range teams[teamB].Slots {
|
|
if teams[teamA].Slots[slotA].Role != teams[teamB].Slots[slotB].Role {
|
|
continue
|
|
}
|
|
teams[teamA].Slots[slotA], teams[teamB].Slots[slotB] = teams[teamB].Slots[slotB], teams[teamA].Slots[slotA]
|
|
score := balanceScore(teams, players)
|
|
teams[teamA].Slots[slotA], teams[teamB].Slots[slotB] = teams[teamB].Slots[slotB], teams[teamA].Slots[slotA]
|
|
if score < bestScore {
|
|
bestScore = score
|
|
bestA, bestB, bestSlotA, bestSlotB = teamA, teamB, slotA, slotB
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if bestA < 0 {
|
|
return
|
|
}
|
|
teams[bestA].Slots[bestSlotA], teams[bestB].Slots[bestSlotB] = teams[bestB].Slots[bestSlotB], teams[bestA].Slots[bestSlotA]
|
|
}
|
|
}
|
|
|
|
func preferenceStats(teams []Team, players []Player) (int, int, int) {
|
|
playerByID := make(map[string]Player, len(players))
|
|
teamByPlayer := make(map[string]int)
|
|
roleByPlayer := make(map[string]Role)
|
|
for _, player := range players {
|
|
playerByID[player.ID] = player
|
|
}
|
|
for teamIndex, team := range teams {
|
|
for _, slot := range team.Slots {
|
|
teamByPlayer[slot.PlayerID] = teamIndex
|
|
roleByPlayer[slot.PlayerID] = slot.Role
|
|
}
|
|
}
|
|
roleMatches, teammateMatches, avoidedMatches := 0, 0, 0
|
|
for playerID, teamIndex := range teamByPlayer {
|
|
player := playerByID[playerID]
|
|
if len(player.PreferredRoles) > 0 && slices.Contains(player.PreferredRoles, roleByPlayer[playerID]) {
|
|
roleMatches++
|
|
}
|
|
for _, preferredID := range player.PreferredPlayerIDs {
|
|
if preferredTeam, ok := teamByPlayer[preferredID]; ok && preferredTeam == teamIndex {
|
|
teammateMatches++
|
|
}
|
|
}
|
|
for _, avoidedID := range player.AvoidedPlayerIDs {
|
|
if avoidedTeam, ok := teamByPlayer[avoidedID]; ok && avoidedTeam != teamIndex {
|
|
avoidedMatches++
|
|
}
|
|
}
|
|
}
|
|
return roleMatches, teammateMatches, avoidedMatches
|
|
}
|
|
|
|
func candidateSignature(candidate BalanceCandidate) string {
|
|
parts := make([]string, 0, len(candidate.Teams))
|
|
for _, team := range candidate.Teams {
|
|
slots := slices.Clone(team.Slots)
|
|
slices.SortFunc(slots, func(a, b Slot) int {
|
|
return strings.Compare(string(a.Role)+a.PlayerID, string(b.Role)+b.PlayerID)
|
|
})
|
|
var values []string
|
|
for _, slot := range slots {
|
|
values = append(values, string(slot.Role)+":"+slot.PlayerID)
|
|
}
|
|
parts = append(parts, strings.Join(values, ","))
|
|
}
|
|
slices.Sort(parts)
|
|
return strings.Join(parts, "|")
|
|
}
|
|
|
|
func rating(p Player, role Role) int {
|
|
switch role {
|
|
case Tank:
|
|
return p.Ratings.Tank
|
|
case Damage:
|
|
return p.Ratings.Damage
|
|
default:
|
|
return p.Ratings.Support
|
|
}
|
|
}
|