256 lines
7.5 KiB
TypeScript
256 lines
7.5 KiB
TypeScript
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: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 }));
|
|
}
|