import { validBossDifficulty } from "./dungeonDifficulty"; import { normalizeBiteSizedSelection, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs"; export interface DungeonRunMember { readonly name: string; readonly classId: string; readonly level: number; readonly secondaryClassId?: string; readonly role: string; readonly npc: boolean; } export interface DungeonRunProgress { readonly biteSizedSelection?: BiteSizedRunSelection; readonly id: string; readonly dungeonId: string; readonly difficultyId: string; readonly bossDifficulty?: number; readonly startedAt: number | null; readonly finishedAt: number | null; readonly durationMs: number | null; readonly timed: boolean; readonly killedBossIds: readonly string[]; readonly party: readonly DungeonRunMember[]; } export interface DungeonRunRecord { readonly biteSizedSelection?: BiteSizedRunSelection; readonly id: string; readonly dungeonId: string; readonly difficultyId: string; readonly bossDifficulty?: number; readonly durationMs: number; readonly finishedAt: number; readonly killedBossIds: readonly string[]; readonly party: readonly DungeonRunMember[]; readonly fullClear: boolean; } export type DungeonClearFilter = "full" | "final"; export interface RankedDungeonRun extends DungeonRunRecord { readonly rank: number } export interface DungeonLeaderboard { readonly entries: readonly RankedDungeonRun[]; readonly total: number; } export interface DungeonRunPlacement { readonly finalRank: number; readonly finalTotal: number; readonly fullRank: number | null; readonly fullTotal: number; } export function formatDungeonDuration(durationMs: number): string { const tenths = Math.floor(Math.max(0, durationMs) / 100); const seconds = Math.floor(tenths / 10); const minutes = Math.floor(seconds / 60); return `${String(minutes).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${tenths % 10}`; } /** Untrusted checkpoint/local-storage data must never reset or invent a ranked timer. */ export function normalizeDungeonRunProgress(value: unknown): DungeonRunProgress | null { if (!value || typeof value !== "object") return null; const input = value as DungeonRunProgress; const text = (entry: unknown) => typeof entry === "string" && entry.length > 0 && entry.length <= 100; const time = (entry: unknown) => entry === null || (typeof entry === "number" && Number.isFinite(entry) && entry >= 0); if (!text(input.id) || !text(input.dungeonId) || !text(input.difficultyId) || (input.bossDifficulty !== undefined && !validBossDifficulty(input.bossDifficulty)) || !time(input.startedAt) || !time(input.finishedAt) || !time(input.durationMs) || typeof input.timed !== "boolean" || !Array.isArray(input.killedBossIds) || input.killedBossIds.length > 100 || !input.killedBossIds.every(text) || !Array.isArray(input.party) || input.party.length > 5 || !input.party.every((member) => member && text(member.name) && text(member.classId) && Number.isInteger(member.level) && member.level >= 1 && member.level <= 80 && ["tank", "healer", "damage"].includes(member.role) && typeof member.npc === "boolean" && (member.secondaryClassId === undefined || text(member.secondaryClassId))) || (input.finishedAt !== null && (input.startedAt === null || input.finishedAt < input.startedAt)) || (input.durationMs !== null && (input.finishedAt === null || input.durationMs < 1)) || (input.finishedAt !== null && (input.durationMs === null || input.party.length === 0 || input.durationMs !== Math.max(1, Math.round(input.finishedAt - input.startedAt!))))) return null; let biteSizedSelection: BiteSizedRunSelection | undefined; try { if (input.biteSizedSelection !== undefined) biteSizedSelection = normalizeBiteSizedSelection(input.biteSizedSelection); } catch { return null; } if (biteSizedSelection && biteSizedSelection.dungeonId !== input.dungeonId) return null; return { id: input.id, dungeonId: input.dungeonId, difficultyId: input.difficultyId, ...(biteSizedSelection ? { biteSizedSelection } : {}), ...(input.bossDifficulty !== undefined ? { bossDifficulty: input.bossDifficulty } : {}), startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs, timed: input.timed, killedBossIds: [...new Set(input.killedBossIds)], party: input.party.map((member) => ({ name: member.name, classId: member.classId, level: member.level, role: member.role, npc: member.npc, ...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId } : {}) })) }; }