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 := preferenceStats(c.Teams, players) c.Explanation = []string{ fmt.Sprintf("%d preferred role assignments satisfied", preferredRoles), fmt.Sprintf("%d preferred teammate choices satisfied", preferredTeammates), } 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 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 } } } score := (maxTotal-minTotal)*3 + weakRolePenalty + preferredRolePenalty + preferredTeammatePenalty 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) { 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 := 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++ } } } return roleMatches, teammateMatches } 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 } }