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