// Tournament state: teams, matches, results, standings, withdrawals, bracket propagation. import { makePools, roundRobin, singleElimination, doubleElimination, makeMatch } from './formats.js'; export const DEFAULT_RULES = { teamSize: 2, pointsTo: 21, winBy: 2, cap: 25, bestOf: 1, tiebreaks: ['headToHead', 'setRatio', 'pointRatio', 'pointDiff', 'seed'], }; export class Tournament { constructor({ name, rules = {}, courtCount = 2, stages = ['pool', 'bracket'] } = {}) { this.name = name; this.stages = stages; // which stages the day has: ['pool'], ['bracket'], or both this.rules = { ...DEFAULT_RULES, ...rules }; this.courtCount = courtCount; this.phase = 'checkin'; // checkin | closed | live | final this.teams = new Map(); // id -> { id, name, seed, status, players, poolId } this.matches = []; // ordered list this.pools = []; this.log = []; this._seq = 1; this.listeners = []; } on(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter(f => f !== fn); }; } emit(evt) { this.log.push({ ts: this.log.length, ...evt }); for (const f of this.listeners) f(evt, this); } // ---------- registration ---------- registerTeam({ name, players = 2, captain = null, phone = null, playerNames = [] }, { force = false } = {}) { if (this.phase !== 'checkin' && !force) throw new Error('Registration is closed'); if (force && this.matches.length) throw new Error('Pools or bracket already generated; regenerate after adding a team'); name = String(name ?? '').trim(); if (!name) throw new Error('Team name is required'); if (name.length > 40) throw new Error('Team name must be 40 characters or fewer'); players = Number(players); if (!Number.isInteger(players) || players < 1 || players > 20) throw new Error('Player count must be a whole number between 1 and 20'); if ([...this.teams.values()].some(t => t.name.toLowerCase() === name.toLowerCase())) throw new Error(`Team name "${name}" already taken`); if (players < this.rules.teamSize) throw new Error(`Need at least ${this.rules.teamSize} players for ${this.rules.teamSize}s`); const id = `t${this._seq++}`; const team = { id, name, players, captain, phone, playerNames: playerNames.filter(Boolean), seed: this.teams.size + 1, status: 'registered', poolId: null, code: code4(), registeredAt: this.now?.() ?? Date.now() }; this.teams.set(id, team); this.emit({ type: 'team_registered', team: id, name }); return team; } updateTeam(id, patch) { const t = this.teams.get(id); if (!t) throw new Error('No such team'); if (patch.name !== undefined) { const name = String(patch.name).trim(); if (!name) throw new Error('Team name is required'); if ([...this.teams.values()].some(x => x.id !== id && x.name.toLowerCase() === name.toLowerCase())) throw new Error(`Team name "${name}" already taken`); t.name = name; } for (const k of ['captain', 'phone']) if (patch[k] !== undefined) t[k] = patch[k]; if (patch.players !== undefined) { const p = Number(patch.players); if (!Number.isInteger(p) || p < 1) throw new Error('Bad player count'); t.players = p; } if (patch.playerNames !== undefined) t.playerNames = patch.playerNames.filter(Boolean); this.emit({ type: 'team_updated', team: id, name: t.name }); return t; } removeTeam(id) { if (this.matches.length) throw new Error('Use withdraw once play is generated'); this.teams.delete(id); this.activeTeams().forEach((t, i) => { t.seed = i + 1; }); this.emit({ type: 'team_removed', team: id }); } teamByCode(code) { return [...this.teams.values()].find(t => t.code === String(code).toUpperCase()) ?? null; } closeRegistration() { this.phase = 'closed'; this.emit({ type: 'phase', phase: 'closed' }); } reopenRegistration() { if (this.matches.length) throw new Error('Play already generated'); this.phase = 'checkin'; this.emit({ type: 'phase', phase: 'checkin' }); } setSeeds(orderedIds) { orderedIds.forEach((id, i) => { this.teams.get(id).seed = i + 1; }); } activeTeams() { return [...this.teams.values()].filter(t => t.status === 'registered').sort((a, b) => a.seed - b.seed); } // ---------- generation ---------- generatePools(poolSize = 4) { if (this.matches.some(m => m.stage === 'pool' && m.status === 'final')) throw new Error('Pool play already has results; cannot regenerate'); this.matches = this.matches.filter(m => m.stage !== 'pool'); this.pools = makePools(this.activeTeams().map(t => t.id), poolSize); for (const p of this.pools) { for (const tid of p.teams) this.teams.get(tid).poolId = p.id; this.matches.push(...roundRobin(p.teams, p.id)); } this.emit({ type: 'pools_generated', pools: this.pools.map(p => ({ id: p.id, teams: p.teams.map(t => this.teams.get(t).name) })) }); return this.pools; } /** Top `perPool` from each pool plus `wildcards` best remaining, ranked by pool position then record. */ advanceFromPools({ perPool = 2, wildcards = 0 } = {}) { const qualified = []; const rest = []; for (const p of this.pools) { const table = this.standings(p.id); table.slice(0, perPool).forEach((row, i) => qualified.push({ ...row, rank: i + 1 })); table.slice(perPool).forEach((row, i) => rest.push({ ...row, rank: perPool + i + 1 })); } // seed qualifiers: pool winners first, then runners-up, ordered within a rank by win pct / point ratio const byRank = (a, b) => a.rank - b.rank || b.winPct - a.winPct || b.pointRatio - a.pointRatio; qualified.sort(byRank); rest.sort(byRank); return [...qualified, ...rest.slice(0, wildcards)].map(r => r.teamId); } generateBracket(seededIds, { type = 'single', thirdPlace = false } = {}) { if (this.matches.some(m => m.stage !== 'pool' && m.status === 'final')) throw new Error('Bracket already has results'); this.matches = this.matches.filter(m => m.stage === 'pool'); const res = type === 'double' ? doubleElimination(seededIds) : singleElimination(seededIds, { thirdPlace }); this.matches.push(...res.matches); this.emit({ type: 'bracket_generated', bracket: type, size: res.size, teams: seededIds.map(id => this.teams.get(id).name) }); return res; } // ---------- results ---------- match(id) { return this.matches.find(m => m.id === id); } teamName(id) { return id ? this.teams.get(id)?.name ?? id : 'TBD'; } /** sets: [[a,b], ...]. Validates against rules, sets winner, propagates into bracket. */ recordResult(matchId, sets, { actor = 'organizer', forfeit = null } = {}) { const m = this.match(matchId); if (!m) throw new Error(`No match ${matchId}`); if (!m.teamA || !m.teamB) throw new Error(`Match ${matchId} does not have both teams yet`); if (forfeit) { m.status = 'forfeit'; m.sets = []; m.winner = forfeit === m.teamA ? m.teamB : m.teamA; m.loser = forfeit; } else { let wa = 0, wb = 0; for (const [a, b] of sets) { validateSet(a, b, this.rules); if (a > b) wa++; else wb++; } const need = Math.ceil(this.rules.bestOf / 2); if (wa < need && wb < need) throw new Error(`Best of ${this.rules.bestOf}: no side has ${need} sets`); m.sets = sets; m.status = 'final'; m.winner = wa > wb ? m.teamA : m.teamB; m.loser = wa > wb ? m.teamB : m.teamA; } m.finishedAt = this.now?.() ?? Date.now(); this.emit({ type: 'result', match: m.id, actor, winner: m.winner, score: sets.map(s => s.join('-')).join(', ') || 'forfeit' }); this._propagate(m); return m; } _place(matchId, side, teamId) { if (!matchId) return; const next = this.match(matchId); if (!next) return; next[side === 'A' ? 'teamA' : 'teamB'] = teamId; // losers-bracket bye: the other side was a bye, so this team walks through if (next.pendingBye && next.status === 'pending') { next.status = 'bye'; next.winner = teamId; delete next.pendingBye; this._propagate(next); } } _propagate(m) { if (m.feeds) this._place(m.feeds, m.feedsSide, m.winner); if (m.loserFeeds && m.loser) this._place(m.loserFeeds, m.loserFeedsSide, m.loser); // double-elim grand final: if LB side (B) wins, activate the reset match if (m.label === 'Grand final' && m.resetMatch) { const reset = this.match(m.resetMatch); if (m.winner === m.teamB) { reset.teamA = m.teamA; reset.teamB = m.teamB; reset.conditional = false; } else { reset.status = 'void'; } } if (this.isComplete()) { this.phase = 'final'; this.emit({ type: 'phase', phase: 'final', champion: this.champion() }); } } /** Complete when every configured stage has matches and all of them are decided. */ isComplete() { for (const st of this.stages) { const group = st === 'pool' ? ['pool'] : ['bracket', 'losers', 'final']; if (!this.matches.some(m => group.includes(m.stage))) return false; } return this.matches.every(m => ['final', 'forfeit', 'void', 'bye'].includes(m.status) || m.conditional); } champion() { const finals = this.matches.filter(m => m.stage !== 'pool' && m.stage !== 'losers' && m.winner && !m.conditional && m.label !== '3rd place'); if (!finals.length) return null; // the last decided non-losers match with no `feeds` is the title match const last = finals.filter(m => !m.feeds || !this.match(m.feeds) || this.match(m.feeds).status === 'void').pop(); return last ? this.teamName(last.winner) : null; } // ---------- withdrawal ---------- /** mode: 'forfeit' (remaining games lost 0-pointsTo) | 'void' (all of the team's pool games removed from standings). */ withdrawTeam(teamId, { mode = 'forfeit', actor = 'organizer' } = {}) { const t = this.teams.get(teamId); t.status = 'withdrawn'; t.withdrawalMode = mode; for (const m of this.matches) { if (m.teamA !== teamId && m.teamB !== teamId) continue; if (m.stage === 'pool') { if (m.status === 'final' && mode === 'void') { m.status = 'void'; m.winner = null; m.loser = null; } else if (['pending', 'on_deck', 'live'].includes(m.status)) { if (mode === 'void') { m.status = 'void'; } else this.recordResult(m.id, [], { actor, forfeit: teamId }); m.court = null; } } else if (['pending', 'on_deck', 'live'].includes(m.status)) { // bracket: opponent walks over, if known; otherwise the slot becomes a bye const opp = m.teamA === teamId ? m.teamB : m.teamA; if (opp) this.recordResult(m.id, [], { actor, forfeit: teamId }); else { m[m.teamA === teamId ? 'teamA' : 'teamB'] = null; m.pendingBye = m.teamA === null ? 'A' : 'B'; } m.court = null; } } this.emit({ type: 'team_withdrawn', team: teamId, name: t.name, mode }); } // ---------- standings ---------- standings(poolId) { const pool = this.pools.find(p => p.id === poolId); const rows = new Map(); for (const tid of pool.teams) { const t = this.teams.get(tid); rows.set(tid, { teamId: tid, name: t.name, seed: t.seed, status: t.status, w: 0, l: 0, setsW: 0, setsL: 0, pf: 0, pa: 0, played: 0 }); } const played = this.matches.filter(m => m.poolId === poolId && ['final', 'forfeit'].includes(m.status)); for (const m of played) { const A = rows.get(m.teamA), B = rows.get(m.teamB); const sets = m.status === 'forfeit' ? Array.from({ length: Math.ceil(this.rules.bestOf / 2) }, () => (m.winner === m.teamA ? [this.rules.pointsTo, 0] : [0, this.rules.pointsTo])) : m.sets; for (const [a, b] of sets) { A.pf += a; A.pa += b; B.pf += b; B.pa += a; if (a > b) { A.setsW++; B.setsL++; } else { B.setsW++; A.setsL++; } } A.played++; B.played++; if (m.winner === m.teamA) { A.w++; B.l++; } else { B.w++; A.l++; } } const list = [...rows.values()].map(r => ({ ...r, winPct: r.played ? r.w / r.played : 0, setRatio: r.setsL ? r.setsW / r.setsL : (r.setsW ? Infinity : 0), pointRatio: r.pa ? r.pf / r.pa : (r.pf ? Infinity : 0), pointDiff: r.pf - r.pa, decidedBy: null, })); // Withdrawn teams sink to the bottom. Active teams are grouped by win% and each tied // group is broken by the ordered tiebreak list; head-to-head is computed as a // mini-league among only the tied teams, so a circular 3-way tie falls through. const active = list.filter(r => r.status !== 'withdrawn'); const withdrawn = list.filter(r => r.status === 'withdrawn').sort((a, b) => b.winPct - a.winPct); const byPct = groupBy(active.sort((a, b) => b.winPct - a.winPct), r => r.winPct); const ranked = byPct.flatMap(g => this._breakTie(g, played, 0)); return [...ranked, ...withdrawn]; } _breakTie(group, played, ruleIdx) { if (group.length < 2) return group; if (ruleIdx >= this.rules.tiebreaks.length) return group; const rule = this.rules.tiebreaks[ruleIdx]; const ids = new Set(group.map(r => r.teamId)); let key; if (rule === 'headToHead') { const wins = new Map(group.map(r => [r.teamId, 0])); for (const m of played) if (ids.has(m.teamA) && ids.has(m.teamB) && m.winner) wins.set(m.winner, wins.get(m.winner) + 1); key = r => wins.get(r.teamId); } else if (rule === 'setRatio') key = r => r.setRatio; else if (rule === 'pointRatio') key = r => r.pointRatio; else if (rule === 'pointDiff') key = r => r.pointDiff; else if (rule === 'seed') key = r => -r.seed; else throw new Error(`Unknown tiebreak ${rule}`); const sorted = [...group].sort((a, b) => key(b) - key(a)); const sub = groupBy(sorted, key); if (sub.length === 1) return this._breakTie(group, played, ruleIdx + 1); // rule didn't separate anyone const sameRecord = group.every(r => r.w === group[0].w && r.l === group[0].l); return sub.flatMap(g => { if (g.length < group.length && sameRecord) for (const r of g) r.decidedBy = r.decidedBy ?? rule; return this._breakTie(g, played, ruleIdx + 1); }); } bracketMatches(stage = 'bracket') { return this.matches.filter(m => m.stage === stage); } // ---------- persistence ---------- toJSON() { return { name: this.name, slug: this.slug ?? null, date: this.date ?? null, notes: this.notes ?? null, rules: this.rules, courtCount: this.courtCount, stages: this.stages, phase: this.phase, teams: [...this.teams.values()], matches: this.matches, pools: this.pools, log: this.log.slice(-500), seq: this._seq, banner: this.banner ?? null, createdAt: this.createdAt ?? null, }; } static fromJSON(j) { const t = new Tournament({ name: j.name, rules: j.rules, courtCount: j.courtCount, stages: j.stages }); Object.assign(t, { slug: j.slug, date: j.date, notes: j.notes, phase: j.phase, matches: j.matches, pools: j.pools, log: j.log ?? [], _seq: j.seq ?? 1, banner: j.banner ?? null, createdAt: j.createdAt ?? null }); t.teams = new Map((j.teams ?? []).map(x => [x.id, x])); return t; } /** Public-safe team view (no phone numbers, no private codes). */ publicTeam(id) { const t = this.teams.get(id); return t ? { id: t.id, name: t.name, players: t.players, seed: t.seed, status: t.status, poolId: t.poolId } : null; } } export function validateSet(a, b, rules) { const { pointsTo, winBy, cap } = rules; const hi = Math.max(a, b), lo = Math.min(a, b); if (a === b) throw new Error(`Set cannot end tied ${a}-${b}`); if (hi < pointsTo) throw new Error(`Winner must reach ${pointsTo} (got ${hi}-${lo})`); if (cap && hi === cap) { if (hi - lo < 1) throw new Error('bad cap score'); return true; } if (cap && hi > cap) throw new Error(`Score exceeds cap ${cap}`); if (hi - lo < winBy) throw new Error(`Must win by ${winBy} (got ${hi}-${lo})`); if (hi > pointsTo && hi - lo > winBy) throw new Error(`${hi}-${lo}: set should have ended at ${lo + winBy}-${lo}`); return true; } function groupBy(sortedList, key) { const out = []; for (const item of sortedList) { const k = key(item); if (out.length && key(out[out.length - 1][0]) === k) out[out.length - 1].push(item); else out.push([item]); } return out; } function code4() { const s = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; return Array.from({ length: 4 }, () => s[Math.floor(Math.random() * s.length)]).join(''); }