feat: add round-robin schedule generator

Implement circle-method algorithm for generating round-robin tournament
schedules. Handles both even and odd team counts with bye rounds.

Includes unit tests for algorithm correctness, input validation, and
expected round/matchup calculations.
This commit is contained in:
2026-04-02 00:57:49 -07:00
parent 3d87f3e1dc
commit df856c62df
2 changed files with 277 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
export interface MatchupPairing {
team1Id: number
team2Id: number
}
export interface RoundSchedule {
roundNumber: number
matchups: MatchupPairing[]
}
/**
* Generate a round-robin schedule using the circle method.
*
* For N teams, produces N-1 rounds where each team plays every other
* team exactly once. If N is odd, a "bye" is added internally so one
* team sits out each round (the bye matchup is excluded from output).
*
* @param teamIds - Array of team IDs to schedule
* @returns Array of rounds, each containing matchup pairings
*/
export function generateRoundRobin(teamIds: number[]): RoundSchedule[] {
if (teamIds.length < 2) {
return []
}
// Use circle method: fix first team, rotate the rest
// If odd number of teams, add a sentinel for byes
const hasOddTeams = teamIds.length % 2 !== 0
const workingTeams = hasOddTeams ? [...teamIds, -1] : [...teamIds]
const n = workingTeams.length
const numRounds = n - 1
const matchupsPerRound = n / 2
const rounds: RoundSchedule[] = []
for (let round = 0; round < numRounds; round++) {
const matchups: MatchupPairing[] = []
for (let i = 0; i < matchupsPerRound; i++) {
const team1Idx = i
const team2Idx = n - 1 - i
const team1Id = workingTeams[team1Idx]
const team2Id = workingTeams[team2Idx]
// Skip bye matchups (where either team is the sentinel -1)
if (team1Id !== -1 && team2Id !== -1) {
matchups.push({ team1Id, team2Id })
}
}
rounds.push({ roundNumber: round + 1, matchups })
// Rotate all teams except the first one (clockwise)
// Move last element to position 1
const last = workingTeams.pop()!
workingTeams.splice(1, 0, last)
}
return rounds
}
/**
* Validate that a set of team IDs can be scheduled.
*/
export function validateScheduleInput(teamIds: number[]): {
valid: boolean
error?: string
} {
if (teamIds.length < 2) {
return { valid: false, error: "At least 2 teams are required to generate a schedule" }
}
const uniqueIds = new Set(teamIds)
if (uniqueIds.size !== teamIds.length) {
return { valid: false, error: "Duplicate team IDs found" }
}
return { valid: true }
}
/**
* Calculate the expected number of rounds for N teams.
*/
export function expectedRounds(teamCount: number): number {
if (teamCount < 2) return 0
return teamCount % 2 === 0 ? teamCount - 1 : teamCount
}
/**
* Calculate the expected number of total matchups for N teams.
*/
export function expectedMatchups(teamCount: number): number {
if (teamCount < 2) return 0
return (teamCount * (teamCount - 1)) / 2
}