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,254 @@
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
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<Record<Difficulty, number>> = {
|
||||
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';
|
||||
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 pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${pad(days)}:${pad(hours)}:${pad(minutes)}`;
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user