Files
courtside/engine/courts.js
T
bkvargyasandClaude Fable 5.1 eb58eb9ad9 Courtside Manager score keeper page per court; Phase below Courts on the desk; estimated finish on court cards
A per-court signed link from the desk opens a full-screen scoring page with
big +/- buttons, undo, best-of sets, and a confirmed Finalize step; it follows
whatever match is on the court and can never edit a finished result. Public
court cards show the estimated finish time. Release 0.4.0.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
2026-09-04 02:10:40 +00:00

210 lines
9.7 KiB
JavaScript
Raw Permalink 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;
// ignore implausible durations (a match left open over lunch, a clock jump) so the ETA stays useful
if (m.status === 'final' && dur >= 2 * MIN && dur <= 90 * MIN) 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(', ') };
}
}