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
210 lines
9.7 KiB
JavaScript
210 lines
9.7 KiB
JavaScript
// 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(', ') };
|
||
}
|
||
}
|