Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs

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
This commit is contained in:
2026-09-03 20:31:46 +00:00
co-authored by Claude Fable 5.1
commit 5ade592384
39 changed files with 3103 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
// Court engine: decides which match goes on which court, keeps an "on deck" queue with ETAs,
// and emits alert events. Everything is recomputed from state on every change.
const STAGE_ORDER = { pool: 0, bracket: 1, losers: 1, final: 2 };
const MIN = 60_000;
export class CourtEngine {
constructor(tournament, { courtCount = tournament.courtCount ?? 2, now = () => Date.now(), defaultMatchMinutes = 15, changeoverMinutes = 3 } = {}) {
this.t = tournament;
this.now = now;
tournament.now = now;
this.courts = Array.from({ length: courtCount }, (_, i) => ({ number: i + 1, status: 'open', matchId: null, startedAt: null, freeAt: null }));
this.defaultMatchMs = defaultMatchMinutes * MIN;
this.changeoverMs = changeoverMinutes * MIN;
this.durations = []; // observed match durations (ms)
this.lastPlayed = new Map(); // teamId -> finishedAt
this.alerted = new Set(); // `${matchId}:${kind}` dedupe
this.bumped = new Map(); // matchId -> extra priority (push back)
this.alerts = [];
}
// ---------- public API ----------
start() { this.t.phase = 'live'; this.t.emit({ type: 'phase', phase: 'live' }); this.tick(); }
recordResult(matchId, sets, opts) {
const m = this.t.match(matchId);
const court = this.courts.find(c => c.matchId === matchId);
this.t.recordResult(matchId, sets, opts);
if (court) {
const dur = this.now() - court.startedAt;
if (m.status === 'final') this.durations.push(dur);
this._freeCourt(court);
}
for (const tid of [m.teamA, m.teamB]) this.lastPlayed.set(tid, this.now());
this.tick();
}
withdrawTeam(teamId, opts) {
for (const c of this.courts) {
const m = c.matchId && this.t.match(c.matchId);
if (m && (m.teamA === teamId || m.teamB === teamId)) this._freeCourt(c);
}
this.t.withdrawTeam(teamId, opts);
// clear on-deck flags on anything that got voided/forfeited
for (const m of this.t.matches) if (m.status !== 'pending' && m.status !== 'live') m.court = null;
this.tick();
}
pauseCourt(n, reason = '') {
const c = this._court(n); c.status = 'paused'; c.pauseReason = reason;
this.t.emit({ type: 'court_paused', court: n, reason });
this.tick();
}
resumeCourt(n) { const c = this._court(n); c.status = 'open'; delete c.pauseReason; this.t.emit({ type: 'court_resumed', court: n }); this.tick(); }
/** Move a live match to another (free) court, e.g. sun/wind fairness. */
swapToCourt(matchId, n) {
const from = this.courts.find(c => c.matchId === matchId);
const to = this._court(n);
if (!from || to.matchId) throw new Error('Target court is busy or match is not live');
to.matchId = from.matchId; to.startedAt = from.startedAt; to.freeAt = from.freeAt;
from.matchId = null; from.startedAt = null; from.freeAt = null;
this.t.match(matchId).court = n;
this.t.emit({ type: 'court_swapped', match: matchId, from: from.number, to: n });
this.tick();
}
/** Push a pending match back `n` places in the queue (team not back from lunch, etc). */
pushBack(matchId, n = 2) {
this.bumped.set(matchId, (this.bumped.get(matchId) ?? 0) + n);
const m = this.t.match(matchId);
if (m.status === 'on_deck') { m.status = 'pending'; m.court = null; }
this.t.emit({ type: 'match_pushed_back', match: matchId, places: n });
this.tick();
}
broadcast(text) { this._alert({ kind: 'broadcast', text, teams: this.t.activeTeams().map(t => t.id) }); }
/** The main loop. Assign free courts, refresh on-deck, emit alerts. Idempotent. */
tick() {
if (this.t.phase !== 'live') return;
let assigned = true;
while (assigned) {
assigned = false;
const free = this.courts.find(c => c.status === 'open' && !c.matchId);
if (!free) break;
const next = this._eligible(this._busyTeams()).find(m => m.status !== 'live');
if (!next) break;
this._assign(next, free);
assigned = true;
}
this._refreshOnDeck();
}
/** Public board data. */
board() {
const q = this.queue();
return {
phase: this.t.phase,
courts: this.courts.map(c => {
const m = c.matchId ? this.t.match(c.matchId) : null;
return { court: c.number, status: c.status, match: m ? this._matchView(m) : null };
}),
upNext: q.slice(0, 3).map(x => ({ ...this._matchView(x.match), court: x.court, etaMin: Math.round(x.eta / MIN) })),
avgMatchMin: Math.round(this.avgMatchMs() / MIN),
};
}
/** Ordered queue of pending matches with a predicted court and ETA each. */
queue() {
const sims = this.courts.filter(c => c.status === 'open').map(c => ({ number: c.number, freeAt: c.matchId ? this._expectedFinish(c) : this.now() }));
if (!sims.length) return this._eligible(new Set()).map(m => ({ match: m, court: null, eta: Infinity }));
const teamFree = new Map(this.courts.filter(c => c.matchId).flatMap(c => { const m = this.t.match(c.matchId); const f = this._expectedFinish(c); return [[m.teamA, f], [m.teamB, f]]; }));
const out = [];
for (const m of this._eligible(new Set())) {
sims.sort((a, b) => a.freeAt - b.freeAt);
const ready = Math.max(sims[0].freeAt, teamFree.get(m.teamA) ?? 0, teamFree.get(m.teamB) ?? 0);
const start = ready + this.changeoverMs;
out.push({ match: m, court: sims[0].number, eta: start - this.now() });
sims[0].freeAt = start + this.avgMatchMs();
teamFree.set(m.teamA, sims[0].freeAt); teamFree.set(m.teamB, sims[0].freeAt);
}
return out;
}
avgMatchMs() {
if (!this.durations.length) return this.defaultMatchMs;
const recent = this.durations.slice(-6);
return recent.reduce((a, b) => a + b, 0) / recent.length;
}
// ---------- persistence ----------
toJSON() {
return {
courts: this.courts, durations: this.durations, lastPlayed: [...this.lastPlayed], alerted: [...this.alerted],
bumped: [...this.bumped], alerts: this.alerts.slice(-200), defaultMatchMs: this.defaultMatchMs, changeoverMs: this.changeoverMs,
};
}
static fromJSON(tournament, j, opts = {}) {
const e = new CourtEngine(tournament, { courtCount: j.courts.length, ...opts });
e.courts = j.courts; e.durations = j.durations ?? []; e.lastPlayed = new Map(j.lastPlayed ?? []);
e.alerted = new Set(j.alerted ?? []); e.bumped = new Map(j.bumped ?? []); e.alerts = j.alerts ?? [];
e.defaultMatchMs = j.defaultMatchMs ?? e.defaultMatchMs; e.changeoverMs = j.changeoverMs ?? e.changeoverMs;
return e;
}
// ---------- internals ----------
_court(n) { const c = this.courts.find(c => c.number === n); if (!c) throw new Error(`No court ${n}`); return c; }
_busyTeams() { return new Set(this.courts.filter(c => c.matchId).flatMap(c => { const m = this.t.match(c.matchId); return [m.teamA, m.teamB]; })); }
_expectedFinish(c) { return Math.max(this.now(), c.startedAt + this.avgMatchMs()); }
/** Pending matches that could be played now, in priority order. */
_eligible(busy) {
const rest = tid => this.lastPlayed.get(tid) ?? 0;
const bracketReady = m => this.t.matches.filter(x => x.feeds === m.id || x.loserFeeds === m.id).every(x => ['final','forfeit','bye','void'].includes(x.status));
return this.t.matches
.filter(m => (m.status === 'pending' || m.status === 'on_deck') && m.teamA && m.teamB && !busy.has(m.teamA) && !busy.has(m.teamB) && !m.conditional)
.filter(m => m.stage === 'pool' || bracketReady(m))
.map(m => ({ m, key: [STAGE_ORDER[m.stage], m.round, this.bumped.get(m.id) ?? 0, Math.max(rest(m.teamA), rest(m.teamB)), m.slot ?? 0] }))
.sort((a, b) => { for (let i = 0; i < a.key.length; i++) if (a.key[i] !== b.key[i]) return a.key[i] - b.key[i]; return 0; })
.map(x => x.m);
}
_assign(m, court) {
court.matchId = m.id; court.startedAt = this.now();
m.status = 'live'; m.court = court.number; m.startedAt = court.startedAt;
this.t.emit({ type: 'match_started', match: m.id, court: court.number, a: this.t.teamName(m.teamA), b: this.t.teamName(m.teamB) });
this._alert({ kind: 'up_now', match: m.id, court: court.number, teams: [m.teamA, m.teamB] });
}
_freeCourt(c) { c.matchId = null; c.startedAt = null; c.freeAt = null; }
_refreshOnDeck() {
const q = this.queue();
const deck = q.slice(0, this.courts.filter(c => c.status === 'open').length);
const deckIds = new Set(deck.map(x => x.match.id));
for (const m of this.t.matches) if (m.status === 'on_deck' && !deckIds.has(m.id)) { m.status = 'pending'; m.court = null; }
for (const x of deck) {
const m = x.match;
if (m.status !== 'on_deck') {
m.status = 'on_deck'; m.court = x.court;
this._alert({ kind: 'on_deck', match: m.id, court: x.court, etaMin: Math.round(x.eta / MIN), teams: [m.teamA, m.teamB] });
}
}
}
_alert(a) {
const key = a.kind === 'broadcast' ? null : `${a.match}:${a.kind}`;
if (key && this.alerted.has(key)) return;
if (key) this.alerted.add(key);
const msg = this._message(a);
this.alerts.push({ ...a, text: msg, at: this.now() });
this.t.emit({ type: 'alert', ...a, text: msg });
}
_message(a) {
if (a.kind === 'broadcast') return a.text;
const m = this.t.match(a.match);
const names = [this.t.teamName(m.teamA), this.t.teamName(m.teamB)];
if (a.kind === 'up_now') return `${names[0]} vs ${names[1]}: you're UP NOW on Court ${a.court}.`;
return `${names[0]} vs ${names[1]}: you're ON DECK for Court ${a.court} (~${a.etaMin} min).`;
}
_matchView(m) {
return { id: m.id, stage: m.stage, round: m.round, label: m.label ?? null, a: this.t.teamName(m.teamA), b: this.t.teamName(m.teamB), status: m.status, court: m.court, score: m.sets.map(s => s.join('')).join(', ') };
}
}
+190
View File
@@ -0,0 +1,190 @@
// 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 };
}
+323
View File
@@ -0,0 +1,323 @@
// 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('');
}