export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH'; export interface BoardCard { id: string; name: string; descriptionMd: string; categoryId: string; categoryAbbreviation: string; categoryIconPath: string; difficulty: Difficulty; livePoints: number; solveCount: number; solvedByMe: boolean; } export interface CategoryColumn { id: string; abbreviation: string; name: string; iconPath: string; cards: BoardCard[]; } export interface BoardResponse { columns: CategoryColumn[]; solvedChallengeIds: string[]; perChallengeLivePoints: Record; } export interface SolverRow { position: number; playerId: string; playerName: string; solvedAtUtc: string; awardedPoints: number; basePoints: number; rankBonus: number; isFirst: boolean; isSecond: boolean; isThird: boolean; } export interface AwardedSolve { position: number; basePoints: number; rankBonus: number; awardedPoints: number; awardedAtUtc: string; } export interface SolveResponse { status: 'solved' | 'already_solved'; challenge: BoardCard; awarded: AwardedSolve; solvers: SolverRow[]; } export interface SolveEventPayload { challengeId: string; playerId: string; playerName: string; awardedPoints: number; rankBonus: number; awardedAtUtc: string; position: number; } export interface SolveEventLivePayload extends SolveEventPayload { livePoints: number; solveCount: number; initialPoints: number; minimumPoints: number; decaySolves: number; } export interface ChallengeDetail { challenge: BoardCard; solvers: SolverRow[]; } export interface EventStatePayloadLike { state: 'running' | 'countdown' | 'stopped' | 'unconfigured'; serverNowUtc: string; eventStartUtc: string | null; eventEndUtc: string | null; secondsToStart: number | null; secondsToEnd: number | null; } export const DIFFICULTY_RANK: Readonly> = { LOW: 0, MEDIUM: 1, HIGH: 2, }; export function compareDifficulty(a: Difficulty, b: Difficulty): number { return DIFFICULTY_RANK[a] - DIFFICULTY_RANK[b]; } export function sortCardsInColumn(cards: readonly BoardCard[]): BoardCard[] { return [...cards].sort((a, b) => { const d = compareDifficulty(a.difficulty, b.difficulty); if (d !== 0) return d; const an = (a.name ?? '').toLowerCase(); const bn = (b.name ?? '').toLowerCase(); if (an < bn) return -1; if (an > bn) return 1; return 0; }); } export function sortColumnsByAbbrev(columns: readonly CategoryColumn[]): CategoryColumn[] { return [...columns].sort((a, b) => { const al = (a.abbreviation ?? '').toLowerCase(); const bl = (b.abbreviation ?? '').toLowerCase(); if (al < bl) return -1; if (al > bl) return 1; return 0; }); } export function formatDdHhMm(totalSeconds: number): string { if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00:00'; const s = Math.floor(totalSeconds); const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const minutes = Math.floor((s % 3600) / 60); const seconds = s % 60; const pad = (n: number) => n.toString().padStart(2, '0'); return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; } export function formatUtcToLocal(utc: string | null | undefined): string { if (!utc) return ''; const d = new Date(utc); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleString(); } export type SolveErrorCode = | 'not_running' | 'incorrect' | 'already_solved' | 'not_found' | 'unauthorized' | 'forbidden' | 'validation' | 'network' | 'unknown'; export function parseSolveError(status: number, body: any): SolveErrorCode { const code = body?.code as string | undefined; if (code === 'EVENT_NOT_RUNNING') return 'not_running'; if (code === 'FLAG_INCORRECT') return 'incorrect'; if (status === 404 || code === 'NOT_FOUND') return 'not_found'; if (status === 401 || code === 'UNAUTHORIZED') return 'unauthorized'; if (status === 403 || code === 'FORBIDDEN') return 'forbidden'; if (status === 0) return 'network'; if (code === 'VALIDATION_FAILED') return 'validation'; return 'unknown'; } export function parseStatusPayload(raw: any): EventStatePayloadLike { if (!raw || typeof raw !== 'object') { return { state: 'unconfigured', serverNowUtc: new Date().toISOString(), eventStartUtc: null, eventEndUtc: null, secondsToStart: null, secondsToEnd: null, }; } const eventStartUtc = raw.event_start_utc !== undefined ? raw.event_start_utc : raw.eventStartUtc ?? null; const eventEndUtc = raw.event_end_utc !== undefined ? raw.event_end_utc : raw.eventEndUtc ?? null; return { state: (raw.state as EventStatePayloadLike['state']) ?? 'unconfigured', serverNowUtc: raw.server_time_utc ?? raw.serverNowUtc ?? new Date().toISOString(), eventStartUtc, eventEndUtc, secondsToStart: raw.seconds_to_start ?? raw.secondsToStart ?? null, secondsToEnd: raw.seconds_to_end ?? raw.secondsToEnd ?? null, }; } export function parseSolveEvent(raw: any): SolveEventLivePayload { const awardedAtUtc = raw?.awarded_at_utc ?? raw?.awardedAtUtc ?? raw?.solvedAt ?? ''; const playerId = raw?.player_id ?? raw?.playerId ?? raw?.userId ?? ''; return { challengeId: raw?.challenge_id ?? raw?.challengeId ?? '', playerId, playerName: raw?.player_name ?? raw?.playerName ?? '', awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0), rankBonus: Number(raw?.rank_bonus ?? raw?.rankBonus ?? 0), awardedAtUtc, position: Number(raw?.position ?? 0), livePoints: Number(raw?.live_points ?? raw?.livePoints ?? 0), solveCount: Number(raw?.solve_count ?? raw?.solveCount ?? 0), initialPoints: Number(raw?.initial_points ?? raw?.initialPoints ?? 0), minimumPoints: Number(raw?.minimum_points ?? raw?.minimumPoints ?? 0), decaySolves: Number(raw?.decay_solves ?? raw?.decaySolves ?? 0), }; } export function messageForSolveError(code: SolveErrorCode): string { switch (code) { case 'not_running': return 'Submissions are only accepted while the event is running.'; case 'incorrect': return 'Incorrect flag. Try again.'; case 'already_solved': return 'You already solved this challenge.'; case 'not_found': return 'Challenge not found.'; case 'unauthorized': return 'Your session has expired. Please sign in again.'; case 'forbidden': return 'You are not allowed to perform this action.'; case 'network': return 'Network error. Please try again.'; case 'validation': return 'Invalid submission.'; default: return 'Unexpected error. Please try again.'; } } export function mergeSolveEventIntoSolvers( existing: readonly SolverRow[], payload: SolveEventPayload, ): SolverRow[] { if (existing.some((s) => s.playerId === payload.playerId && s.solvedAtUtc === payload.awardedAtUtc)) { return [...existing]; } const newRow: SolverRow = { position: 0, playerId: payload.playerId, playerName: payload.playerName, solvedAtUtc: payload.awardedAtUtc, awardedPoints: payload.awardedPoints, basePoints: 0, rankBonus: payload.rankBonus, isFirst: payload.position === 1, isSecond: payload.position === 2, isThird: payload.position === 3, }; const merged = [...existing, newRow].sort((a, b) => { const at = new Date(a.solvedAtUtc).getTime(); const bt = new Date(b.solvedAtUtc).getTime(); return at - bt; }); return merged.map((r, idx) => ({ ...r, position: idx + 1 })); }