93 lines
4.5 KiB
TypeScript
93 lines
4.5 KiB
TypeScript
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 } : {}) })) };
|
|
}
|