Files
courtside/engine/courts.js
T
bkvargyasandClaude Fable 5.1 ae44014e22 Tentative next-up for bracket matches waiting on live courts; per-court score keeper QR codes
Matches whose opponent comes from a live match now show in the queue and under
courts as 'X vs winner on Court 2 · ready ~time', which is what double
elimination needed. Score keeper access is a QR per court on the desk plus a
printable page; the copy-link button is gone. Release 0.4.1.

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

226 lines
11 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), tentative: !!x.tentative, aFrom: x.aFrom ?? null, bFrom: x.bFrom ?? null })),
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);
}
// Bracket matches still waiting on a live match (one or both sides TBD) get a tentative slot too,
// so late in a bracket the board still says "Net Gains vs winner on Court 2 · ready ~2:10".
const decided = x => ['final', 'forfeit', 'bye', 'void'].includes(x.status);
const liveCourt = x => this.courts.find(c => c.matchId === x.id)?.number ?? null;
for (const m of this.t.matches) {
if (m.status !== 'pending' || m.conditional || m.stage === 'pool' || (m.teamA && m.teamB)) continue;
const feeders = this.t.matches.filter(x => x.feeds === m.id || x.loserFeeds === m.id);
if (!feeders.length || !feeders.every(x => decided(x) || x.status === 'live') || !feeders.some(x => x.status === 'live')) continue;
const side = (which) => { const f = feeders.find(x => (x.feeds === m.id && x.feedsSide === which) || (x.loserFeeds === m.id && x.loserFeedsSide === which)); return f && f.status === 'live' ? { court: liveCourt(f), loser: f.loserFeeds === m.id } : null; };
const waitOn = feeders.filter(x => x.status === 'live').map(x => this._expectedFinish(this.courts.find(c => c.matchId === x.id)));
sims.sort((a, b) => a.freeAt - b.freeAt);
const ready = Math.max(sims[0].freeAt, ...waitOn, m.teamA ? teamFree.get(m.teamA) ?? 0 : 0, m.teamB ? teamFree.get(m.teamB) ?? 0 : 0);
const start = ready + this.changeoverMs;
out.push({ match: m, court: sims[0].number, eta: start - this.now(), tentative: true, aFrom: m.teamA ? null : side('A'), bFrom: m.teamB ? null : side('B') });
sims[0].freeAt = start + this.avgMatchMs();
}
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().filter(x => !x.tentative);
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(', ') };
}
}