c141fc6dd7
Implement team generation algorithms for tournament partnerships: - Random pairing (Fisher-Yates shuffle) - ELO-based pairing (strongest + weakest) - Even matches (balance competitive levels) - Minimize repeat partnerships - Support for byes with odd participant counts - Team balance calculation utilities Refs #22
408 lines
11 KiB
TypeScript
408 lines
11 KiB
TypeScript
/**
|
|
* Team Generation Algorithms
|
|
*
|
|
* Provides algorithms for generating teams in tournaments
|
|
* based on different partner rotation strategies.
|
|
*/
|
|
|
|
export type PartnerRotation = 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based'
|
|
|
|
export interface Player {
|
|
id: number
|
|
currentElo: number
|
|
name: string
|
|
}
|
|
|
|
export interface Team {
|
|
player1Id: number
|
|
player2Id: number
|
|
teamName: string | null
|
|
}
|
|
|
|
export interface TeamGenerationResult {
|
|
teams: Team[]
|
|
byePlayer: Player | null
|
|
strategy: PartnerRotation
|
|
}
|
|
|
|
/**
|
|
* Generate teams based on partner rotation strategy
|
|
*/
|
|
export function generateTeams(
|
|
players: Player[],
|
|
strategy: PartnerRotation,
|
|
allowByes: boolean
|
|
): TeamGenerationResult {
|
|
if (players.length < 2) {
|
|
return { teams: [], byePlayer: null, strategy }
|
|
}
|
|
|
|
// Handle odd number of players
|
|
let byePlayer: Player | null = null
|
|
let workingPlayers = [...players]
|
|
|
|
if (workingPlayers.length % 2 !== 0) {
|
|
if (!allowByes) {
|
|
throw new Error("Odd number of participants. Enable 'Allow Byes' to proceed.")
|
|
}
|
|
// Remove the player with the lowest ELO for bye
|
|
workingPlayers.sort((a, b) => a.currentElo - b.currentElo)
|
|
byePlayer = workingPlayers.pop() || null
|
|
}
|
|
|
|
let teams: Team[]
|
|
|
|
switch (strategy) {
|
|
case 'none': // Random pairing
|
|
teams = generateRandomTeams(workingPlayers)
|
|
break
|
|
|
|
case 'minimize_repeat':
|
|
// For initial generation, we can't minimize repeats since there are no previous teams
|
|
// So we just generate random teams
|
|
teams = generateRandomTeams(workingPlayers)
|
|
break
|
|
|
|
case 'maximize_even':
|
|
// Pair players to maximize competitive balance
|
|
teams = generateEvenTeams(workingPlayers)
|
|
break
|
|
|
|
case 'elo_based':
|
|
// Pair strongest with weakest
|
|
teams = generateELOBasedTeams(workingPlayers)
|
|
break
|
|
|
|
default:
|
|
teams = generateRandomTeams(workingPlayers)
|
|
}
|
|
|
|
return { teams, byePlayer, strategy }
|
|
}
|
|
|
|
/**
|
|
* Generate random teams using Fisher-Yates shuffle
|
|
*/
|
|
export function generateRandomTeams(players: Player[]): Team[] {
|
|
const shuffled = [...players]
|
|
|
|
// Fisher-Yates shuffle
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1))
|
|
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
|
}
|
|
|
|
return createTeamsFromPairs(shuffled)
|
|
}
|
|
|
|
/**
|
|
* Generate teams to maximize competitive balance
|
|
* Pairs top half with bottom half by ELO
|
|
*/
|
|
export function generateEvenTeams(players: Player[]): Team[] {
|
|
// Sort by ELO descending
|
|
const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo)
|
|
|
|
// Split into two halves
|
|
const midpoint = Math.floor(sorted.length / 2)
|
|
const topHalf = sorted.slice(0, midpoint)
|
|
const bottomHalf = sorted.slice(midpoint)
|
|
|
|
// Interleave: pair top players with bottom players
|
|
const interleaved: Player[] = []
|
|
const maxLen = Math.max(topHalf.length, bottomHalf.length)
|
|
|
|
for (let i = 0; i < maxLen; i++) {
|
|
if (i < topHalf.length) interleaved.push(topHalf[i])
|
|
if (i < bottomHalf.length) interleaved.push(bottomHalf[i])
|
|
}
|
|
|
|
return createTeamsFromPairs(interleaved)
|
|
}
|
|
|
|
/**
|
|
* Generate ELO-based teams (strongest + weakest pairing)
|
|
* Pairs highest with lowest, 2nd highest with 2nd lowest, etc.
|
|
*/
|
|
export function generateELOBasedTeams(players: Player[]): Team[] {
|
|
// Sort by ELO descending
|
|
const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo)
|
|
|
|
const teams: Team[] = []
|
|
|
|
// Pair strongest with weakest
|
|
for (let i = 0; i < Math.floor(sorted.length / 2); i++) {
|
|
const j = sorted.length - 1 - i
|
|
if (i >= j) break
|
|
|
|
teams.push({
|
|
player1Id: sorted[i].id,
|
|
player2Id: sorted[j].id,
|
|
teamName: `${sorted[i].name} & ${sorted[j].name}`,
|
|
})
|
|
}
|
|
|
|
return teams
|
|
}
|
|
|
|
/**
|
|
* Helper function to create teams from pairs of players
|
|
*/
|
|
function createTeamsFromPairs(players: Player[]): Team[] {
|
|
const teams: Team[] = []
|
|
|
|
for (let i = 0; i < players.length - 1; i += 2) {
|
|
teams.push({
|
|
player1Id: players[i].id,
|
|
player2Id: players[i + 1].id,
|
|
teamName: `${players[i].name} & ${players[i + 1].name}`,
|
|
})
|
|
}
|
|
|
|
return teams
|
|
}
|
|
|
|
/**
|
|
* Calculate ELO balance score for a set of teams
|
|
* Higher score means more balanced teams
|
|
*/
|
|
export function calculateTeamBalance(teams: Team[], players: Player[]): number {
|
|
const playerMap = new Map(players.map(p => [p.id, p]))
|
|
|
|
let totalBalance = 0
|
|
let validTeams = 0
|
|
|
|
for (const team of teams) {
|
|
const player1 = playerMap.get(team.player1Id)
|
|
const player2 = playerMap.get(team.player2Id)
|
|
|
|
if (player1 && player2) {
|
|
// Balance is higher when ELOs are closer
|
|
const diff = Math.abs(player1.currentElo - player2.currentElo)
|
|
totalBalance += diff
|
|
validTeams++
|
|
}
|
|
}
|
|
|
|
// Return average ELO difference (lower is better balanced)
|
|
return validTeams > 0 ? totalBalance / validTeams : 0
|
|
}
|
|
|
|
/**
|
|
* Calculate partnership frequency for a set of teams
|
|
* Returns a map of partnership pairs to their count
|
|
*/
|
|
export function calculatePartnershipFrequency(
|
|
allTeams: Team[][],
|
|
players: Player[]
|
|
): Map<string, number> {
|
|
const frequency = new Map<string, number>()
|
|
|
|
for (const roundTeams of allTeams) {
|
|
for (const team of roundTeams) {
|
|
// Create sorted key to handle both orderings
|
|
const key = [team.player1Id, team.player2Id].sort().join('-')
|
|
frequency.set(key, (frequency.get(key) || 0) + 1)
|
|
}
|
|
}
|
|
|
|
return frequency
|
|
}
|
|
|
|
/**
|
|
* Generate teams with partner rotation to minimize repeats
|
|
* This algorithm tries to avoid pairing players who have already partnered together
|
|
*/
|
|
export function generateTeamsWithRotation(
|
|
players: Player[],
|
|
previousTeams: Team[][],
|
|
strategy: PartnerRotation = 'none',
|
|
allowByes: boolean = true
|
|
): TeamGenerationResult {
|
|
if (players.length < 2) {
|
|
return { teams: [], byePlayer: null, strategy }
|
|
}
|
|
|
|
// Calculate partnership frequency from previous rounds
|
|
const partnershipFreq = calculatePartnershipFrequency(previousTeams, players)
|
|
|
|
// Handle odd number of players
|
|
let byePlayer: Player | null = null
|
|
let workingPlayers = [...players]
|
|
|
|
if (workingPlayers.length % 2 !== 0) {
|
|
if (!allowByes) {
|
|
throw new Error("Odd number of participants. Enable 'Allow Byes' to proceed.")
|
|
}
|
|
// Remove the player with the lowest ELO for bye
|
|
workingPlayers.sort((a, b) => a.currentElo - b.currentElo)
|
|
byePlayer = workingPlayers.pop() || null
|
|
}
|
|
|
|
// Generate teams based on strategy, avoiding repeat partnerships
|
|
let teams: Team[]
|
|
|
|
switch (strategy) {
|
|
case 'minimize_repeat':
|
|
teams = generateTeamsMinimizingRepeats(workingPlayers, partnershipFreq)
|
|
break
|
|
|
|
case 'maximize_even':
|
|
teams = generateEvenTeamsAvoidingRepeats(workingPlayers, partnershipFreq)
|
|
break
|
|
|
|
case 'elo_based':
|
|
teams = generateELOBasedTeamsAvoidingRepeats(workingPlayers, partnershipFreq)
|
|
break
|
|
|
|
default:
|
|
teams = generateRandomTeams(workingPlayers)
|
|
}
|
|
|
|
return { teams, byePlayer, strategy }
|
|
}
|
|
|
|
/**
|
|
* Generate teams minimizing repeat partnerships
|
|
*/
|
|
function generateTeamsMinimizingRepeats(
|
|
players: Player[],
|
|
partnershipFreq: Map<string, number>
|
|
): Team[] {
|
|
const teams: Team[] = []
|
|
const used = new Set<number>()
|
|
|
|
// Sort players by number of partnerships (least partnered first)
|
|
const playersWithPartnershipCount = players.map(p => {
|
|
let count = 0
|
|
for (const [key, freq] of partnershipFreq) {
|
|
const [id1, id2] = key.split('-').map(Number)
|
|
if (id1 === p.id || id2 === p.id) {
|
|
count += freq
|
|
}
|
|
}
|
|
return { player: p, partnerships: count }
|
|
})
|
|
|
|
playersWithPartnershipCount.sort((a, b) => a.partnerships - b.partnerships)
|
|
|
|
// Greedy algorithm: pair least-partnered players first
|
|
for (let i = 0; i < playersWithPartnershipCount.length; i++) {
|
|
if (used.has(playersWithPartnershipCount[i].player.id)) continue
|
|
|
|
let bestPartner = -1
|
|
let bestScore = Infinity
|
|
|
|
for (let j = i + 1; j < playersWithPartnershipCount.length; j++) {
|
|
if (used.has(playersWithPartnershipCount[j].player.id)) continue
|
|
|
|
const key = [
|
|
playersWithPartnershipCount[i].player.id,
|
|
playersWithPartnershipCount[j].player.id
|
|
].sort().join('-')
|
|
|
|
const freq = partnershipFreq.get(key) || 0
|
|
if (freq < bestScore) {
|
|
bestScore = freq
|
|
bestPartner = j
|
|
}
|
|
}
|
|
|
|
if (bestPartner !== -1) {
|
|
teams.push({
|
|
player1Id: playersWithPartnershipCount[i].player.id,
|
|
player2Id: playersWithPartnershipCount[bestPartner].player.id,
|
|
teamName: `${playersWithPartnershipCount[i].player.name} & ${playersWithPartnershipCount[bestPartner].player.name}`,
|
|
})
|
|
used.add(playersWithPartnershipCount[i].player.id)
|
|
used.add(playersWithPartnershipCount[bestPartner].player.id)
|
|
}
|
|
}
|
|
|
|
return teams
|
|
}
|
|
|
|
/**
|
|
* Generate even teams while avoiding repeat partnerships
|
|
*/
|
|
function generateEvenTeamsAvoidingRepeats(
|
|
players: Player[],
|
|
partnershipFreq: Map<string, number>
|
|
): Team[] {
|
|
// Start with even teams
|
|
const baseTeams = generateEvenTeams(players)
|
|
|
|
// Try to improve by swapping to reduce repeat partnerships
|
|
return optimizeTeamsForRepeats(baseTeams, players, partnershipFreq)
|
|
}
|
|
|
|
/**
|
|
* Generate ELO-based teams while avoiding repeat partnerships
|
|
*/
|
|
function generateELOBasedTeamsAvoidingRepeats(
|
|
players: Player[],
|
|
partnershipFreq: Map<string, number>
|
|
): Team[] {
|
|
// Start with ELO-based teams
|
|
const baseTeams = generateELOBasedTeams(players)
|
|
|
|
// Try to improve by swapping to reduce repeat partnerships
|
|
return optimizeTeamsForRepeats(baseTeams, players, partnershipFreq)
|
|
}
|
|
|
|
/**
|
|
* Optimize teams by swapping players to reduce repeat partnerships
|
|
*/
|
|
function optimizeTeamsForRepeats(
|
|
teams: Team[],
|
|
players: Player[],
|
|
partnershipFreq: Map<string, number>
|
|
): Team[] {
|
|
if (teams.length < 2) return teams
|
|
|
|
let improved = true
|
|
let iterations = 0
|
|
const maxIterations = 100
|
|
|
|
while (improved && iterations < maxIterations) {
|
|
improved = false
|
|
iterations++
|
|
|
|
for (let i = 0; i < teams.length; i++) {
|
|
for (let j = i + 1; j < teams.length; j++) {
|
|
// Try swapping player1 of team i with player1 of team j
|
|
const newTeams = [...teams]
|
|
const temp = newTeams[i].player1Id
|
|
newTeams[i] = { ...newTeams[i], player1Id: newTeams[j].player1Id }
|
|
newTeams[j] = { ...newTeams[j], player1Id: temp }
|
|
|
|
// Calculate current frequency
|
|
const currentFreq = calculateTeamFrequency(teams[i], partnershipFreq) +
|
|
calculateTeamFrequency(teams[j], partnershipFreq)
|
|
|
|
// Calculate new frequency
|
|
const newFreq = calculateTeamFrequency(newTeams[i], partnershipFreq) +
|
|
calculateTeamFrequency(newTeams[j], partnershipFreq)
|
|
|
|
if (newFreq < currentFreq) {
|
|
teams = newTeams
|
|
improved = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return teams
|
|
}
|
|
|
|
/**
|
|
* Calculate partnership frequency for a single team
|
|
*/
|
|
function calculateTeamFrequency(
|
|
team: Team,
|
|
partnershipFreq: Map<string, number>
|
|
): number {
|
|
const key = [team.player1Id, team.player2Id].sort().join('-')
|
|
return partnershipFreq.get(key) || 0
|
|
}
|