// 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); } } 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 = '', 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.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 */ } } } }