// In-memory registry of tournaments. Every mutation goes through `mutate()`, which // persists the new state and notifies subscribers (the WebSocket layer) with the public view. import { Tournament } from '../engine/tournament.js'; import { CourtEngine } from '../engine/courts.js'; import { publicState } from './public-state.js'; const slugify = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'tournament'; export class Registry { constructor(store) { this.store = store; this.items = new Map(); // slug -> { t, engine } this.subscribers = new Map(); // slug -> Set for (const row of store.loadAll()) { const t = Tournament.fromJSON(row.tournament); t.slug = row.slug; const engine = row.engine ? CourtEngine.fromJSON(t, row.engine) : new CourtEngine(t, { courtCount: t.courtCount }); this._attach(row.slug, t, engine); // Re-run bye settling on load: a tournament saved by an older version may have bracket slots // that could never be filled (the 18-team double-elim case). Cheap and idempotent. if (t.matches.some(m => m.stage !== 'pool') && t.phase !== 'final') { const before = JSON.stringify(t.matches.map(m => [m.status, m.teamA, m.teamB])); t.settleByes(); t._checkComplete(); if (t.phase === 'live') engine.tick(); if (before !== JSON.stringify(t.matches.map(m => [m.status, m.teamA, m.teamB]))) { console.log(`[load] ${row.slug}: settled bracket byes`); this.persist(row.slug); } } } } list() { return [...this.items.entries()].map(([slug, { t }]) => ({ slug, name: t.name, date: t.date, phase: t.phase, teams: t.activeTeams().length, createdAt: t.createdAt })); } get(slug) { return this.items.get(slug) ?? null; } create({ name, date, courtCount = 2, rules = {}, stages = ['pool', 'bracket'], notes = '', timezone = null, slug: wanted = null }) { const base = slugify(wanted ?? name); let slug = base, n = 2; while (this.items.has(slug)) slug = `${base}-${n++}`; const t = new Tournament({ name, courtCount: Number(courtCount), rules, stages }); t.slug = slug; t.date = date || null; t.notes = notes; t.timezone = timezone || null; t.createdAt = Date.now(); const engine = new CourtEngine(t, { courtCount: t.courtCount }); this._attach(slug, t, engine); this.persist(slug); return this.items.get(slug); } remove(slug) { this.items.delete(slug); this.subscribers.delete(slug); this.store.remove(slug); } _attach(slug, t, engine) { this.items.set(slug, { t, engine }); t.on(evt => { try { this.store.logEvent(slug, evt); } catch { /* audit log is best-effort */ } }); } /** Run fn against a tournament, then persist and broadcast. Throws propagate to the caller. */ mutate(slug, fn) { const item = this.get(slug); if (!item) throw new Error('No such tournament'); const result = fn(item.t, item.engine); // keep the queue/on-deck fresh after any change during live play if (item.t.phase === 'live') item.engine.tick(); this.persist(slug); this.notify(slug); return result; } persist(slug) { const { t, engine } = this.get(slug); this.store.save(slug, t.toJSON(), engine.toJSON()); } subscribe(slug, fn) { if (!this.subscribers.has(slug)) this.subscribers.set(slug, new Set()); this.subscribers.get(slug).add(fn); return () => this.subscribers.get(slug)?.delete(fn); } notify(slug) { const subs = this.subscribers.get(slug); if (!subs?.size) return; const state = publicState(this.get(slug)); for (const fn of subs) { try { fn(state); } catch { /* a dead socket must not break the others */ } } } }