Files
HIPCTF2/frontend/src/app/features/challenges/challenges.store.ts
T
2026-07-23 03:47:08 +00:00

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);
}
}