// Bracket and pool generation. Pure functions: teams in, match objects out. // A match is { id, stage, round, poolId, slot, teamA, teamB, feeds, feedsSide, status, sets, winner } // teamA/teamB are team ids, or null when the slot is fed by another match. let nextId = 1; export function resetIds(n = 1) { nextId = n; } const mid = () => `m${nextId++}`; export function makeMatch(p) { return { id: mid(), stage: 'pool', round: 1, poolId: null, slot: null, teamA: null, teamB: null, // team ids feeds: null, feedsSide: null, // winner goes to match `feeds`, side 'A' | 'B' loserFeeds: null, loserFeedsSide: null, status: 'pending', // pending | on_deck | live | final | forfeit | void | bye sets: [], winner: null, loser: null, court: null, ...p, }; } /** Split team ids into pools of ~poolSize using snake seeding (1,2,3 / 6,5,4 / 7,8,9 ...). */ export function makePools(teamIds, poolSize = 4) { const n = teamIds.length; const poolCount = Math.max(1, Math.round(n / poolSize)); const pools = Array.from({ length: poolCount }, (_, i) => ({ id: String.fromCharCode(65 + i), teams: [] })); teamIds.forEach((t, i) => { const lap = Math.floor(i / poolCount); const idx = lap % 2 === 0 ? i % poolCount : poolCount - 1 - (i % poolCount); pools[idx].teams.push(t); }); return pools; } /** Round robin via the circle method. Returns matches with round numbers 1..(n-1). */ export function roundRobin(teamIds, poolId = null) { const ids = [...teamIds]; if (ids.length % 2 === 1) ids.push(null); // bye marker const n = ids.length, rounds = n - 1, half = n / 2; const out = []; for (let r = 0; r < rounds; r++) { for (let i = 0; i < half; i++) { const a = ids[i], b = ids[n - 1 - i]; if (a === null || b === null) continue; // alternate home/away so the same team isn't always listed first const [ta, tb] = (r + i) % 2 === 0 ? [a, b] : [b, a]; out.push(makeMatch({ stage: 'pool', round: r + 1, poolId, teamA: ta, teamB: tb })); } ids.splice(1, 0, ids.pop()); // rotate all but the first } return out; } /** Standard seeding order for a bracket of `size` (power of two): 1 v size, then 2 v size-1 etc. */ export function seedOrder(size) { let order = [1]; while (order.length < size) { const len = order.length * 2; const next = []; for (const s of order) next.push(s, len + 1 - s); order = next; } return order; } /** * Single elimination with byes. `seeded` is team ids in seed order (best first). * Returns { matches, size }. Byes are resolved immediately (status 'bye', winner set) and * propagated into the next round. */ export function singleElimination(seeded, { thirdPlace = false } = {}) { const size = 1 << Math.ceil(Math.log2(Math.max(2, seeded.length))); const rounds = Math.log2(size); const order = seedOrder(size); const byRound = []; // Round 1 const r1 = []; for (let i = 0; i < size / 2; i++) { const a = seeded[order[2 * i] - 1] ?? null; const b = seeded[order[2 * i + 1] - 1] ?? null; r1.push(makeMatch({ stage: 'bracket', round: 1, slot: i + 1, teamA: a, teamB: b })); } byRound.push(r1); for (let r = 2; r <= rounds; r++) { const prev = byRound[r - 2]; const cur = []; for (let i = 0; i < prev.length / 2; i++) { const m = makeMatch({ stage: 'bracket', round: r, slot: i + 1 }); prev[2 * i].feeds = m.id; prev[2 * i].feedsSide = 'A'; prev[2 * i + 1].feeds = m.id; prev[2 * i + 1].feedsSide = 'B'; cur.push(m); } byRound.push(cur); } const matches = byRound.flat(); if (thirdPlace && rounds >= 2) { const semis = byRound[rounds - 2]; const tp = makeMatch({ stage: 'bracket', round: rounds, slot: 2, label: '3rd place' }); semis[0].loserFeeds = tp.id; semis[0].loserFeedsSide = 'A'; semis[1].loserFeeds = tp.id; semis[1].loserFeedsSide = 'B'; matches.push(tp); } // Resolve byes const index = Object.fromEntries(matches.map(m => [m.id, m])); for (const m of r1) { if (m.teamA && !m.teamB) settleBye(m, m.teamA, index); else if (!m.teamA && m.teamB) settleBye(m, m.teamB, index); } return { matches, size, rounds }; } function settleBye(m, winner, index) { m.status = 'bye'; m.winner = winner; const next = index[m.feeds]; if (next) next[m.feedsSide === 'A' ? 'teamA' : 'teamB'] = winner; } /** * Double elimination for power-of-two sizes (byes are padded like single elim). * Winners bracket + losers bracket + grand final (+ optional reset match if the LB team wins). */ export function doubleElimination(seeded, { resetMatch = true } = {}) { const wb = singleElimination(seeded); const wbRounds = wb.rounds; const wbByRound = Array.from({ length: wbRounds }, (_, r) => wb.matches.filter(m => m.round === r + 1 && m.label !== '3rd place')); const lb = []; // array of rounds, each an array of matches let lbRound = 0; // LB round 1: losers of WB R1 pair up let carry = []; for (let r = 0; r < wbRounds; r++) { const losers = wbByRound[r]; // matches whose losers drop here if (r === 0) { lbRound++; const cur = []; for (let i = 0; i < losers.length / 2; i++) { const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 }); losers[2 * i].loserFeeds = m.id; losers[2 * i].loserFeedsSide = 'A'; losers[2 * i + 1].loserFeeds = m.id; losers[2 * i + 1].loserFeedsSide = 'B'; cur.push(m); } lb.push(cur); carry = cur; } else { // "drop" round: WB losers of round r vs carry winners (reverse order to delay rematches) lbRound++; const cur = []; const rev = [...losers].reverse(); for (let i = 0; i < carry.length; i++) { const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 }); rev[i].loserFeeds = m.id; rev[i].loserFeedsSide = 'A'; carry[i].feeds = m.id; carry[i].feedsSide = 'B'; cur.push(m); } lb.push(cur); carry = cur; if (carry.length > 1) { // "consolidation" round: carry winners pair up lbRound++; const nxt = []; for (let i = 0; i < carry.length / 2; i++) { const m = makeMatch({ stage: 'losers', round: lbRound, slot: i + 1 }); carry[2 * i].feeds = m.id; carry[2 * i].feedsSide = 'A'; carry[2 * i + 1].feeds = m.id; carry[2 * i + 1].feedsSide = 'B'; nxt.push(m); } lb.push(nxt); carry = nxt; } } } const wbFinal = wbByRound[wbRounds - 1][0]; const lbFinal = carry[0]; const gf = makeMatch({ stage: 'final', round: 1, slot: 1, label: 'Grand final' }); wbFinal.feeds = gf.id; wbFinal.feedsSide = 'A'; lbFinal.feeds = gf.id; lbFinal.feedsSide = 'B'; const matches = [...wb.matches, ...lb.flat(), gf]; if (resetMatch) { const reset = makeMatch({ stage: 'final', round: 2, slot: 1, label: 'Bracket reset', conditional: true }); gf.resetMatch = reset.id; matches.push(reset); } // Byes in WB R1 mean LB R1 gets a null loser -> treat as bye there too. const index = Object.fromEntries(matches.map(m => [m.id, m])); for (const m of wbByRound[0]) if (m.status === 'bye') { const l = index[m.loserFeeds]; l[m.loserFeedsSide === 'A' ? 'teamA' : 'teamB'] = '__bye__'; } for (const m of lb[0]) { if (m.teamA === '__bye__' && m.teamB === '__bye__') { m.status = 'void'; } else if (m.teamA === '__bye__') { m.teamA = null; m.pendingBye = 'A'; } else if (m.teamB === '__bye__') { m.teamB = null; m.pendingBye = 'B'; } } return { matches, size: wb.size, wbRounds, lbRounds: lbRound }; }