238 lines
6.9 KiB
TypeScript
238 lines
6.9 KiB
TypeScript
import { db } from '../db';
|
|
import { brackets } from '../db/schema/brackets';
|
|
import { rounds } from '../db/schema/rounds';
|
|
import { matches, type Match } from '../db/schema/matches';
|
|
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
|
|
import { BaseTournamentGenerator } from './types';
|
|
import type { RoundInsert } from '../db/schema/rounds';
|
|
import { v7 } from 'uuid';
|
|
import { eq } from 'drizzle-orm';
|
|
import { fields } from '../db/schema/fields';
|
|
import type { Team } from '../db/schema/teams';
|
|
|
|
export class DoubleRoundRobinGenerator extends BaseTournamentGenerator {
|
|
getMode(): SchedulingMode {
|
|
return SchedulingMode.double_round_robin;
|
|
}
|
|
|
|
/**
|
|
* Generate a single main bracket for double round robin
|
|
*/
|
|
async generateBrackets(): Promise<string[]> {
|
|
const [bracket] = await db
|
|
.insert(brackets)
|
|
.values({
|
|
id: v7(),
|
|
name: 'Main Bracket',
|
|
bracketType: BracketType.MAIN,
|
|
position: 1,
|
|
isActive: true,
|
|
competition_id: this.competitionId,
|
|
scheduling_mode: SchedulingMode.double_round_robin
|
|
})
|
|
.returning();
|
|
|
|
return [bracket.id];
|
|
}
|
|
|
|
/**
|
|
* Generate all rounds for a double round robin tournament
|
|
* In double round robin, each team plays against every other team twice
|
|
* (once home, once away)
|
|
*/
|
|
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
|
|
// Number of rounds needed = 2 * (n-1) where n is the number of teams
|
|
const totalRounds = 2 * (this.teamsCount - 1);
|
|
const createdRounds: RoundInsert[] = [];
|
|
|
|
for (let i = 0; i < totalRounds; i++) {
|
|
const roundNumber = i + 1;
|
|
const roundName = `Round ${roundNumber}`;
|
|
|
|
// Each round has floor(n/2) matches
|
|
const matchesInRound = Math.floor(this.teamsCount / 2);
|
|
|
|
const roundData: RoundInsert = {
|
|
name: roundName,
|
|
round_number: roundNumber,
|
|
nb_matches: matchesInRound,
|
|
bracket_id: bracketId
|
|
};
|
|
|
|
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
|
|
createdRounds.push(insertedRound);
|
|
}
|
|
|
|
return createdRounds;
|
|
}
|
|
|
|
/**
|
|
* Generate matches for a double round robin tournament
|
|
* This extends the round robin algorithm to create two sets of matches
|
|
* with home/away teams reversed in the second half
|
|
*/
|
|
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
|
|
// Get the round
|
|
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
|
|
|
|
if (!round) {
|
|
throw new Error(`Round not found: ${roundId}`);
|
|
}
|
|
|
|
if (!round.bracket_id) {
|
|
throw new Error(`Round ${roundId} has no bracket`);
|
|
}
|
|
|
|
// Get the bracket
|
|
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
|
|
|
|
if (!bracket) {
|
|
throw new Error(`Bracket not found for round: ${roundId}`);
|
|
}
|
|
|
|
// Default field (could be enhanced to assign different fields)
|
|
const [defaultField] = await db
|
|
.select()
|
|
.from(fields)
|
|
.where(eq(fields.competition_id, this.competitionId))
|
|
.limit(1);
|
|
|
|
if (!defaultField) {
|
|
throw new Error('No field available for matches');
|
|
}
|
|
|
|
// Generate pairings using the circle method
|
|
const singleRoundRobinRounds = this.teamsCount - 1;
|
|
const isSecondHalf = round.round_number > singleRoundRobinRounds;
|
|
|
|
// For second half, we need to flip home/away teams
|
|
const effectiveRoundNumber = isSecondHalf
|
|
? round.round_number - singleRoundRobinRounds
|
|
: round.round_number;
|
|
|
|
// Generate pairings for this round
|
|
const pairings = this.generateRoundRobinPairings(teams, effectiveRoundNumber);
|
|
|
|
// Create match objects
|
|
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
|
|
const now = new Date();
|
|
const endTime = new Date(now);
|
|
endTime.setHours(endTime.getHours() + 1);
|
|
|
|
// For the second half of matches, swap team1 and team2 to reverse home/away
|
|
const [team1Id, team2Id] = isSecondHalf
|
|
? [pairing.team2Id, pairing.team1Id]
|
|
: [pairing.team1Id, pairing.team2Id];
|
|
|
|
return {
|
|
id: v7(),
|
|
match_number: index + 1,
|
|
team1_id: team1Id,
|
|
team2_id: team2Id,
|
|
start_date: now,
|
|
end_date: endTime,
|
|
position: index + 1,
|
|
table: index + 1, // Table assignment
|
|
round_id: roundId,
|
|
bracket_id: bracket.id,
|
|
field_id: defaultField.id,
|
|
score1: 0,
|
|
score2: 0,
|
|
status: MatchStatus.PENDING,
|
|
winner_id: null,
|
|
created_at: now,
|
|
updated_at: null,
|
|
deleted_at: null
|
|
};
|
|
});
|
|
|
|
// Insert matches and return them
|
|
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
|
|
return createdMatches;
|
|
}
|
|
|
|
/**
|
|
* In double round robin, there's no "next round" generation based on results
|
|
* All rounds are created at the beginning
|
|
*/
|
|
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
|
|
// Get the previous round
|
|
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
|
|
|
|
if (!previousRound) {
|
|
throw new Error(`Previous round not found: ${previousRoundId}`);
|
|
}
|
|
|
|
// Get all rounds in the bracket
|
|
const allRounds = await db
|
|
.select()
|
|
.from(rounds)
|
|
.where(eq(rounds.bracket_id, bracketId))
|
|
.orderBy(rounds.round_number);
|
|
|
|
// Check if this was the final round
|
|
if (previousRound.round_number === allRounds.length) {
|
|
return null; // Tournament is complete
|
|
}
|
|
|
|
// Find the next round
|
|
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
|
|
|
|
if (!nextRound) {
|
|
throw new Error(`Next round not found for round ${previousRound.round_number}`);
|
|
}
|
|
|
|
return nextRound;
|
|
}
|
|
|
|
/**
|
|
* Generate pairings for a round robin tournament using the circle method
|
|
* In this method, one team stays fixed (team at index 0) and others rotate clockwise
|
|
*/
|
|
private generateRoundRobinPairings(teams: Team[], roundNumber: number) {
|
|
// If odd number of teams, add a "bye" team
|
|
if (teams.length % 2 !== 0) {
|
|
teams.push({
|
|
id: '00000000-0000-0000-0000-000000000000',
|
|
name: '',
|
|
created_at: new Date(Date.now()),
|
|
updated_at: null,
|
|
deleted_at: null,
|
|
competition_id: ''
|
|
}); // Bye team ID
|
|
}
|
|
|
|
const n = teams.length;
|
|
const pairings: { team1Id: string; team2Id: string }[] = [];
|
|
|
|
// Create a copy of the teams array that we can manipulate
|
|
const teamsForRound = [...teams];
|
|
|
|
// Rotate the teams (except the first one) based on the round number
|
|
// For round 1, use teams as is
|
|
// For subsequent rounds, rotate teams[1...n-1] clockwise
|
|
if (roundNumber > 1) {
|
|
// Apply rotation (roundNumber - 1) times
|
|
for (let i = 0; i < roundNumber - 1; i++) {
|
|
const lastTeam = teamsForRound.pop()!;
|
|
teamsForRound.splice(1, 0, lastTeam);
|
|
}
|
|
}
|
|
|
|
// Create pairings for this round
|
|
for (let i = 0; i < n / 2; i++) {
|
|
// Match teams[i] with teams[n-1-i]
|
|
pairings.push({
|
|
team1Id: teamsForRound[i].id,
|
|
team2Id: teamsForRound[n - 1 - i].id
|
|
});
|
|
}
|
|
|
|
// Filter out pairings with the bye team
|
|
return pairings.filter(
|
|
(pairing) =>
|
|
pairing.team1Id !== '00000000-0000-0000-0000-000000000000' &&
|
|
pairing.team2Id !== '00000000-0000-0000-0000-000000000000'
|
|
);
|
|
}
|
|
}
|