feat: add tournaments and validation and security
This commit is contained in:
242
src/lib/server/tournament/bracket.test.ts
Normal file
242
src/lib/server/tournament/bracket.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
standardBracketSeeding,
|
||||
nextPowerOf2,
|
||||
generateSingleElimBracket,
|
||||
generateDoubleElimBracket,
|
||||
generateGroupAssignments,
|
||||
generateRoundRobinPairings,
|
||||
generateGroupStageMatches
|
||||
} from './bracket';
|
||||
|
||||
describe('nextPowerOf2', () => {
|
||||
it('returns correct values', () => {
|
||||
expect(nextPowerOf2(1)).toBe(1);
|
||||
expect(nextPowerOf2(2)).toBe(2);
|
||||
expect(nextPowerOf2(3)).toBe(4);
|
||||
expect(nextPowerOf2(5)).toBe(8);
|
||||
expect(nextPowerOf2(8)).toBe(8);
|
||||
expect(nextPowerOf2(9)).toBe(16);
|
||||
expect(nextPowerOf2(17)).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('standardBracketSeeding', () => {
|
||||
it('returns [1, 2] for size 2', () => {
|
||||
expect(standardBracketSeeding(2)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('returns correct seeding for size 4', () => {
|
||||
const seeds = standardBracketSeeding(4);
|
||||
expect(seeds).toEqual([1, 4, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns correct seeding for size 8', () => {
|
||||
const seeds = standardBracketSeeding(8);
|
||||
// 1v8, 4v5, 2v7, 3v6
|
||||
expect(seeds).toEqual([1, 8, 4, 5, 2, 7, 3, 6]);
|
||||
});
|
||||
|
||||
it('ensures seed 1 and 2 are on opposite halves for size 16', () => {
|
||||
const seeds = standardBracketSeeding(16);
|
||||
expect(seeds).toHaveLength(16);
|
||||
// seed 1 should be in top half, seed 2 in bottom half
|
||||
const topHalf = seeds.slice(0, 8);
|
||||
const bottomHalf = seeds.slice(8);
|
||||
expect(topHalf).toContain(1);
|
||||
expect(bottomHalf).toContain(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSingleElimBracket', () => {
|
||||
it('generates correct bracket for 4 teams', () => {
|
||||
const slots = generateSingleElimBracket(4);
|
||||
// 4 teams, 2 rounds, 3 total matches
|
||||
expect(slots).toHaveLength(3);
|
||||
|
||||
const round0 = slots.filter((s) => s.roundIndex === 0);
|
||||
const round1 = slots.filter((s) => s.roundIndex === 1);
|
||||
expect(round0).toHaveLength(2);
|
||||
expect(round1).toHaveLength(1);
|
||||
|
||||
// finals has no winnerGoesTo
|
||||
expect(round1[0].winnerGoesTo).toBeNull();
|
||||
|
||||
// no byes
|
||||
expect(slots.every((s) => !s.isBye)).toBe(true);
|
||||
});
|
||||
|
||||
it('generates correct bracket for 8 teams', () => {
|
||||
const slots = generateSingleElimBracket(8);
|
||||
// 8 teams, 3 rounds, 7 matches
|
||||
expect(slots).toHaveLength(7);
|
||||
|
||||
const round0 = slots.filter((s) => s.roundIndex === 0);
|
||||
expect(round0).toHaveLength(4);
|
||||
// all first round matches have seeds assigned
|
||||
for (const slot of round0) {
|
||||
expect(slot.team1Seed).not.toBeNull();
|
||||
expect(slot.team2Seed).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('handles 6 teams with BYEs', () => {
|
||||
const slots = generateSingleElimBracket(6);
|
||||
// bracket size = 8, so 7 matches
|
||||
expect(slots).toHaveLength(7);
|
||||
|
||||
const byes = slots.filter((s) => s.isBye);
|
||||
// 2 byes (seeds 7 and 8 don't exist)
|
||||
expect(byes).toHaveLength(2);
|
||||
|
||||
// verify top 2 seeds get byes
|
||||
const byeSeeds = byes.flatMap((s) => [s.team1Seed, s.team2Seed]);
|
||||
expect(byeSeeds).toContain(1);
|
||||
expect(byeSeeds).toContain(2);
|
||||
});
|
||||
|
||||
it('generates correct progression links', () => {
|
||||
const slots = generateSingleElimBracket(4);
|
||||
const round0 = slots.filter((s) => s.roundIndex === 0);
|
||||
|
||||
// match 0 winner goes to round 1, position 0, slot 1
|
||||
expect(round0[0].winnerGoesTo).toEqual({
|
||||
roundIndex: 1,
|
||||
bracketPosition: 0,
|
||||
slot: 1
|
||||
});
|
||||
// match 1 winner goes to round 1, position 0, slot 2
|
||||
expect(round0[1].winnerGoesTo).toEqual({
|
||||
roundIndex: 1,
|
||||
bracketPosition: 0,
|
||||
slot: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('all bracket types are winners', () => {
|
||||
const slots = generateSingleElimBracket(8);
|
||||
expect(slots.every((s) => s.bracketType === 'winners')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDoubleElimBracket', () => {
|
||||
it('generates correct bracket for 4 teams', () => {
|
||||
const slots = generateDoubleElimBracket(4);
|
||||
const wb = slots.filter((s) => s.bracketType === 'winners');
|
||||
const lb = slots.filter((s) => s.bracketType === 'losers');
|
||||
const gf = slots.filter((s) => s.bracketType === 'grand_final');
|
||||
|
||||
// WB: 2 rounds (2 + 1 = 3 matches)
|
||||
expect(wb).toHaveLength(3);
|
||||
// LB: 2*(2-1) = 2 rounds
|
||||
expect(lb.length).toBeGreaterThan(0);
|
||||
// GF: 2 matches (GF1 + GF2)
|
||||
expect(gf).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('generates correct bracket for 8 teams', () => {
|
||||
const slots = generateDoubleElimBracket(8);
|
||||
const wb = slots.filter((s) => s.bracketType === 'winners');
|
||||
const lb = slots.filter((s) => s.bracketType === 'losers');
|
||||
const gf = slots.filter((s) => s.bracketType === 'grand_final');
|
||||
|
||||
// WB: 3 rounds (4 + 2 + 1 = 7 matches)
|
||||
expect(wb).toHaveLength(7);
|
||||
// GF: always 2
|
||||
expect(gf).toHaveLength(2);
|
||||
// LB: 2*(3-1) = 4 rounds
|
||||
const lbRounds = new Set(lb.map((s) => s.roundIndex));
|
||||
expect(lbRounds.size).toBe(4);
|
||||
});
|
||||
|
||||
it('WB losers have loserGoesTo set', () => {
|
||||
const slots = generateDoubleElimBracket(8);
|
||||
const wb = slots.filter((s) => s.bracketType === 'winners');
|
||||
// all WB matches should have loserGoesTo pointing to LB
|
||||
for (const slot of wb) {
|
||||
expect(slot.loserGoesTo).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('GF1 links to GF2', () => {
|
||||
const slots = generateDoubleElimBracket(8);
|
||||
const gf1 = slots.find((s) => s.bracketType === 'grand_final' && s.roundIndex === 0);
|
||||
expect(gf1).toBeDefined();
|
||||
expect(gf1!.winnerGoesTo).toEqual({
|
||||
roundIndex: 1,
|
||||
bracketPosition: 0,
|
||||
slot: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateGroupAssignments', () => {
|
||||
it('snake seeds correctly for 8 teams, 2 groups', () => {
|
||||
const groups = generateGroupAssignments(8, 2);
|
||||
expect(groups).toHaveLength(2);
|
||||
// Group A: 1, 4, 5, 8
|
||||
expect(groups[0].teamSeeds).toEqual([1, 4, 5, 8]);
|
||||
// Group B: 2, 3, 6, 7
|
||||
expect(groups[1].teamSeeds).toEqual([2, 3, 6, 7]);
|
||||
});
|
||||
|
||||
it('snake seeds correctly for 16 teams, 4 groups', () => {
|
||||
const groups = generateGroupAssignments(16, 4);
|
||||
expect(groups).toHaveLength(4);
|
||||
// Group A: 1, 8, 9, 16
|
||||
expect(groups[0].teamSeeds).toEqual([1, 8, 9, 16]);
|
||||
// Group B: 2, 7, 10, 15
|
||||
expect(groups[1].teamSeeds).toEqual([2, 7, 10, 15]);
|
||||
});
|
||||
|
||||
it('handles uneven groups', () => {
|
||||
const groups = generateGroupAssignments(7, 2);
|
||||
// one group gets 4, other gets 3
|
||||
const total = groups.reduce((sum, g) => sum + g.teamSeeds.length, 0);
|
||||
expect(total).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRoundRobinPairings', () => {
|
||||
it('generates correct number of pairings for 4 teams', () => {
|
||||
const pairings = generateRoundRobinPairings([1, 2, 3, 4]);
|
||||
// C(4,2) = 6 matches
|
||||
expect(pairings).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('generates correct number of pairings for 3 teams', () => {
|
||||
const pairings = generateRoundRobinPairings([1, 2, 3]);
|
||||
// C(3,2) = 3 matches
|
||||
expect(pairings).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('every team plays every other team exactly once', () => {
|
||||
const teams = [1, 2, 3, 4];
|
||||
const pairings = generateRoundRobinPairings(teams);
|
||||
|
||||
for (const a of teams) {
|
||||
for (const b of teams) {
|
||||
if (a >= b) continue;
|
||||
const found = pairings.filter(
|
||||
(p) =>
|
||||
(p.team1Seed === a && p.team2Seed === b) || (p.team1Seed === b && p.team2Seed === a)
|
||||
);
|
||||
expect(found).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateGroupStageMatches', () => {
|
||||
it('generates groups and all round-robin matches', () => {
|
||||
const { groups, matches } = generateGroupStageMatches(8, 2);
|
||||
expect(groups).toHaveLength(2);
|
||||
// 4 teams per group, C(4,2) = 6 matches per group, 12 total
|
||||
expect(matches).toHaveLength(12);
|
||||
// each match has a groupIndex
|
||||
const group0Matches = matches.filter((m) => m.groupIndex === 0);
|
||||
const group1Matches = matches.filter((m) => m.groupIndex === 1);
|
||||
expect(group0Matches).toHaveLength(6);
|
||||
expect(group1Matches).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
397
src/lib/server/tournament/bracket.ts
Normal file
397
src/lib/server/tournament/bracket.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Pure bracket generation logic - no DB, no side effects.
|
||||
* Generates the structure of tournament brackets for single elim, double elim, and group stages.
|
||||
*/
|
||||
|
||||
export interface BracketSlot {
|
||||
roundIndex: number;
|
||||
bracketPosition: number;
|
||||
team1Seed: number | null; // null = TBD (filled by previous match winner)
|
||||
team2Seed: number | null;
|
||||
winnerGoesTo: { roundIndex: number; bracketPosition: number; slot: 1 | 2 } | null;
|
||||
loserGoesTo: { roundIndex: number; bracketPosition: number; slot: 1 | 2 } | null;
|
||||
bracketType: 'winners' | 'losers' | 'grand_final';
|
||||
isBye: boolean;
|
||||
}
|
||||
|
||||
export interface GroupAssignment {
|
||||
groupIndex: number;
|
||||
teamSeeds: number[];
|
||||
}
|
||||
|
||||
export interface GroupMatch {
|
||||
groupIndex: number;
|
||||
team1Seed: number;
|
||||
team2Seed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard tournament seeding placement for a bracket of given size.
|
||||
* Ensures seed 1 and 2 are on opposite sides, high seeds are spread out.
|
||||
* Returns array of seed numbers in bracket order (position 0 = top of bracket).
|
||||
*/
|
||||
export function standardBracketSeeding(size: number): number[] {
|
||||
if (size < 2) return [1];
|
||||
if (size === 2) return [1, 2];
|
||||
|
||||
const result: number[] = new Array(size);
|
||||
result[0] = 1;
|
||||
result[1] = 2;
|
||||
|
||||
// iteratively place seeds by splitting the bracket
|
||||
for (let round = 1; round < Math.log2(size); round++) {
|
||||
const count = 1 << round; // seeds already placed: 2, 4, 8...
|
||||
const nextCount = count * 2;
|
||||
const temp: number[] = new Array(nextCount);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
temp[i * 2] = result[i];
|
||||
temp[i * 2 + 1] = nextCount + 1 - result[i];
|
||||
}
|
||||
|
||||
for (let i = 0; i < nextCount; i++) {
|
||||
result[i] = temp[i];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the smallest power of 2 >= n
|
||||
*/
|
||||
export function nextPowerOf2(n: number): number {
|
||||
let p = 1;
|
||||
while (p < n) p *= 2;
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single elimination bracket.
|
||||
*/
|
||||
export function generateSingleElimBracket(teamCount: number): BracketSlot[] {
|
||||
const bracketSize = nextPowerOf2(teamCount);
|
||||
const totalRounds = Math.log2(bracketSize);
|
||||
const seeding = standardBracketSeeding(bracketSize);
|
||||
const slots: BracketSlot[] = [];
|
||||
|
||||
// generate all rounds
|
||||
for (let round = 0; round < totalRounds; round++) {
|
||||
const matchesInRound = bracketSize / (2 << round);
|
||||
|
||||
for (let pos = 0; pos < matchesInRound; pos++) {
|
||||
const isFirstRound = round === 0;
|
||||
const isFinalRound = round === totalRounds - 1;
|
||||
|
||||
let team1Seed: number | null = null;
|
||||
let team2Seed: number | null = null;
|
||||
let isBye = false;
|
||||
|
||||
if (isFirstRound) {
|
||||
const idx1 = pos * 2;
|
||||
const idx2 = pos * 2 + 1;
|
||||
team1Seed = seeding[idx1];
|
||||
team2Seed = seeding[idx2];
|
||||
// if either seed > teamCount, it's a BYE
|
||||
isBye = team1Seed > teamCount || team2Seed > teamCount;
|
||||
}
|
||||
|
||||
const winnerGoesTo = isFinalRound
|
||||
? null
|
||||
: {
|
||||
roundIndex: round + 1,
|
||||
bracketPosition: Math.floor(pos / 2),
|
||||
slot: ((pos % 2) + 1) as 1 | 2
|
||||
};
|
||||
|
||||
slots.push({
|
||||
roundIndex: round,
|
||||
bracketPosition: pos,
|
||||
team1Seed,
|
||||
team2Seed,
|
||||
winnerGoesTo,
|
||||
loserGoesTo: null,
|
||||
bracketType: 'winners',
|
||||
isBye
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a double elimination bracket.
|
||||
* Winners bracket + losers bracket + grand finals (with potential bracket reset).
|
||||
*/
|
||||
export function generateDoubleElimBracket(teamCount: number): BracketSlot[] {
|
||||
const bracketSize = nextPowerOf2(teamCount);
|
||||
const wbRounds = Math.log2(bracketSize);
|
||||
const lbRounds = 2 * (wbRounds - 1);
|
||||
const seeding = standardBracketSeeding(bracketSize);
|
||||
const slots: BracketSlot[] = [];
|
||||
|
||||
// helper to find a slot by type + round + position
|
||||
const findSlot = (type: string, round: number, pos: number) =>
|
||||
slots.find(
|
||||
(s) => s.bracketType === type && s.roundIndex === round && s.bracketPosition === pos
|
||||
);
|
||||
|
||||
// ── Generate Losers Bracket first (so we can reference it from WB) ──
|
||||
// LB has alternating "feed-in" and "reduction" rounds
|
||||
// LB round 0: WB R0 losers play each other (bracketSize/4 matches)
|
||||
// LB round 1: LB R0 winners vs WB R1 losers (feed-in)
|
||||
// LB round 2: LB R1 winners play each other (reduction)
|
||||
// LB round 3: LB R2 winners vs WB R2 losers (feed-in)
|
||||
// ... and so on
|
||||
|
||||
for (let lbRound = 0; lbRound < lbRounds; lbRound++) {
|
||||
const isFeedIn = lbRound % 2 === 1;
|
||||
// number of matches halves every 2 rounds
|
||||
const matchesInRound = bracketSize / (2 << Math.floor((lbRound + 1) / 2));
|
||||
|
||||
for (let pos = 0; pos < matchesInRound; pos++) {
|
||||
const isLastLbRound = lbRound === lbRounds - 1;
|
||||
|
||||
const winnerGoesTo = isLastLbRound
|
||||
? null // will be linked to GF later
|
||||
: {
|
||||
roundIndex: lbRound + 1,
|
||||
bracketPosition: isFeedIn ? pos : Math.floor(pos / 2),
|
||||
slot: (isFeedIn ? 1 : (pos % 2) + 1) as 1 | 2
|
||||
};
|
||||
|
||||
slots.push({
|
||||
roundIndex: lbRound,
|
||||
bracketPosition: pos,
|
||||
team1Seed: null,
|
||||
team2Seed: null,
|
||||
winnerGoesTo,
|
||||
loserGoesTo: null, // losers bracket losers are eliminated
|
||||
bracketType: 'losers',
|
||||
isBye: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate Winners Bracket ──
|
||||
for (let round = 0; round < wbRounds; round++) {
|
||||
const matchesInRound = bracketSize / (2 << round);
|
||||
|
||||
for (let pos = 0; pos < matchesInRound; pos++) {
|
||||
const isFirstRound = round === 0;
|
||||
const isLastWbRound = round === wbRounds - 1;
|
||||
|
||||
let team1Seed: number | null = null;
|
||||
let team2Seed: number | null = null;
|
||||
let isBye = false;
|
||||
|
||||
if (isFirstRound) {
|
||||
const idx1 = pos * 2;
|
||||
const idx2 = pos * 2 + 1;
|
||||
team1Seed = seeding[idx1];
|
||||
team2Seed = seeding[idx2];
|
||||
isBye = team1Seed > teamCount || team2Seed > teamCount;
|
||||
}
|
||||
|
||||
const winnerGoesTo = isLastWbRound
|
||||
? null // will be linked to GF later
|
||||
: {
|
||||
roundIndex: round + 1,
|
||||
bracketPosition: Math.floor(pos / 2),
|
||||
slot: ((pos % 2) + 1) as 1 | 2
|
||||
};
|
||||
|
||||
// losers go to losers bracket
|
||||
// WB R0 losers → LB R0, WB R1 losers → LB R2, WB R2 losers → LB R4, etc.
|
||||
const lbTargetRound = round === 0 ? 0 : round * 2 - 1;
|
||||
// determine position in LB - for feed-in rounds, position maps directly
|
||||
// for the first WB round, losers pair up: WB match 0,1 → LB match 0; WB match 2,3 → LB match 1
|
||||
let lbTargetPos: number;
|
||||
let lbTargetSlot: 1 | 2;
|
||||
|
||||
if (round === 0) {
|
||||
// WB R0 losers pair up in LB R0
|
||||
lbTargetPos = Math.floor(pos / 2);
|
||||
lbTargetSlot = ((pos % 2) + 1) as 1 | 2;
|
||||
} else {
|
||||
// WB R1+ losers enter feed-in rounds as slot 2
|
||||
lbTargetPos = pos;
|
||||
lbTargetSlot = 2;
|
||||
}
|
||||
|
||||
const loserGoesTo = {
|
||||
roundIndex: lbTargetRound,
|
||||
bracketPosition: lbTargetPos,
|
||||
slot: lbTargetSlot
|
||||
};
|
||||
|
||||
slots.push({
|
||||
roundIndex: round,
|
||||
bracketPosition: pos,
|
||||
team1Seed,
|
||||
team2Seed,
|
||||
winnerGoesTo,
|
||||
loserGoesTo,
|
||||
bracketType: 'winners',
|
||||
isBye
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grand Finals ──
|
||||
// GF1: WB winner vs LB winner
|
||||
slots.push({
|
||||
roundIndex: 0,
|
||||
bracketPosition: 0,
|
||||
team1Seed: null, // WB winner
|
||||
team2Seed: null, // LB winner
|
||||
winnerGoesTo: null, // conditionally goes to GF2
|
||||
loserGoesTo: null,
|
||||
bracketType: 'grand_final',
|
||||
isBye: false
|
||||
});
|
||||
|
||||
// GF2 (bracket reset): only played if LB winner wins GF1
|
||||
slots.push({
|
||||
roundIndex: 1,
|
||||
bracketPosition: 0,
|
||||
team1Seed: null,
|
||||
team2Seed: null,
|
||||
winnerGoesTo: null,
|
||||
loserGoesTo: null,
|
||||
bracketType: 'grand_final',
|
||||
isBye: false
|
||||
});
|
||||
|
||||
// ── Wire up WB finals → GF1, LB finals → GF1 ──
|
||||
const wbFinal = slots.find((s) => s.bracketType === 'winners' && s.roundIndex === wbRounds - 1);
|
||||
if (wbFinal) {
|
||||
wbFinal.winnerGoesTo = { roundIndex: 0, bracketPosition: 0, slot: 1 };
|
||||
}
|
||||
|
||||
const lbFinal = slots.find((s) => s.bracketType === 'losers' && s.roundIndex === lbRounds - 1);
|
||||
if (lbFinal) {
|
||||
lbFinal.winnerGoesTo = { roundIndex: 0, bracketPosition: 0, slot: 2 };
|
||||
}
|
||||
|
||||
// GF1 winner → GF2 (conditionally)
|
||||
const gf1 = slots.find((s) => s.bracketType === 'grand_final' && s.roundIndex === 0);
|
||||
if (gf1) {
|
||||
gf1.winnerGoesTo = { roundIndex: 1, bracketPosition: 0, slot: 1 };
|
||||
gf1.loserGoesTo = { roundIndex: 1, bracketPosition: 0, slot: 2 };
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate group assignments using snake seeding.
|
||||
* e.g. 4 groups, 16 teams:
|
||||
* Group A: 1, 8, 9, 16
|
||||
* Group B: 2, 7, 10, 15
|
||||
* Group C: 3, 6, 11, 14
|
||||
* Group D: 4, 5, 12, 13
|
||||
*/
|
||||
export function generateGroupAssignments(teamCount: number, groupCount: number): GroupAssignment[] {
|
||||
const groups: GroupAssignment[] = Array.from({ length: groupCount }, (_, i) => ({
|
||||
groupIndex: i,
|
||||
teamSeeds: []
|
||||
}));
|
||||
|
||||
for (let seed = 1; seed <= teamCount; seed++) {
|
||||
const row = Math.floor((seed - 1) / groupCount);
|
||||
const col = (seed - 1) % groupCount;
|
||||
// snake: even rows go left→right, odd rows go right→left
|
||||
const groupIdx = row % 2 === 0 ? col : groupCount - 1 - col;
|
||||
groups[groupIdx].teamSeeds.push(seed);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate round-robin pairings for a group of teams.
|
||||
* Uses the circle method to ensure all teams play each other exactly once.
|
||||
*/
|
||||
export function generateRoundRobinPairings(teamSeeds: number[]): GroupMatch[] {
|
||||
const matches: GroupMatch[] = [];
|
||||
const n = teamSeeds.length;
|
||||
|
||||
// for round-robin with n teams, we need n-1 rounds (or n if n is odd, with byes)
|
||||
// using the circle method
|
||||
const teams = [...teamSeeds];
|
||||
if (teams.length % 2 !== 0) {
|
||||
teams.push(-1); // -1 = BYE
|
||||
}
|
||||
|
||||
const count = teams.length;
|
||||
const rounds = count - 1;
|
||||
const half = count / 2;
|
||||
|
||||
// fix team[0] and rotate the rest
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
for (let i = 0; i < half; i++) {
|
||||
const home = i === 0 ? teams[0] : teams[count - i];
|
||||
const away = teams[i === 0 ? (count - 1 - round < 1 ? count - 1 : 0) : i];
|
||||
|
||||
// actually let's use a simpler approach
|
||||
}
|
||||
}
|
||||
|
||||
// simpler: just enumerate all unique pairs
|
||||
for (let i = 0; i < teamSeeds.length; i++) {
|
||||
for (let j = i + 1; j < teamSeeds.length; j++) {
|
||||
matches.push({
|
||||
groupIndex: 0, // caller sets this
|
||||
team1Seed: teamSeeds[i],
|
||||
team2Seed: teamSeeds[j]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all group stage matches for a tournament.
|
||||
*/
|
||||
export function generateGroupStageMatches(
|
||||
teamCount: number,
|
||||
groupCount: number
|
||||
): { groups: GroupAssignment[]; matches: GroupMatch[] } {
|
||||
const groups = generateGroupAssignments(teamCount, groupCount);
|
||||
const allMatches: GroupMatch[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const pairings = generateRoundRobinPairings(group.teamSeeds);
|
||||
for (const match of pairings) {
|
||||
allMatches.push({ ...match, groupIndex: group.groupIndex });
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, matches: allMatches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the round name for a single/double elim bracket based on remaining teams.
|
||||
*/
|
||||
export function getRoundName(
|
||||
matchesInRound: number,
|
||||
bracketType: 'winners' | 'losers' | 'grand_final'
|
||||
): string {
|
||||
if (bracketType === 'grand_final') return 'Grand Finals';
|
||||
|
||||
const prefix = bracketType === 'losers' ? 'LB ' : '';
|
||||
|
||||
switch (matchesInRound) {
|
||||
case 1:
|
||||
return `${prefix}Finals`;
|
||||
case 2:
|
||||
return `${prefix}Semifinals`;
|
||||
case 4:
|
||||
return `${prefix}Quarterfinals`;
|
||||
default:
|
||||
return `${prefix}Round of ${matchesInRound * 2}`;
|
||||
}
|
||||
}
|
||||
6
src/lib/server/tournament/index.ts
Normal file
6
src/lib/server/tournament/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './types';
|
||||
export * from './bracket';
|
||||
export * from './lifecycle';
|
||||
export * from './progression';
|
||||
export * from './scheduler';
|
||||
export * from './qualifiers';
|
||||
714
src/lib/server/tournament/lifecycle.ts
Normal file
714
src/lib/server/tournament/lifecycle.ts
Normal file
@@ -0,0 +1,714 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
tournament,
|
||||
tournamentStaff,
|
||||
tournamentRound,
|
||||
tournamentRegistration,
|
||||
tournamentMatch,
|
||||
tournamentGroup,
|
||||
tournamentGroupEntry,
|
||||
match
|
||||
} from '$lib/server/db/schema';
|
||||
import { eq, and, inArray, asc } from 'drizzle-orm';
|
||||
import { log } from '$lib/server/logger';
|
||||
import {
|
||||
TOURNAMENT_STATES,
|
||||
TOURNAMENT_MATCH_STATES,
|
||||
TOURNAMENT_STAFF_ROLES,
|
||||
type TournamentConfig,
|
||||
type TournamentFormat
|
||||
} from './types';
|
||||
import {
|
||||
generateSingleElimBracket,
|
||||
generateDoubleElimBracket,
|
||||
generateGroupAssignments,
|
||||
generateGroupStageMatches,
|
||||
nextPowerOf2,
|
||||
getRoundName,
|
||||
type BracketSlot
|
||||
} from './bracket';
|
||||
import { processByes } from './progression';
|
||||
|
||||
// ── Create Tournament ──────────────────────────────────────────────────
|
||||
|
||||
export async function createTournament(opts: {
|
||||
name: string;
|
||||
description?: string;
|
||||
format: TournamentFormat;
|
||||
config: TournamentConfig;
|
||||
maxSlots: number;
|
||||
bannerUrl?: string;
|
||||
createdBy: string;
|
||||
registrationOpenAt?: Date;
|
||||
registrationCloseAt?: Date;
|
||||
startAt?: Date;
|
||||
}) {
|
||||
const [created] = await db
|
||||
.insert(tournament)
|
||||
.values({
|
||||
name: opts.name,
|
||||
description: opts.description,
|
||||
format: opts.format,
|
||||
config: opts.config,
|
||||
maxSlots: opts.maxSlots,
|
||||
bannerUrl: opts.bannerUrl,
|
||||
createdBy: opts.createdBy,
|
||||
registrationOpenAt: opts.registrationOpenAt,
|
||||
registrationCloseAt: opts.registrationCloseAt,
|
||||
startAt: opts.startAt,
|
||||
state: TOURNAMENT_STATES.DRAFT
|
||||
})
|
||||
.returning();
|
||||
|
||||
// add creator as organizer
|
||||
await db.insert(tournamentStaff).values({
|
||||
tournamentId: created.id,
|
||||
userId: opts.createdBy,
|
||||
role: TOURNAMENT_STAFF_ROLES.ORGANIZER
|
||||
});
|
||||
|
||||
log.tournament.info({ tournamentId: created.id, name: opts.name }, 'tournament created');
|
||||
return created;
|
||||
}
|
||||
|
||||
// ── Open Registration ──────────────────────────────────────────────────
|
||||
|
||||
export async function openRegistration(tournamentId: string) {
|
||||
const t = await getTournamentOrThrow(tournamentId);
|
||||
assertTournamentState(t.state, TOURNAMENT_STATES.DRAFT);
|
||||
|
||||
// validate at least one round exists
|
||||
const rounds = await db.query.tournamentRound.findMany({
|
||||
where: eq(tournamentRound.tournamentId, tournamentId)
|
||||
});
|
||||
if (rounds.length === 0) {
|
||||
throw new Error('Cannot open registration: no rounds configured');
|
||||
}
|
||||
|
||||
await db
|
||||
.update(tournament)
|
||||
.set({
|
||||
state: TOURNAMENT_STATES.REGISTRATION,
|
||||
registrationOpenAt: new Date()
|
||||
})
|
||||
.where(eq(tournament.id, tournamentId));
|
||||
|
||||
log.tournament.info({ tournamentId }, 'registration opened');
|
||||
}
|
||||
|
||||
// ── Close Registration ─────────────────────────────────────────────────
|
||||
|
||||
export async function closeRegistration(tournamentId: string) {
|
||||
const t = await getTournamentOrThrow(tournamentId);
|
||||
assertTournamentState(t.state, TOURNAMENT_STATES.REGISTRATION);
|
||||
|
||||
const config = t.config as TournamentConfig;
|
||||
const nextState = config.qualifiers?.enabled
|
||||
? TOURNAMENT_STATES.QUALIFIERS
|
||||
: TOURNAMENT_STATES.SEEDING;
|
||||
|
||||
await db
|
||||
.update(tournament)
|
||||
.set({
|
||||
state: nextState,
|
||||
registrationCloseAt: new Date()
|
||||
})
|
||||
.where(eq(tournament.id, tournamentId));
|
||||
|
||||
log.tournament.info({ tournamentId, nextState }, 'registration closed');
|
||||
}
|
||||
|
||||
// ── Register for Tournament ────────────────────────────────────────────
|
||||
|
||||
export async function registerForTournament(tournamentId: string, teamId: string, userId: string) {
|
||||
const t = await getTournamentOrThrow(tournamentId);
|
||||
assertTournamentState(t.state, TOURNAMENT_STATES.REGISTRATION);
|
||||
|
||||
// check max slots
|
||||
const registrations = await db.query.tournamentRegistration.findMany({
|
||||
where: and(
|
||||
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
|
||||
)
|
||||
});
|
||||
|
||||
const config = t.config as TournamentConfig;
|
||||
const hasQualifiers = config.qualifiers?.enabled;
|
||||
|
||||
// if no qualifiers, cap at maxSlots. with qualifiers, allow more signups
|
||||
if (!hasQualifiers && registrations.length >= t.maxSlots) {
|
||||
throw new Error('Tournament is full');
|
||||
}
|
||||
|
||||
// check not already registered
|
||||
const existing = await db.query.tournamentRegistration.findFirst({
|
||||
where: and(
|
||||
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||
eq(tournamentRegistration.teamId, teamId)
|
||||
)
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('Already registered for this tournament');
|
||||
}
|
||||
|
||||
const [reg] = await db
|
||||
.insert(tournamentRegistration)
|
||||
.values({
|
||||
tournamentId,
|
||||
teamId,
|
||||
userId,
|
||||
status: 'registered'
|
||||
})
|
||||
.returning();
|
||||
|
||||
log.tournament.info({ tournamentId, teamId }, 'team registered');
|
||||
return reg;
|
||||
}
|
||||
|
||||
// ── Withdraw from Tournament ───────────────────────────────────────────
|
||||
|
||||
export async function withdrawFromTournament(tournamentId: string, teamId: string) {
|
||||
const reg = await db.query.tournamentRegistration.findFirst({
|
||||
where: and(
|
||||
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||
eq(tournamentRegistration.teamId, teamId)
|
||||
)
|
||||
});
|
||||
if (!reg) throw new Error('Not registered for this tournament');
|
||||
|
||||
await db
|
||||
.update(tournamentRegistration)
|
||||
.set({ status: 'withdrawn' })
|
||||
.where(eq(tournamentRegistration.id, reg.id));
|
||||
|
||||
log.tournament.info({ tournamentId, teamId }, 'team withdrew');
|
||||
}
|
||||
|
||||
// ── Finalize Seeding ───────────────────────────────────────────────────
|
||||
|
||||
export async function finalizeSeeding(tournamentId: string) {
|
||||
const t = await getTournamentOrThrow(tournamentId);
|
||||
if (t.state !== TOURNAMENT_STATES.SEEDING && t.state !== TOURNAMENT_STATES.QUALIFIERS) {
|
||||
throw new Error(`Cannot finalize seeding in state ${t.state}`);
|
||||
}
|
||||
|
||||
const registrations = await db.query.tournamentRegistration.findMany({
|
||||
where: and(
|
||||
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
|
||||
),
|
||||
orderBy: [asc(tournamentRegistration.seed), asc(tournamentRegistration.registeredAt)]
|
||||
});
|
||||
|
||||
if (registrations.length < 2) {
|
||||
throw new Error('Need at least 2 teams to generate bracket');
|
||||
}
|
||||
|
||||
// cap to maxSlots if needed (qualifiers should have already reduced)
|
||||
const activeTeams = registrations.slice(0, t.maxSlots);
|
||||
|
||||
// assign seeds if not already set (from qualifiers)
|
||||
for (let i = 0; i < activeTeams.length; i++) {
|
||||
if (!activeTeams[i].seed) {
|
||||
await db
|
||||
.update(tournamentRegistration)
|
||||
.set({ seed: i + 1 })
|
||||
.where(eq(tournamentRegistration.id, activeTeams[i].id));
|
||||
activeTeams[i] = { ...activeTeams[i], seed: i + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// eliminate teams that didn't make the cut
|
||||
const eliminated = registrations.slice(t.maxSlots);
|
||||
for (const reg of eliminated) {
|
||||
await db
|
||||
.update(tournamentRegistration)
|
||||
.set({ status: 'eliminated' })
|
||||
.where(eq(tournamentRegistration.id, reg.id));
|
||||
}
|
||||
|
||||
const format = t.format as TournamentFormat;
|
||||
const config = t.config as TournamentConfig;
|
||||
|
||||
// build seed → teamId map
|
||||
const seedToTeam = new Map<number, string>();
|
||||
for (const reg of activeTeams) {
|
||||
seedToTeam.set(reg.seed!, reg.teamId);
|
||||
}
|
||||
|
||||
if (format === 'groups_bracket' && config.groupStage) {
|
||||
await generateGroupsAndBracket(tournamentId, activeTeams.length, config, seedToTeam);
|
||||
} else if (format === 'double_elim') {
|
||||
await generateDoubleElimFromSeeds(tournamentId, activeTeams.length, seedToTeam);
|
||||
} else {
|
||||
await generateSingleElimFromSeeds(tournamentId, activeTeams.length, seedToTeam);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(tournament)
|
||||
.set({ state: TOURNAMENT_STATES.BRACKET })
|
||||
.where(eq(tournament.id, tournamentId));
|
||||
|
||||
// auto-advance BYE matches
|
||||
await processByes(tournamentId);
|
||||
|
||||
log.tournament.info({ tournamentId, teamCount: activeTeams.length, format }, 'bracket generated');
|
||||
}
|
||||
|
||||
// ── Cancel Tournament ──────────────────────────────────────────────────
|
||||
|
||||
export async function cancelTournament(tournamentId: string) {
|
||||
const t = await getTournamentOrThrow(tournamentId);
|
||||
if (t.state === TOURNAMENT_STATES.FINISHED || t.state === TOURNAMENT_STATES.CANCELLED) {
|
||||
throw new Error(`Tournament is already ${t.state}`);
|
||||
}
|
||||
|
||||
// cancel any active matches
|
||||
const activeMatches = await db.query.tournamentMatch.findMany({
|
||||
where: and(
|
||||
eq(tournamentMatch.tournamentId, tournamentId),
|
||||
inArray(tournamentMatch.state, [
|
||||
TOURNAMENT_MATCH_STATES.SCHEDULED,
|
||||
TOURNAMENT_MATCH_STATES.LIVE
|
||||
])
|
||||
)
|
||||
});
|
||||
|
||||
for (const tm of activeMatches) {
|
||||
if (tm.matchId) {
|
||||
await db
|
||||
.update(match)
|
||||
.set({ state: 'CANCELLED', finishedAt: new Date() })
|
||||
.where(eq(match.id, tm.matchId));
|
||||
}
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED })
|
||||
.where(eq(tournamentMatch.id, tm.id));
|
||||
}
|
||||
|
||||
await db
|
||||
.update(tournament)
|
||||
.set({ state: TOURNAMENT_STATES.CANCELLED, finishedAt: new Date() })
|
||||
.where(eq(tournament.id, tournamentId));
|
||||
|
||||
log.tournament.info({ tournamentId }, 'tournament cancelled');
|
||||
}
|
||||
|
||||
// ── Check Tournament Completion ────────────────────────────────────────
|
||||
|
||||
export async function checkTournamentCompletion(tournamentId: string) {
|
||||
const allMatches = await db.query.tournamentMatch.findMany({
|
||||
where: eq(tournamentMatch.tournamentId, tournamentId)
|
||||
});
|
||||
|
||||
const incomplete = allMatches.filter(
|
||||
(m) => m.state !== TOURNAMENT_MATCH_STATES.FINISHED && m.state !== TOURNAMENT_MATCH_STATES.BYE
|
||||
);
|
||||
|
||||
if (incomplete.length > 0) return false;
|
||||
|
||||
// find the final match winner
|
||||
// for single/double elim, it's the last match with no winnerGoesToId
|
||||
const finalMatch = allMatches.find((m) => !m.winnerGoesToId && m.winnerId);
|
||||
const winnerId = finalMatch?.winnerId;
|
||||
|
||||
await db
|
||||
.update(tournament)
|
||||
.set({
|
||||
state: TOURNAMENT_STATES.FINISHED,
|
||||
finishedAt: new Date(),
|
||||
winnerId
|
||||
})
|
||||
.where(eq(tournament.id, tournamentId));
|
||||
|
||||
log.tournament.info({ tournamentId, winnerId }, 'tournament finished');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Internal: Generate Single Elim from Seeds ──────────────────────────
|
||||
|
||||
async function generateSingleElimFromSeeds(
|
||||
tournamentId: string,
|
||||
teamCount: number,
|
||||
seedToTeam: Map<number, string>
|
||||
) {
|
||||
const bracketSlots = generateSingleElimBracket(teamCount);
|
||||
const bracketSize = nextPowerOf2(teamCount);
|
||||
const totalRounds = Math.log2(bracketSize);
|
||||
|
||||
// get or create rounds
|
||||
const rounds = await getOrCreateRounds(tournamentId, totalRounds, 'winners');
|
||||
|
||||
// create tournament matches
|
||||
await insertBracketMatches(tournamentId, bracketSlots, rounds, seedToTeam, 'winners');
|
||||
}
|
||||
|
||||
// ── Internal: Generate Double Elim from Seeds ──────────────────────────
|
||||
|
||||
async function generateDoubleElimFromSeeds(
|
||||
tournamentId: string,
|
||||
teamCount: number,
|
||||
seedToTeam: Map<number, string>
|
||||
) {
|
||||
const bracketSlots = generateDoubleElimBracket(teamCount);
|
||||
const bracketSize = nextPowerOf2(teamCount);
|
||||
const wbRounds = Math.log2(bracketSize);
|
||||
const lbRounds = 2 * (wbRounds - 1);
|
||||
|
||||
// create rounds for WB, LB, and GF
|
||||
const wbRoundRecords = await getOrCreateRounds(tournamentId, wbRounds, 'winners');
|
||||
const lbRoundRecords = await getOrCreateRounds(tournamentId, lbRounds, 'losers');
|
||||
const gfRoundRecords = await getOrCreateRounds(tournamentId, 2, 'grand_final');
|
||||
|
||||
// build round map: bracketType + roundIndex → round record
|
||||
const roundMap = new Map<string, { id: string }>();
|
||||
for (let i = 0; i < wbRoundRecords.length; i++) {
|
||||
roundMap.set(`winners-${i}`, wbRoundRecords[i]);
|
||||
}
|
||||
for (let i = 0; i < lbRoundRecords.length; i++) {
|
||||
roundMap.set(`losers-${i}`, lbRoundRecords[i]);
|
||||
}
|
||||
for (let i = 0; i < gfRoundRecords.length; i++) {
|
||||
roundMap.set(`grand_final-${i}`, gfRoundRecords[i]);
|
||||
}
|
||||
|
||||
// insert all bracket matches (in two passes: first create, then link)
|
||||
// first pass: create all matches without progression links
|
||||
const slotToMatchId = new Map<string, string>();
|
||||
|
||||
for (const slot of bracketSlots) {
|
||||
const roundKey = `${slot.bracketType}-${slot.roundIndex}`;
|
||||
const round = roundMap.get(roundKey);
|
||||
if (!round) continue;
|
||||
|
||||
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
|
||||
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
|
||||
const isBye = slot.isBye;
|
||||
|
||||
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
|
||||
if (isBye) state = TOURNAMENT_MATCH_STATES.BYE;
|
||||
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
|
||||
|
||||
const [created] = await db
|
||||
.insert(tournamentMatch)
|
||||
.values({
|
||||
tournamentId,
|
||||
roundId: round.id,
|
||||
bracketPosition: slot.bracketPosition,
|
||||
team1Id: team1Id,
|
||||
team2Id: team2Id,
|
||||
state
|
||||
})
|
||||
.returning();
|
||||
|
||||
const key = `${slot.bracketType}-${slot.roundIndex}-${slot.bracketPosition}`;
|
||||
slotToMatchId.set(key, created.id);
|
||||
}
|
||||
|
||||
// second pass: link progression
|
||||
for (const slot of bracketSlots) {
|
||||
const key = `${slot.bracketType}-${slot.roundIndex}-${slot.bracketPosition}`;
|
||||
const matchId = slotToMatchId.get(key);
|
||||
if (!matchId) continue;
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (slot.winnerGoesTo) {
|
||||
const targetKey = `${slot.winnerGoesTo.roundIndex === 0 && slot.bracketType !== 'grand_final' ? slot.bracketType : slot.winnerGoesTo.roundIndex >= 0 ? '' : ''}`;
|
||||
// determine target bracket type from the slot's winnerGoesTo
|
||||
let targetBracketType = slot.bracketType;
|
||||
if (
|
||||
slot.bracketType === 'winners' &&
|
||||
slot.roundIndex ===
|
||||
Math.log2(
|
||||
nextPowerOf2(bracketSlots.filter((s) => s.bracketType === 'winners').length + 1)
|
||||
) -
|
||||
1
|
||||
) {
|
||||
targetBracketType = 'grand_final';
|
||||
}
|
||||
if (slot.bracketType === 'losers') {
|
||||
const maxLbRound = Math.max(
|
||||
...bracketSlots.filter((s) => s.bracketType === 'losers').map((s) => s.roundIndex)
|
||||
);
|
||||
if (slot.roundIndex === maxLbRound) {
|
||||
targetBracketType = 'grand_final';
|
||||
}
|
||||
}
|
||||
|
||||
// actually, the winnerGoesTo already encodes which round/position, we just need to find the bracket type
|
||||
// the bracket generator already set winnerGoesTo correctly, we need to figure out the target bracket type
|
||||
// for WB final → GF: winnerGoesTo points to GF roundIndex 0
|
||||
// for LB final → GF: winnerGoesTo points to GF roundIndex 0
|
||||
// for GF1 → GF2: winnerGoesTo points to GF roundIndex 1
|
||||
|
||||
// let's use a simpler approach: try all bracket types
|
||||
const wgt = slot.winnerGoesTo;
|
||||
let targetId: string | undefined;
|
||||
for (const bt of ['winners', 'losers', 'grand_final'] as const) {
|
||||
const tKey = `${bt}-${wgt.roundIndex}-${wgt.bracketPosition}`;
|
||||
targetId = slotToMatchId.get(tKey);
|
||||
if (targetId) break;
|
||||
}
|
||||
if (targetId) {
|
||||
updates.winnerGoesToId = targetId;
|
||||
updates.winnerSlot = wgt.slot;
|
||||
}
|
||||
}
|
||||
|
||||
if (slot.loserGoesTo) {
|
||||
const lgt = slot.loserGoesTo;
|
||||
let targetId: string | undefined;
|
||||
for (const bt of ['winners', 'losers', 'grand_final'] as const) {
|
||||
const tKey = `${bt}-${lgt.roundIndex}-${lgt.bracketPosition}`;
|
||||
targetId = slotToMatchId.get(tKey);
|
||||
if (targetId) break;
|
||||
}
|
||||
if (targetId) {
|
||||
updates.loserGoesToId = targetId;
|
||||
updates.loserSlot = lgt.slot;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, matchId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: Generate Groups + Bracket ────────────────────────────────
|
||||
|
||||
async function generateGroupsAndBracket(
|
||||
tournamentId: string,
|
||||
teamCount: number,
|
||||
config: TournamentConfig,
|
||||
seedToTeam: Map<number, string>
|
||||
) {
|
||||
const { groupCount, advanceCount } = config.groupStage!;
|
||||
const { groups, matches } = generateGroupStageMatches(teamCount, groupCount);
|
||||
|
||||
// get or create group stage round
|
||||
const existingRounds = await db.query.tournamentRound.findMany({
|
||||
where: and(
|
||||
eq(tournamentRound.tournamentId, tournamentId),
|
||||
eq(tournamentRound.bracketType, 'group')
|
||||
)
|
||||
});
|
||||
|
||||
let groupRound: { id: string };
|
||||
if (existingRounds.length > 0) {
|
||||
groupRound = existingRounds[0];
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(tournamentRound)
|
||||
.values({
|
||||
tournamentId,
|
||||
name: 'Group Stage',
|
||||
abbreviation: 'GS',
|
||||
roundOrder: 0,
|
||||
bestOf: 5, // default, can be configured per round
|
||||
bracketType: 'group'
|
||||
})
|
||||
.returning();
|
||||
groupRound = created;
|
||||
}
|
||||
|
||||
// create groups and entries
|
||||
for (const group of groups) {
|
||||
const [createdGroup] = await db
|
||||
.insert(tournamentGroup)
|
||||
.values({
|
||||
tournamentId,
|
||||
name: `Group ${String.fromCharCode(65 + group.groupIndex)}`,
|
||||
groupOrder: group.groupIndex
|
||||
})
|
||||
.returning();
|
||||
|
||||
for (let i = 0; i < group.teamSeeds.length; i++) {
|
||||
const teamId = seedToTeam.get(group.teamSeeds[i]);
|
||||
if (!teamId) continue;
|
||||
|
||||
await db.insert(tournamentGroupEntry).values({
|
||||
groupId: createdGroup.id,
|
||||
teamId,
|
||||
seed: i + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// create group stage matches
|
||||
let position = 0;
|
||||
for (const gm of matches) {
|
||||
const team1Id = seedToTeam.get(gm.team1Seed) ?? null;
|
||||
const team2Id = seedToTeam.get(gm.team2Seed) ?? null;
|
||||
|
||||
await db.insert(tournamentMatch).values({
|
||||
tournamentId,
|
||||
roundId: groupRound.id,
|
||||
bracketPosition: position++,
|
||||
team1Id,
|
||||
team2Id,
|
||||
state: team1Id && team2Id ? TOURNAMENT_MATCH_STATES.READY : TOURNAMENT_MATCH_STATES.PENDING
|
||||
});
|
||||
}
|
||||
|
||||
// bracket stage will be generated after groups finish
|
||||
// (via progression.ts when all group matches are done)
|
||||
}
|
||||
|
||||
// ── Internal: Get or Create Rounds ─────────────────────────────────────
|
||||
|
||||
async function getOrCreateRounds(
|
||||
tournamentId: string,
|
||||
count: number,
|
||||
bracketType: string
|
||||
): Promise<{ id: string }[]> {
|
||||
const existing = await db.query.tournamentRound.findMany({
|
||||
where: and(
|
||||
eq(tournamentRound.tournamentId, tournamentId),
|
||||
eq(tournamentRound.bracketType, bracketType)
|
||||
),
|
||||
orderBy: [asc(tournamentRound.roundOrder)]
|
||||
});
|
||||
|
||||
if (existing.length >= count) {
|
||||
return existing.slice(0, count);
|
||||
}
|
||||
|
||||
const rounds: { id: string }[] = [...existing];
|
||||
|
||||
for (let i = existing.length; i < count; i++) {
|
||||
// calculate matches in this round for naming
|
||||
const bracketSize = 1 << count; // 2^count for winners
|
||||
const matchesInRound = bracketType === 'grand_final' ? 1 : bracketSize / (2 << i);
|
||||
|
||||
const name =
|
||||
bracketType === 'grand_final'
|
||||
? i === 0
|
||||
? 'Grand Finals'
|
||||
: 'Grand Finals (Reset)'
|
||||
: getRoundName(
|
||||
Math.max(matchesInRound, 1),
|
||||
bracketType as 'winners' | 'losers' | 'grand_final'
|
||||
);
|
||||
|
||||
const prefix = bracketType === 'winners' ? 'WB' : bracketType === 'losers' ? 'LB' : 'GF';
|
||||
|
||||
const [created] = await db
|
||||
.insert(tournamentRound)
|
||||
.values({
|
||||
tournamentId,
|
||||
name,
|
||||
abbreviation: `${prefix}${i + 1}`,
|
||||
roundOrder: rounds.length,
|
||||
bestOf: 5, // default, organizer can change later
|
||||
bracketType
|
||||
})
|
||||
.returning();
|
||||
|
||||
rounds.push(created);
|
||||
}
|
||||
|
||||
return rounds;
|
||||
}
|
||||
|
||||
// ── Internal: Insert Bracket Matches (for single elim) ─────────────────
|
||||
|
||||
async function insertBracketMatches(
|
||||
tournamentId: string,
|
||||
bracketSlots: BracketSlot[],
|
||||
rounds: { id: string }[],
|
||||
seedToTeam: Map<number, string>,
|
||||
bracketType: string
|
||||
) {
|
||||
// first pass: create all matches
|
||||
const slotToMatchId = new Map<string, string>();
|
||||
|
||||
for (const slot of bracketSlots) {
|
||||
if (slot.bracketType !== bracketType) continue;
|
||||
|
||||
const round = rounds[slot.roundIndex];
|
||||
if (!round) continue;
|
||||
|
||||
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
|
||||
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
|
||||
const isBye = slot.isBye;
|
||||
|
||||
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
|
||||
if (isBye) state = TOURNAMENT_MATCH_STATES.BYE;
|
||||
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
|
||||
|
||||
const [created] = await db
|
||||
.insert(tournamentMatch)
|
||||
.values({
|
||||
tournamentId,
|
||||
roundId: round.id,
|
||||
bracketPosition: slot.bracketPosition,
|
||||
team1Id,
|
||||
team2Id,
|
||||
state
|
||||
})
|
||||
.returning();
|
||||
|
||||
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
|
||||
slotToMatchId.set(key, created.id);
|
||||
}
|
||||
|
||||
// second pass: link progression
|
||||
for (const slot of bracketSlots) {
|
||||
if (slot.bracketType !== bracketType) continue;
|
||||
|
||||
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
|
||||
const matchId = slotToMatchId.get(key);
|
||||
if (!matchId) continue;
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (slot.winnerGoesTo) {
|
||||
const targetKey = `${slot.winnerGoesTo.roundIndex}-${slot.winnerGoesTo.bracketPosition}`;
|
||||
const targetId = slotToMatchId.get(targetKey);
|
||||
if (targetId) {
|
||||
updates.winnerGoesToId = targetId;
|
||||
updates.winnerSlot = slot.winnerGoesTo.slot;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, matchId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTournamentOrThrow(tournamentId: string) {
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, tournamentId)
|
||||
});
|
||||
if (!t) throw new Error('Tournament not found');
|
||||
return t;
|
||||
}
|
||||
|
||||
export function assertTournamentState(current: string, expected: string) {
|
||||
if (current !== expected) {
|
||||
throw new Error(`Tournament is in state ${current}, expected ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTournamentFull(tournamentId: string) {
|
||||
return db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, tournamentId),
|
||||
with: {
|
||||
rounds: {
|
||||
orderBy: [asc(tournamentRound.roundOrder)]
|
||||
},
|
||||
registrations: true,
|
||||
matches: true,
|
||||
groups: {
|
||||
with: { entries: true }
|
||||
},
|
||||
staff: true
|
||||
}
|
||||
});
|
||||
}
|
||||
408
src/lib/server/tournament/progression.ts
Normal file
408
src/lib/server/tournament/progression.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
tournament,
|
||||
tournamentMatch,
|
||||
tournamentRound,
|
||||
tournamentGroup,
|
||||
tournamentGroupEntry,
|
||||
tournamentRegistration,
|
||||
match,
|
||||
matchParticipant
|
||||
} from '$lib/server/db/schema';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { TOURNAMENT_MATCH_STATES, type TournamentConfig } from './types';
|
||||
import { generateSingleElimBracket, nextPowerOf2, getRoundName } from './bracket';
|
||||
import { checkTournamentCompletion } from './lifecycle';
|
||||
|
||||
// ── Try Advance Tournament ─────────────────────────────────────────────
|
||||
|
||||
export async function tryAdvanceTournament(matchId: string) {
|
||||
const tm = await db.query.tournamentMatch.findFirst({
|
||||
where: eq(tournamentMatch.matchId, matchId)
|
||||
});
|
||||
if (!tm) return; // not a tournament match
|
||||
|
||||
const finishedMatch = await db.query.match.findFirst({
|
||||
where: eq(match.id, matchId)
|
||||
});
|
||||
if (!finishedMatch || !finishedMatch.winnerId) return;
|
||||
|
||||
const winnerId = finishedMatch.winnerId;
|
||||
const loserId = tm.team1Id === winnerId ? tm.team2Id : tm.team1Id;
|
||||
|
||||
// update tournament match
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED, winnerId })
|
||||
.where(eq(tournamentMatch.id, tm.id));
|
||||
|
||||
log.tournament.info(
|
||||
{ tournamentId: tm.tournamentId, matchId, winnerId },
|
||||
'tournament match finished'
|
||||
);
|
||||
|
||||
// get the round to check bracket type
|
||||
const round = await db.query.tournamentRound.findFirst({
|
||||
where: eq(tournamentRound.id, tm.roundId)
|
||||
});
|
||||
|
||||
if (round?.bracketType === 'group') {
|
||||
await handleGroupMatchResult(tm.tournamentId, winnerId, loserId, finishedMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
if (round?.bracketType === 'grand_final') {
|
||||
await handleGrandFinalsResult(tm, winnerId, loserId);
|
||||
return;
|
||||
}
|
||||
|
||||
// standard bracket advancement
|
||||
if (tm.winnerGoesToId && winnerId) {
|
||||
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, winnerId);
|
||||
}
|
||||
|
||||
if (tm.loserGoesToId && loserId) {
|
||||
await advanceTeamToMatch(tm.loserGoesToId, tm.loserSlot!, loserId);
|
||||
}
|
||||
|
||||
await checkTournamentCompletion(tm.tournamentId);
|
||||
}
|
||||
|
||||
// ── Process BYE Matches ────────────────────────────────────────────────
|
||||
|
||||
export async function processByes(tournamentId: string) {
|
||||
const byeMatches = await db.query.tournamentMatch.findMany({
|
||||
where: and(
|
||||
eq(tournamentMatch.tournamentId, tournamentId),
|
||||
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.BYE)
|
||||
)
|
||||
});
|
||||
|
||||
for (const tm of byeMatches) {
|
||||
const winnerId = tm.team1Id ?? tm.team2Id;
|
||||
if (!winnerId) continue;
|
||||
|
||||
// skip if already has a winner (already processed)
|
||||
if (tm.winnerId) continue;
|
||||
|
||||
await db.update(tournamentMatch).set({ winnerId }).where(eq(tournamentMatch.id, tm.id));
|
||||
|
||||
if (tm.winnerGoesToId) {
|
||||
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, winnerId);
|
||||
}
|
||||
|
||||
log.tournament.info({ tournamentId, matchId: tm.id, winnerId }, 'BYE auto-advanced');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handle Team Withdrawal ─────────────────────────────────────────────
|
||||
|
||||
export async function handleWithdrawal(tournamentId: string, teamId: string) {
|
||||
const teamMatches = await db.query.tournamentMatch.findMany({
|
||||
where: and(
|
||||
eq(tournamentMatch.tournamentId, tournamentId),
|
||||
inArray(tournamentMatch.state, [
|
||||
TOURNAMENT_MATCH_STATES.PENDING,
|
||||
TOURNAMENT_MATCH_STATES.READY,
|
||||
TOURNAMENT_MATCH_STATES.SCHEDULED
|
||||
])
|
||||
)
|
||||
});
|
||||
|
||||
for (const tm of teamMatches) {
|
||||
if (tm.team1Id !== teamId && tm.team2Id !== teamId) continue;
|
||||
|
||||
const opponentId = tm.team1Id === teamId ? tm.team2Id : tm.team1Id;
|
||||
|
||||
if (tm.matchId) {
|
||||
await db
|
||||
.update(match)
|
||||
.set({ state: 'CANCELLED', finishedAt: new Date() })
|
||||
.where(eq(match.id, tm.matchId));
|
||||
}
|
||||
|
||||
if (opponentId) {
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED, winnerId: opponentId })
|
||||
.where(eq(tournamentMatch.id, tm.id));
|
||||
|
||||
if (tm.winnerGoesToId) {
|
||||
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, opponentId);
|
||||
}
|
||||
} else {
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED })
|
||||
.where(eq(tournamentMatch.id, tm.id));
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(tournamentRegistration)
|
||||
.set({ status: 'withdrawn' })
|
||||
.where(
|
||||
and(
|
||||
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||
eq(tournamentRegistration.teamId, teamId)
|
||||
)
|
||||
);
|
||||
|
||||
log.tournament.info({ tournamentId, teamId }, 'team withdrew mid-tournament');
|
||||
await checkTournamentCompletion(tournamentId);
|
||||
}
|
||||
|
||||
// ── Internal: Advance Team to Match ────────────────────────────────────
|
||||
|
||||
async function advanceTeamToMatch(targetMatchId: string, targetSlot: number, teamId: string) {
|
||||
const updates: Record<string, unknown> =
|
||||
targetSlot === 1 ? { team1Id: teamId } : { team2Id: teamId };
|
||||
|
||||
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, targetMatchId));
|
||||
|
||||
// check if both teams are now known → mark as READY
|
||||
const updated = await db.query.tournamentMatch.findFirst({
|
||||
where: eq(tournamentMatch.id, targetMatchId)
|
||||
});
|
||||
|
||||
if (
|
||||
updated &&
|
||||
updated.team1Id &&
|
||||
updated.team2Id &&
|
||||
updated.state === TOURNAMENT_MATCH_STATES.PENDING
|
||||
) {
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({ state: TOURNAMENT_MATCH_STATES.READY })
|
||||
.where(eq(tournamentMatch.id, targetMatchId));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: Handle Group Match Result ────────────────────────────────
|
||||
|
||||
async function handleGroupMatchResult(
|
||||
tournamentId: string,
|
||||
winnerId: string,
|
||||
loserId: string | null,
|
||||
finishedMatch: typeof match.$inferSelect
|
||||
) {
|
||||
// update group standings
|
||||
const groups = await db.query.tournamentGroup.findMany({
|
||||
where: eq(tournamentGroup.tournamentId, tournamentId),
|
||||
with: { entries: true }
|
||||
});
|
||||
|
||||
// get match participant scores for map difference
|
||||
const participants = await db.query.matchParticipant.findMany({
|
||||
where: eq(matchParticipant.matchId, finishedMatch.id)
|
||||
});
|
||||
const winnerParticipant = participants.find((p) => p.teamId === winnerId);
|
||||
const loserParticipant = loserId ? participants.find((p) => p.teamId === loserId) : null;
|
||||
|
||||
for (const group of groups) {
|
||||
const winnerEntry = group.entries.find((e) => e.teamId === winnerId);
|
||||
const loserEntry = loserId ? group.entries.find((e) => e.teamId === loserId) : null;
|
||||
|
||||
if (winnerEntry) {
|
||||
await db
|
||||
.update(tournamentGroupEntry)
|
||||
.set({
|
||||
wins: winnerEntry.wins + 1,
|
||||
mapWins: winnerEntry.mapWins + (winnerParticipant?.score ?? 0),
|
||||
mapLosses: winnerEntry.mapLosses + (loserParticipant?.score ?? 0)
|
||||
})
|
||||
.where(eq(tournamentGroupEntry.id, winnerEntry.id));
|
||||
}
|
||||
|
||||
if (loserEntry) {
|
||||
await db
|
||||
.update(tournamentGroupEntry)
|
||||
.set({
|
||||
losses: loserEntry.losses + 1,
|
||||
mapWins: loserEntry.mapWins + (loserParticipant?.score ?? 0),
|
||||
mapLosses: loserEntry.mapLosses + (winnerParticipant?.score ?? 0)
|
||||
})
|
||||
.where(eq(tournamentGroupEntry.id, loserEntry.id));
|
||||
}
|
||||
}
|
||||
|
||||
// check if all group matches are finished
|
||||
const groupRounds = await db.query.tournamentRound.findMany({
|
||||
where: and(
|
||||
eq(tournamentRound.tournamentId, tournamentId),
|
||||
eq(tournamentRound.bracketType, 'group')
|
||||
)
|
||||
});
|
||||
const groupRoundIds = groupRounds.map((r) => r.id);
|
||||
|
||||
if (groupRoundIds.length === 0) return;
|
||||
|
||||
const allGroupMatches = await db.query.tournamentMatch.findMany({
|
||||
where: and(
|
||||
eq(tournamentMatch.tournamentId, tournamentId),
|
||||
inArray(tournamentMatch.roundId, groupRoundIds)
|
||||
)
|
||||
});
|
||||
|
||||
const allDone = allGroupMatches.every(
|
||||
(m) => m.state === TOURNAMENT_MATCH_STATES.FINISHED || m.state === TOURNAMENT_MATCH_STATES.BYE
|
||||
);
|
||||
|
||||
if (allDone) {
|
||||
await advanceGroupsToPlayoffs(tournamentId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: Advance Groups to Playoffs ───────────────────────────────
|
||||
|
||||
async function advanceGroupsToPlayoffs(tournamentId: string) {
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, tournamentId)
|
||||
});
|
||||
if (!t) return;
|
||||
|
||||
const config = t.config as TournamentConfig;
|
||||
const advanceCount = config.groupStage?.advanceCount ?? 2;
|
||||
|
||||
const groups = await db.query.tournamentGroup.findMany({
|
||||
where: eq(tournamentGroup.tournamentId, tournamentId),
|
||||
with: { entries: true }
|
||||
});
|
||||
|
||||
// rank teams and determine who advances
|
||||
const advancingTeams: { teamId: string; globalSeed: number }[] = [];
|
||||
let seedCounter = 1;
|
||||
|
||||
for (const group of groups) {
|
||||
const sorted = [...group.entries].sort((a, b) => {
|
||||
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||
const aDiff = a.mapWins - a.mapLosses;
|
||||
const bDiff = b.mapWins - b.mapLosses;
|
||||
return bDiff - aDiff;
|
||||
});
|
||||
|
||||
for (const entry of sorted.slice(0, advanceCount)) {
|
||||
advancingTeams.push({ teamId: entry.teamId, globalSeed: seedCounter++ });
|
||||
}
|
||||
}
|
||||
|
||||
// generate playoff bracket
|
||||
const seedToTeam = new Map<number, string>();
|
||||
for (const t of advancingTeams) {
|
||||
seedToTeam.set(t.globalSeed, t.teamId);
|
||||
}
|
||||
|
||||
const bracketSlots = generateSingleElimBracket(advancingTeams.length);
|
||||
const bracketSize = nextPowerOf2(advancingTeams.length);
|
||||
const totalRounds = Math.log2(bracketSize);
|
||||
|
||||
// create playoff rounds
|
||||
const rounds: { id: string }[] = [];
|
||||
for (let i = 0; i < totalRounds; i++) {
|
||||
const matchesInRound = bracketSize / (2 << i);
|
||||
const name = getRoundName(matchesInRound, 'winners');
|
||||
|
||||
const [created] = await db
|
||||
.insert(tournamentRound)
|
||||
.values({
|
||||
tournamentId,
|
||||
name,
|
||||
abbreviation: `PO${i + 1}`,
|
||||
roundOrder: 100 + i,
|
||||
bestOf: 7,
|
||||
bracketType: 'winners'
|
||||
})
|
||||
.returning();
|
||||
rounds.push(created);
|
||||
}
|
||||
|
||||
// insert bracket matches
|
||||
const slotToMatchId = new Map<string, string>();
|
||||
|
||||
for (const slot of bracketSlots) {
|
||||
const round = rounds[slot.roundIndex];
|
||||
if (!round) continue;
|
||||
|
||||
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
|
||||
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
|
||||
|
||||
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
|
||||
if (slot.isBye) state = TOURNAMENT_MATCH_STATES.BYE;
|
||||
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
|
||||
|
||||
const [created] = await db
|
||||
.insert(tournamentMatch)
|
||||
.values({
|
||||
tournamentId,
|
||||
roundId: round.id,
|
||||
bracketPosition: slot.bracketPosition,
|
||||
team1Id,
|
||||
team2Id,
|
||||
state
|
||||
})
|
||||
.returning();
|
||||
|
||||
slotToMatchId.set(`${slot.roundIndex}-${slot.bracketPosition}`, created.id);
|
||||
}
|
||||
|
||||
// link progression
|
||||
for (const slot of bracketSlots) {
|
||||
if (!slot.winnerGoesTo) continue;
|
||||
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
|
||||
const mid = slotToMatchId.get(key);
|
||||
if (!mid) continue;
|
||||
|
||||
const targetKey = `${slot.winnerGoesTo.roundIndex}-${slot.winnerGoesTo.bracketPosition}`;
|
||||
const targetId = slotToMatchId.get(targetKey);
|
||||
if (!targetId) continue;
|
||||
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({ winnerGoesToId: targetId, winnerSlot: slot.winnerGoesTo.slot })
|
||||
.where(eq(tournamentMatch.id, mid));
|
||||
}
|
||||
|
||||
// process byes
|
||||
await processByes(tournamentId);
|
||||
|
||||
log.tournament.info(
|
||||
{ tournamentId, teamCount: advancingTeams.length },
|
||||
'groups advanced to playoffs'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internal: Handle Grand Finals Result ───────────────────────────────
|
||||
|
||||
async function handleGrandFinalsResult(
|
||||
tm: typeof tournamentMatch.$inferSelect,
|
||||
winnerId: string,
|
||||
loserId: string | null
|
||||
) {
|
||||
if (tm.winnerGoesToId) {
|
||||
// this is GF1 (has progression to GF2)
|
||||
// WB winner is always team1 (slot 1) in GF
|
||||
if (winnerId === tm.team1Id) {
|
||||
// WB winner won → skip bracket reset, mark GF2 as BYE
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({ state: TOURNAMENT_MATCH_STATES.BYE as string, winnerId })
|
||||
.where(eq(tournamentMatch.id, tm.winnerGoesToId));
|
||||
|
||||
log.tournament.info(
|
||||
{ tournamentId: tm.tournamentId, winnerId },
|
||||
'WB winner won GF1, skipping bracket reset'
|
||||
);
|
||||
} else {
|
||||
// LB winner won → bracket reset
|
||||
await advanceTeamToMatch(tm.winnerGoesToId, 1, winnerId);
|
||||
if (loserId) {
|
||||
await advanceTeamToMatch(tm.winnerGoesToId, 2, loserId);
|
||||
}
|
||||
|
||||
log.tournament.info({ tournamentId: tm.tournamentId }, 'bracket reset activated');
|
||||
}
|
||||
}
|
||||
|
||||
await checkTournamentCompletion(tm.tournamentId);
|
||||
}
|
||||
149
src/lib/server/tournament/qualifiers.ts
Normal file
149
src/lib/server/tournament/qualifiers.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
tournament,
|
||||
tournamentRegistration,
|
||||
tournamentQualifierScore,
|
||||
mappool,
|
||||
mappoolSlot
|
||||
} from '$lib/server/db/schema';
|
||||
import { eq, and, inArray, asc, desc } from 'drizzle-orm';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { TOURNAMENT_STATES, type TournamentConfig } from './types';
|
||||
|
||||
// ── Submit Qualifier Score ─────────────────────────────────────────────
|
||||
|
||||
export async function submitQualifierScore(opts: {
|
||||
tournamentId: string;
|
||||
teamId: string;
|
||||
mappoolSlotId: string;
|
||||
totalScore: number;
|
||||
accuracy?: number;
|
||||
}) {
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, opts.tournamentId)
|
||||
});
|
||||
if (!t) throw new Error('Tournament not found');
|
||||
if (t.state !== TOURNAMENT_STATES.QUALIFIERS) {
|
||||
throw new Error('Tournament is not in qualifiers phase');
|
||||
}
|
||||
|
||||
// verify team is registered
|
||||
const reg = await db.query.tournamentRegistration.findFirst({
|
||||
where: and(
|
||||
eq(tournamentRegistration.tournamentId, opts.tournamentId),
|
||||
eq(tournamentRegistration.teamId, opts.teamId),
|
||||
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
|
||||
)
|
||||
});
|
||||
if (!reg) throw new Error('Team is not registered for this tournament');
|
||||
|
||||
const [score] = await db
|
||||
.insert(tournamentQualifierScore)
|
||||
.values({
|
||||
tournamentId: opts.tournamentId,
|
||||
teamId: opts.teamId,
|
||||
mappoolSlotId: opts.mappoolSlotId,
|
||||
totalScore: opts.totalScore,
|
||||
accuracy: opts.accuracy
|
||||
})
|
||||
.returning();
|
||||
|
||||
log.tournament.info(
|
||||
{ tournamentId: opts.tournamentId, teamId: opts.teamId, score: opts.totalScore },
|
||||
'qualifier score submitted'
|
||||
);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// ── Get Qualifier Rankings ─────────────────────────────────────────────
|
||||
|
||||
export async function getQualifierRankings(tournamentId: string) {
|
||||
const scores = await db.query.tournamentQualifierScore.findMany({
|
||||
where: eq(tournamentQualifierScore.tournamentId, tournamentId)
|
||||
});
|
||||
|
||||
// aggregate total score per team across all maps
|
||||
const teamTotals = new Map<string, { totalScore: number; mapCount: number }>();
|
||||
|
||||
for (const score of scores) {
|
||||
const existing = teamTotals.get(score.teamId) ?? { totalScore: 0, mapCount: 0 };
|
||||
existing.totalScore += score.totalScore;
|
||||
existing.mapCount += 1;
|
||||
teamTotals.set(score.teamId, existing);
|
||||
}
|
||||
|
||||
// rank by total score descending
|
||||
const ranked = Array.from(teamTotals.entries())
|
||||
.map(([teamId, data]) => ({
|
||||
teamId,
|
||||
totalScore: data.totalScore,
|
||||
mapCount: data.mapCount,
|
||||
averageScore: data.mapCount > 0 ? data.totalScore / data.mapCount : 0
|
||||
}))
|
||||
.sort((a, b) => b.totalScore - a.totalScore);
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
// ── Finalize Qualifiers ────────────────────────────────────────────────
|
||||
|
||||
export async function finalizeQualifiers(tournamentId: string) {
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, tournamentId)
|
||||
});
|
||||
if (!t) throw new Error('Tournament not found');
|
||||
if (t.state !== TOURNAMENT_STATES.QUALIFIERS) {
|
||||
throw new Error('Tournament is not in qualifiers phase');
|
||||
}
|
||||
|
||||
const rankings = await getQualifierRankings(tournamentId);
|
||||
|
||||
// top maxSlots teams qualify, rest are eliminated
|
||||
for (let i = 0; i < rankings.length; i++) {
|
||||
const isQualified = i < t.maxSlots;
|
||||
|
||||
// update registration with seed and qualifier score
|
||||
await db
|
||||
.update(tournamentRegistration)
|
||||
.set({
|
||||
seed: isQualified ? i + 1 : null,
|
||||
qualifierScore: rankings[i].totalScore,
|
||||
status: isQualified ? 'confirmed' : 'eliminated'
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||
eq(tournamentRegistration.teamId, rankings[i].teamId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// move to seeding phase
|
||||
await db
|
||||
.update(tournament)
|
||||
.set({ state: TOURNAMENT_STATES.SEEDING })
|
||||
.where(eq(tournament.id, tournamentId));
|
||||
|
||||
log.tournament.info(
|
||||
{ tournamentId, qualified: Math.min(rankings.length, t.maxSlots), total: rankings.length },
|
||||
'qualifiers finalized'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Get Qualifier Mappool ──────────────────────────────────────────────
|
||||
|
||||
export async function getQualifierMappool(tournamentId: string) {
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, tournamentId)
|
||||
});
|
||||
if (!t) throw new Error('Tournament not found');
|
||||
|
||||
const config = t.config as TournamentConfig;
|
||||
if (!config.qualifiers?.mappoolId) return null;
|
||||
|
||||
return db.query.mappool.findFirst({
|
||||
where: eq(mappool.id, config.qualifiers.mappoolId),
|
||||
with: { slots: true }
|
||||
});
|
||||
}
|
||||
245
src/lib/server/tournament/scheduler.ts
Normal file
245
src/lib/server/tournament/scheduler.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournament, tournamentMatch, tournamentRound } from '$lib/server/db/schema';
|
||||
import { eq, and, lte, asc, inArray } from 'drizzle-orm';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { TOURNAMENT_MATCH_STATES, TOURNAMENT_STATES, type TournamentConfig } from './types';
|
||||
import { createMatch } from '$lib/server/match/engine';
|
||||
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
||||
import { getActiveLobbyCount } from '$lib/server/bancho/client';
|
||||
import type { MatchConfig } from '$lib/server/match/types';
|
||||
|
||||
const MAX_TOTAL_LOBBIES = 4;
|
||||
const SCHEDULER_INTERVAL_MS = 30_000; // 30 seconds
|
||||
|
||||
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Start/Stop Scheduler ───────────────────────────────────────────────
|
||||
|
||||
export function startTournamentScheduler() {
|
||||
if (schedulerTimer) return;
|
||||
|
||||
schedulerTimer = setInterval(() => {
|
||||
processTournamentMatchQueue().catch((err) =>
|
||||
log.tournament.error({ err }, 'scheduler tick failed')
|
||||
);
|
||||
}, SCHEDULER_INTERVAL_MS);
|
||||
|
||||
log.tournament.info('tournament scheduler started');
|
||||
}
|
||||
|
||||
export function stopTournamentScheduler() {
|
||||
if (schedulerTimer) {
|
||||
clearInterval(schedulerTimer);
|
||||
schedulerTimer = null;
|
||||
log.tournament.info('tournament scheduler stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Process Tournament Match Queue ─────────────────────────────────────
|
||||
|
||||
export async function processTournamentMatchQueue() {
|
||||
// check how many lobby slots are available
|
||||
const activeLobbies = getActiveLobbyCount();
|
||||
const available = MAX_TOTAL_LOBBIES - activeLobbies;
|
||||
|
||||
if (available <= 0) {
|
||||
log.tournament.debug({ activeLobbies }, 'no lobby slots available');
|
||||
return;
|
||||
}
|
||||
|
||||
// find tournaments in BRACKET state
|
||||
const activeTournaments = await db.query.tournament.findMany({
|
||||
where: eq(tournament.state, TOURNAMENT_STATES.BRACKET)
|
||||
});
|
||||
|
||||
if (activeTournaments.length === 0) return;
|
||||
|
||||
// find READY tournament matches across all active tournaments
|
||||
// that are either past their scheduled time or have no scheduled time
|
||||
const readyMatches = await db.query.tournamentMatch.findMany({
|
||||
where: and(
|
||||
inArray(
|
||||
tournamentMatch.tournamentId,
|
||||
activeTournaments.map((t) => t.id)
|
||||
),
|
||||
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
|
||||
),
|
||||
orderBy: [asc(tournamentMatch.scheduledAt)]
|
||||
});
|
||||
|
||||
// filter to matches that are ready to start (scheduled time passed or no schedule)
|
||||
const now = new Date();
|
||||
const eligible = readyMatches.filter((m) => !m.scheduledAt || m.scheduledAt <= now);
|
||||
|
||||
if (eligible.length === 0) return;
|
||||
|
||||
// respect per-tournament concurrency limits
|
||||
const tournamentConfigs = new Map<string, TournamentConfig>();
|
||||
for (const t of activeTournaments) {
|
||||
tournamentConfigs.set(t.id, t.config as TournamentConfig);
|
||||
}
|
||||
|
||||
// count currently live matches per tournament
|
||||
const liveMatches = await db.query.tournamentMatch.findMany({
|
||||
where: and(
|
||||
inArray(
|
||||
tournamentMatch.tournamentId,
|
||||
activeTournaments.map((t) => t.id)
|
||||
),
|
||||
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.LIVE)
|
||||
)
|
||||
});
|
||||
|
||||
const livePerTournament = new Map<string, number>();
|
||||
for (const m of liveMatches) {
|
||||
livePerTournament.set(m.tournamentId, (livePerTournament.get(m.tournamentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let slotsUsed = 0;
|
||||
|
||||
for (const tm of eligible) {
|
||||
if (slotsUsed >= available) break;
|
||||
|
||||
const config = tournamentConfigs.get(tm.tournamentId);
|
||||
const maxConcurrent = config?.scheduling?.maxConcurrentLobbies ?? 3;
|
||||
const currentLive = livePerTournament.get(tm.tournamentId) ?? 0;
|
||||
|
||||
if (currentLive >= maxConcurrent) continue;
|
||||
|
||||
try {
|
||||
await startTournamentMatch(tm);
|
||||
slotsUsed++;
|
||||
livePerTournament.set(tm.tournamentId, currentLive + 1);
|
||||
} catch (err) {
|
||||
log.tournament.error(
|
||||
{ err, tournamentMatchId: tm.id, tournamentId: tm.tournamentId },
|
||||
'failed to start tournament match'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (slotsUsed > 0) {
|
||||
log.tournament.info({ started: slotsUsed }, 'started tournament matches');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start a Single Tournament Match ────────────────────────────────────
|
||||
|
||||
async function startTournamentMatch(tm: typeof tournamentMatch.$inferSelect) {
|
||||
if (!tm.team1Id || !tm.team2Id) {
|
||||
throw new Error('Cannot start match without both teams');
|
||||
}
|
||||
|
||||
// get the round for config (bestOf, mappool)
|
||||
const round = await db.query.tournamentRound.findFirst({
|
||||
where: eq(tournamentRound.id, tm.roundId)
|
||||
});
|
||||
if (!round) throw new Error('Round not found');
|
||||
|
||||
// get tournament for config
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, tm.tournamentId)
|
||||
});
|
||||
if (!t) throw new Error('Tournament not found');
|
||||
|
||||
const tournamentConfig = t.config as TournamentConfig;
|
||||
|
||||
if (!round.mappoolId) {
|
||||
throw new Error(`Round ${round.name} has no mappool assigned`);
|
||||
}
|
||||
|
||||
// build match config from tournament + round settings
|
||||
const matchConfig: MatchConfig = {
|
||||
bestOf: round.bestOf,
|
||||
teamSize: tournamentConfig.teamSize,
|
||||
scoringType: tournamentConfig.scoringType,
|
||||
freemod: tournamentConfig.freemod,
|
||||
forceNoFail: tournamentConfig.forceNoFail,
|
||||
allowEloChange: false // tournament matches don't affect elo
|
||||
};
|
||||
|
||||
// create the actual match using the existing engine
|
||||
const matchName = `${t.name} - ${round.name} - Match ${tm.bracketPosition + 1}`;
|
||||
|
||||
const createdMatch = await createMatch({
|
||||
name: matchName,
|
||||
config: matchConfig,
|
||||
mappoolId: round.mappoolId,
|
||||
teams: [tm.team1Id, tm.team2Id],
|
||||
createdBy: t.createdBy
|
||||
});
|
||||
|
||||
// link tournament match to the real match
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({
|
||||
matchId: createdMatch.id,
|
||||
state: TOURNAMENT_MATCH_STATES.LIVE as string
|
||||
})
|
||||
.where(eq(tournamentMatch.id, tm.id));
|
||||
|
||||
// start the IRC lobby
|
||||
await initMatchLobby(createdMatch.id);
|
||||
|
||||
log.tournament.info(
|
||||
{
|
||||
tournamentId: tm.tournamentId,
|
||||
tournamentMatchId: tm.id,
|
||||
matchId: createdMatch.id,
|
||||
round: round.name
|
||||
},
|
||||
'tournament match started'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Schedule Matches for a Round ───────────────────────────────────────
|
||||
|
||||
export async function scheduleTournamentMatches(tournamentId: string, roundId?: string) {
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, tournamentId)
|
||||
});
|
||||
if (!t) throw new Error('Tournament not found');
|
||||
|
||||
const config = t.config as TournamentConfig;
|
||||
const intervalMs = (config.scheduling?.matchIntervalMinutes ?? 30) * 60 * 1000;
|
||||
|
||||
// get matches to schedule
|
||||
const where = roundId
|
||||
? and(
|
||||
eq(tournamentMatch.tournamentId, tournamentId),
|
||||
eq(tournamentMatch.roundId, roundId),
|
||||
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
|
||||
)
|
||||
: and(
|
||||
eq(tournamentMatch.tournamentId, tournamentId),
|
||||
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
|
||||
);
|
||||
|
||||
const matches = await db.query.tournamentMatch.findMany({
|
||||
where,
|
||||
orderBy: [asc(tournamentMatch.bracketPosition)]
|
||||
});
|
||||
|
||||
// get the round's scheduled start time
|
||||
const round = roundId
|
||||
? await db.query.tournamentRound.findFirst({
|
||||
where: eq(tournamentRound.id, roundId)
|
||||
})
|
||||
: null;
|
||||
|
||||
const baseTime = round?.scheduledAt ?? t.startAt ?? new Date();
|
||||
|
||||
// assign scheduled times with intervals
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const scheduledAt = new Date(baseTime.getTime() + i * intervalMs);
|
||||
await db
|
||||
.update(tournamentMatch)
|
||||
.set({ scheduledAt })
|
||||
.where(eq(tournamentMatch.id, matches[i].id));
|
||||
}
|
||||
|
||||
log.tournament.info(
|
||||
{ tournamentId, roundId, count: matches.length },
|
||||
'scheduled tournament matches'
|
||||
);
|
||||
}
|
||||
71
src/lib/server/tournament/types.ts
Normal file
71
src/lib/server/tournament/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export interface TournamentConfig {
|
||||
teamSize: number;
|
||||
scoringType: 'score' | 'score_v2' | 'accuracy' | 'combo';
|
||||
freemod?: boolean;
|
||||
forceNoFail?: boolean;
|
||||
qualifiers?: {
|
||||
enabled: boolean;
|
||||
mappoolId?: string;
|
||||
lobbySizeMax?: number; // players per qualifier lobby (default 8)
|
||||
};
|
||||
groupStage?: {
|
||||
groupCount: number;
|
||||
advanceCount: number; // top N per group advance to bracket
|
||||
};
|
||||
scheduling?: {
|
||||
matchIntervalMinutes: number; // gap between matches (default 30)
|
||||
maxConcurrentLobbies: number; // respect IRC limits (default 3)
|
||||
};
|
||||
}
|
||||
|
||||
export const TOURNAMENT_STATES = {
|
||||
DRAFT: 'DRAFT',
|
||||
REGISTRATION: 'REGISTRATION',
|
||||
QUALIFIERS: 'QUALIFIERS',
|
||||
SEEDING: 'SEEDING',
|
||||
BRACKET: 'BRACKET',
|
||||
FINISHED: 'FINISHED',
|
||||
CANCELLED: 'CANCELLED'
|
||||
} as const;
|
||||
|
||||
export type TournamentState = (typeof TOURNAMENT_STATES)[keyof typeof TOURNAMENT_STATES];
|
||||
|
||||
export const TOURNAMENT_MATCH_STATES = {
|
||||
PENDING: 'PENDING',
|
||||
READY: 'READY',
|
||||
SCHEDULED: 'SCHEDULED',
|
||||
LIVE: 'LIVE',
|
||||
FINISHED: 'FINISHED',
|
||||
BYE: 'BYE'
|
||||
} as const;
|
||||
|
||||
export type TournamentMatchState =
|
||||
(typeof TOURNAMENT_MATCH_STATES)[keyof typeof TOURNAMENT_MATCH_STATES];
|
||||
|
||||
export const TOURNAMENT_FORMATS = {
|
||||
SINGLE_ELIM: 'single_elim',
|
||||
DOUBLE_ELIM: 'double_elim',
|
||||
GROUPS_BRACKET: 'groups_bracket'
|
||||
} as const;
|
||||
|
||||
export type TournamentFormat = (typeof TOURNAMENT_FORMATS)[keyof typeof TOURNAMENT_FORMATS];
|
||||
|
||||
export const TOURNAMENT_STAFF_ROLES = {
|
||||
ORGANIZER: 'organizer',
|
||||
ADMIN: 'admin',
|
||||
REFEREE: 'referee',
|
||||
POOLER: 'pooler',
|
||||
STREAMER: 'streamer'
|
||||
} as const;
|
||||
|
||||
export type TournamentStaffRole =
|
||||
(typeof TOURNAMENT_STAFF_ROLES)[keyof typeof TOURNAMENT_STAFF_ROLES];
|
||||
|
||||
export const REGISTRATION_STATUSES = {
|
||||
REGISTERED: 'registered',
|
||||
CONFIRMED: 'confirmed',
|
||||
ELIMINATED: 'eliminated',
|
||||
WITHDRAWN: 'withdrawn'
|
||||
} as const;
|
||||
|
||||
export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[keyof typeof REGISTRATION_STATUSES];
|
||||
Reference in New Issue
Block a user