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:
@@ -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(', ') };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
Reference in New Issue
Block a user