351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
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<BoardResponse | null>(null);
|
|
private readonly _eventState = signal<EventStatePayloadLike['state']>('unconfigured');
|
|
private readonly _secondsToStart = signal<number | null>(null);
|
|
private readonly _secondsToEnd = signal<number | null>(null);
|
|
private readonly _loading = signal(false);
|
|
private readonly _error = signal<string | null>(null);
|
|
private readonly _tick = signal(0);
|
|
private readonly _myUserId = signal<string | null>(null);
|
|
private readonly _selectedDetail = signal<ChallengeDetail | null>(null);
|
|
|
|
private readonly solveListeners = new Map<string, Set<(p: SolveEventLivePayload) => 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<CategoryColumn[]>(() => {
|
|
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<number | null>(() => {
|
|
void this._tick();
|
|
const state = this._eventState();
|
|
if (state === 'countdown') return this._secondsToStart();
|
|
if (state === 'running') return this._secondsToEnd();
|
|
return null;
|
|
});
|
|
|
|
private intervalId: ReturnType<typeof setInterval> | 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<void> {
|
|
this._loading.set(true);
|
|
this._error.set(null);
|
|
try {
|
|
const board = await new Promise<BoardResponse>((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<EventStatePayloadLike> {
|
|
return this.api.getEventState();
|
|
}
|
|
|
|
async loadDetail(id: string): Promise<ChallengeDetail | null> {
|
|
try {
|
|
const detail = await new Promise<ChallengeDetail>((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<SolveResponse> {
|
|
return new Promise<SolveResponse>((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);
|
|
}
|
|
}
|