From c141fc6dd73c1e6f99a024c04775e137725716fc Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 3 Apr 2026 19:26:28 -0700 Subject: [PATCH] feat: add team generator library with multiple strategies 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 --- src/lib/team-generator.ts | 407 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 src/lib/team-generator.ts diff --git a/src/lib/team-generator.ts b/src/lib/team-generator.ts new file mode 100644 index 0000000..55771aa --- /dev/null +++ b/src/lib/team-generator.ts @@ -0,0 +1,407 @@ +/** + * 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 { + const frequency = new Map() + + 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 +): Team[] { + const teams: Team[] = [] + const used = new Set() + + // 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 +): 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 +): 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 +): 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 +): number { + const key = [team.player1Id, team.player2Id].sort().join('-') + return partnershipFreq.get(key) || 0 +}