Files
courtside/engine/courts.js
T
bkvargyasandClaude Fable 5.1 5ade592384 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
2026-09-03 20:31:46 +00:00

209 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(', ') };
}
}