import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core'; import { Observable } from 'rxjs'; import { BoardCard, BoardResponse, CategoryColumn, ChallengeDetail, EventStatePayloadLike, SolveEventLivePayload, SolveEventPayload, SolveResponse, SolverRow, mergeSolveEventIntoSolvers, parseSolveEvent, sortCardsInColumn, sortColumnsByAbbrev, } from './challenges.pure'; import { ChallengesApiService } from './challenges.service'; import { EventSourceLike } from '../../core/services/event-status.pure'; @Injectable({ providedIn: 'root' }) export class ChallengesStore { private readonly api: ChallengesApiService; private readonly destroyRef: DestroyRef; private readonly _board = signal(null); private readonly _eventState = signal('unconfigured'); private readonly _secondsToStart = signal(null); private readonly _secondsToEnd = signal(null); private readonly _loading = signal(false); private readonly _error = signal(null); private readonly _tick = signal(0); private readonly _myUserId = signal(null); private readonly _selectedDetail = signal(null); private readonly solveListeners = new Map void>>(); constructor(api?: ChallengesApiService, destroyRef?: DestroyRef) { this.api = api ?? inject(ChallengesApiService); this.destroyRef = destroyRef ?? inject(DestroyRef); this.destroyRef.onDestroy(() => this.stop()); } readonly board = this._board.asReadonly(); readonly loading = this._loading.asReadonly(); readonly error = this._error.asReadonly(); readonly eventState = this._eventState.asReadonly(); readonly selectedChallengeDetail = this._selectedDetail.asReadonly(); readonly sortedColumns = computed(() => { const b = this._board(); if (!b) return []; return sortColumnsByAbbrev( b.columns.map((col) => ({ ...col, cards: sortCardsInColumn(col.cards), })), ); }); readonly isRunning = computed(() => this._eventState() === 'running'); readonly countdownSeconds = computed(() => { void this._tick(); const state = this._eventState(); if (state === 'countdown') return this._secondsToStart(); if (state === 'running') return this._secondsToEnd(); return null; }); private intervalId: ReturnType | null = null; private sse: EventSourceLike | null = null; private countdownZeroHandler: (() => void) | null = null; setMyUserId(id: string | null): string | null { const prev = this._myUserId(); const hasCachedUserData = this._board() !== null || this._selectedDetail() !== null; if (hasCachedUserData && prev !== id) { this.reset(); } this._myUserId.set(id); return prev; } reset(): void { this.stop(); this._board.set(null); this._selectedDetail.set(null); this._myUserId.set(null); this._error.set(null); this._loading.set(false); this.countdownZeroHandler = null; this.solveListeners.clear(); } async load(): Promise { this._loading.set(true); this._error.set(null); try { const board = await new Promise((resolve, reject) => { this.api.getBoard().subscribe({ next: resolve, error: reject }); }); this._board.set(board); } catch (err: any) { this._error.set(err?.message ?? 'Failed to load challenges'); } finally { this._loading.set(false); } } fetchEventState(): Observable { return this.api.getEventState(); } async loadDetail(id: string): Promise { try { const detail = await new Promise((resolve, reject) => { this.api.getDetail(id).subscribe({ next: resolve, error: reject }); }); this._selectedDetail.set(detail); this.applySubmitResponse({ status: 'solved', challenge: detail.challenge, awarded: { position: 0, basePoints: 0, rankBonus: 0, awardedPoints: 0, awardedAtUtc: '', }, solvers: detail.solvers, }); return detail; } catch { this._selectedDetail.set(null); return null; } } clearSelectedDetail(): void { this._selectedDetail.set(null); } registerSolveListener(challengeId: string, listener: (p: SolveEventLivePayload) => void): () => void { const set = this.solveListeners.get(challengeId) ?? new Set(); set.add(listener); this.solveListeners.set(challengeId, set); return () => { const cur = this.solveListeners.get(challengeId); if (!cur) return; cur.delete(listener); if (cur.size === 0) this.solveListeners.delete(challengeId); }; } applyStatusPayload(payload: EventStatePayloadLike): void { this._eventState.set(payload.state); this._secondsToStart.set(payload.secondsToStart); this._secondsToEnd.set(payload.secondsToEnd); } wireSse(createSource: () => EventSourceLike, onUnauthorized?: () => void): void { this.stop(); const src = createSource(); this.sse = src; // The challenges store only cares about 'solve' frames. The status stream is // consumed separately by EventStatusStore (via its own 'message' listener). // Registering on multiple event names would cause each frame to be dispatched // twice (the transport fan-outs to both the named listener and 'message'). const handler = (ev: MessageEvent | Event) => this.handleSseFrame(ev); src.addEventListener('solve', handler); if (onUnauthorized) { src.addEventListener('unauthorized', () => { try { onUnauthorized(); } catch { // ignore } }); } if (!this.intervalId) { this.intervalId = setInterval(() => this._tick.update((v) => v + 1), 1000); } queueMicrotask(() => this.checkCountdownZero()); } /** Exposed for tests; safe to call externally as it is read-only on state. */ handleSseFrame(ev: MessageEvent | Event): void { const me = ev as MessageEvent; if (me.type !== 'solve') return; try { const raw = typeof me.data === 'string' ? JSON.parse(me.data) : me.data; if (!raw) return; const payload = parseSolveEvent(raw); this.applySolveEvent(payload); const listeners = this.solveListeners.get(payload.challengeId); if (listeners) { for (const fn of listeners) { try { fn(payload); } catch { /* ignore */ } } } } catch { // ignore malformed frame } } onCountdownZero(handler: () => void): void { this.countdownZeroHandler = handler; } private checkCountdownZero(): void { const state = this._eventState(); if (state === 'countdown') { const s = this._secondsToStart(); if (s !== null && s <= 0 && this.countdownZeroHandler) { this.countdownZeroHandler(); } } else if (state === 'running') { const s = this._secondsToEnd(); if (s !== null && s <= 0 && this.countdownZeroHandler) { this.countdownZeroHandler(); } } if (this.intervalId) { setTimeout(() => this.checkCountdownZero(), 1000); } } applySolveEvent(payload: SolveEventLivePayload): void { const board = this._board(); const me = this._myUserId(); const isMine = me !== null && payload.playerId === me; if (board) { const updated: BoardResponse = { ...board, columns: board.columns.map((col) => ({ ...col, cards: col.cards.map((card): BoardCard => { if (card.id !== payload.challengeId) return card; return { ...card, livePoints: payload.livePoints ?? card.livePoints, solveCount: payload.solveCount ?? card.solveCount + 1, solvedByMe: isMine ? true : card.solvedByMe, }; }), })), solvedChallengeIds: isMine && !board.solvedChallengeIds.includes(payload.challengeId) ? [...board.solvedChallengeIds, payload.challengeId] : board.solvedChallengeIds, perChallengeLivePoints: payload.livePoints != null ? { ...board.perChallengeLivePoints, [payload.challengeId]: payload.livePoints } : board.perChallengeLivePoints, }; this._board.set(updated); } const detail = this._selectedDetail(); if (detail && detail.challenge.id === payload.challengeId) { const livePoints = payload.livePoints ?? detail.challenge.livePoints; const solveCount = payload.solveCount ?? detail.challenge.solveCount + 1; this._selectedDetail.set({ challenge: { ...detail.challenge, livePoints, solveCount, solvedByMe: isMine ? true : detail.challenge.solvedByMe, }, solvers: mergeSolveEventIntoSolvers(detail.solvers, payload), }); } } applySubmitResponse(response: SolveResponse): void { const board = this._board(); if (board) { const updated: BoardResponse = { ...board, columns: board.columns.map((col) => ({ ...col, cards: col.cards.map((card): BoardCard => { if (card.id !== response.challenge.id) return card; return { ...card, livePoints: response.challenge.livePoints, solveCount: response.challenge.solveCount, solvedByMe: response.challenge.solvedByMe, }; }), })), solvedChallengeIds: response.challenge.solvedByMe && !board.solvedChallengeIds.includes(response.challenge.id) ? [...board.solvedChallengeIds, response.challenge.id] : board.solvedChallengeIds, perChallengeLivePoints: { ...board.perChallengeLivePoints, [response.challenge.id]: response.challenge.livePoints, }, }; this._board.set(updated); } } findCard(id: string): BoardCard | null { const board = this._board(); if (!board) return null; for (const col of board.columns) { const card = col.cards.find((c) => c.id === id); if (card) return card; } return null; } stop(): void { if (this.sse) { try { this.sse.close(); } catch { // ignore } this.sse = null; } if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } } async submit(challengeId: string, flag: string): Promise { return new Promise((resolve, reject) => { this.api.submit(challengeId, flag).subscribe({ next: (resp) => { this.applySubmitResponse(resp); resolve(resp); }, error: (err) => reject(err), }); }); } buildSolversFromResponse(solvers: SolverRow[]): SolverRow[] { return [...solvers].sort((a, b) => { const at = new Date(a.solvedAtUtc).getTime(); const bt = new Date(b.solvedAtUtc).getTime(); return at - bt; }).map((s, idx) => ({ ...s, position: idx + 1 })); } mergeLiveSolver(solvers: SolverRow[], payload: SolveEventPayload): SolverRow[] { return mergeSolveEventIntoSolvers(solvers, payload); } }