import { create } from "zustand"; import { dungeonDefinitionById } from "./dungeonRegistry"; import type { DungeonDefinition } from "./dungeonTypes"; import type { DungeonRunMember, DungeonRunProgress, DungeonRunRecord } from "./dungeonRunTypes"; export function dungeonBossesForDifficulty(definition: DungeonDefinition, difficultyId = definition.defaultDifficultyId) { const difficulty = definition.difficultyVariants.find((variant) => variant.id === difficultyId); return definition.bosses.filter((boss) => { const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id); return !spawn || (((spawn.spawnMask ?? 1) & (difficulty?.spawnMask ?? 1)) !== 0); }); } export function finalDungeonBoss(definition: DungeonDefinition, difficultyId = definition.defaultDifficultyId) { const bosses = dungeonBossesForDifficulty(definition, difficultyId); const bossIds = new Set(bosses.map((boss) => boss.id)); return definition.objectiveOrder.filter((objective) => !objective.optional && bossIds.has(objective.id)).at(-1) ?? bosses.filter((boss) => !boss.optional).at(-1); } export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRecord | null { const definition = dungeonDefinitionById(progress.dungeonId); if (!definition || !progress.timed || progress.finishedAt === null || progress.durationMs === null) return null; return { id: progress.id, dungeonId: progress.dungeonId, difficultyId: progress.difficultyId, ...(progress.biteSizedSelection ? { biteSizedSelection: progress.biteSizedSelection } : {}), bossDifficulty: progress.bossDifficulty, finishedAt: progress.finishedAt, durationMs: progress.durationMs, killedBossIds: progress.killedBossIds, party: progress.party, fullClear: dungeonBossesForDifficulty(definition, progress.difficultyId) .every((boss) => progress.killedBossIds.includes(boss.id)) }; } interface DungeonRunState { readonly progress: DungeonRunProgress | null; begin: (dungeonId: string, difficultyId: string, resume?: DungeonRunProgress, legacyResume?: boolean, bossDifficulty?: number, runId?: string) => void; start: (now: number, party: readonly DungeonRunMember[]) => void; advance: (completedObjectiveIds: readonly string[], deadBossIds: readonly string[], now: number, party: readonly DungeonRunMember[]) => void; } export const useDungeonRunStore = create((set, get) => ({ progress: null, begin: (dungeonId, difficultyId, resume, legacyResume = false, bossDifficulty = 1, runId) => set({ progress: resume?.dungeonId === dungeonId && resume.difficultyId === difficultyId && resume.bossDifficulty === bossDifficulty ? resume : { id: runId ?? globalThis.crypto.randomUUID(), dungeonId, difficultyId, startedAt: null, finishedAt: null, durationMs: null, killedBossIds: [], party: [], timed: !legacyResume, bossDifficulty, }, }), start: (now, party) => { const progress = get().progress; if (!progress || progress.startedAt !== null || party.length === 0) return; set({ progress: { ...progress, startedAt: now, party: [...party] } }); }, advance: (completedObjectiveIds, deadBossIds, now, party) => { const progress = get().progress; if (!progress || progress.startedAt === null || progress.finishedAt !== null) return; const definition = dungeonDefinitionById(progress.dungeonId); if (!definition) return; const bossIds = new Set(definition.bosses.map((boss) => boss.id)); const killedBossIds = [...new Set([...progress.killedBossIds, ...completedObjectiveIds, ...deadBossIds])] .filter((id) => bossIds.has(id)); const finalBoss = finalDungeonBoss(definition, progress.difficultyId); // A scripted final boss must award its objective; despawns and raw corpse states cannot complete it. const finished = finalBoss !== undefined && completedObjectiveIds.includes(finalBoss.id); if (!finished && killedBossIds.length === progress.killedBossIds.length) return; set({ progress: { ...progress, killedBossIds, ...(finished ? { finishedAt: Math.max(now, progress.startedAt), durationMs: Math.max(1, Math.round(now - progress.startedAt)), party: [...party] } : {}) } }); }, }));