Files
euchre_camp/src/lib/team-generator.ts
T
david c141fc6dd7 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
2026-04-26 16:44:47 -07:00

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
}