Tournament engine (pools, single/double elimination, standings with proper tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process Node server with SQLite via node:sqlite and a WebSocket live board, organizer desk, QR landing page that follows the tournament phase, Dockerfile and compose with cloudflared, and documentation for deploying and running a day. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
324 lines
16 KiB
JavaScript
324 lines
16 KiB
JavaScript
// 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('');
|
|
}
|