Files
healer-man/src/game/dungeonRunTypes.ts
T
2026-09-27 21:48:00 -04:00

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 } : {}) })) };
}