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(', ') };
}
}