AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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): void {
|
||||
this._myUserId.set(id);
|
||||
}
|
||||
|
||||
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: card.solvedByMe || isMine,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
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: detail.challenge.solvedByMe || isMine,
|
||||
},
|
||||
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 || card.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user