import { create } from "zustand"; import type { DungeonReplayFrame, DungeonReplayV1, DungeonRouteDefinitionV1, DungeonSessionMode, DungeonSimulationFinding, DungeonSimulationMode, DungeonSimulationPull, DungeonSimulationReportV1, DungeonSimulationResult, DungeonSimulationSpeed, DungeonSpectatorCameraMode, } from "./dungeonSimulationTypes"; export const DUNGEON_SIMULATION_FIXED_STEP_MS = 50; export const DUNGEON_REPLAY_SAMPLE_MS = 100; export const DUNGEON_REPLAY_KEYFRAME_MS = 5_000; export const DUNGEON_SIMULATION_MAX_DURATION_MS = 2 * 60 * 60 * 1_000; const MAX_TICKS_PER_ADVANCE = 80; export interface StartDungeonSimulationOptions { readonly dungeonId: string; readonly route: DungeonRouteDefinitionV1; readonly mode: DungeonSimulationMode; readonly seed: string; readonly startedAt?: number; } export interface DungeonSimulationState { sessionMode: DungeonSessionMode; mode: DungeonSimulationMode; runState: "idle" | "running" | "paused" | "finished"; speed: DungeonSimulationSpeed; cameraMode: DungeonSpectatorCameraMode; spectatedMemberId: string | null; dungeonId: string | null; routeId: string | null; routeHash: string | null; seed: string; startedAt: number; elapsedMs: number; accumulatorMs: number; pendingSteps: number; objectiveId: string | null; checkpointIndex: number; result: DungeonSimulationResult | null; findings: readonly DungeonSimulationFinding[]; pulls: readonly DungeonSimulationPull[]; recoveries: number; frames: readonly DungeonReplayFrame[]; replay: DungeonReplayV1 | null; startLive: (options: StartDungeonSimulationOptions) => void; startReplay: (replay: DungeonReplayV1) => void; leave: () => void; setRunning: (running: boolean) => void; toggleRunning: () => void; setSpeed: (speed: DungeonSimulationSpeed) => void; setCameraMode: (mode: DungeonSpectatorCameraMode) => void; setSpectatedMember: (memberId: string | null) => void; step: () => void; seekReplay: (elapsedMs: number) => void; consumeTicks: (wallDeltaMs: number) => readonly number[]; setRouteProgress: (objectiveId: string | null, checkpointIndex: number) => void; addFinding: (finding: DungeonSimulationFinding) => void; addPull: (pull: DungeonSimulationPull) => void; addRecovery: () => void; addFrame: (frame: DungeonReplayFrame) => void; finish: (result: DungeonSimulationResult, roster: DungeonSimulationReportV1["roster"]) => DungeonReplayV1 | null; } const initialState = { sessionMode: "play" as DungeonSessionMode, mode: "full-combat" as DungeonSimulationMode, runState: "idle" as const, speed: 1 as DungeonSimulationSpeed, cameraMode: "follow-member" as DungeonSpectatorCameraMode, spectatedMemberId: null as string | null, dungeonId: null as string | null, routeId: null as string | null, routeHash: null as string | null, seed: "", startedAt: 0, elapsedMs: 0, accumulatorMs: 0, pendingSteps: 0, objectiveId: null as string | null, checkpointIndex: 0, result: null as DungeonSimulationResult | null, findings: [] as readonly DungeonSimulationFinding[], pulls: [] as readonly DungeonSimulationPull[], recoveries: 0, frames: [] as readonly DungeonReplayFrame[], replay: null as DungeonReplayV1 | null, }; function clampWallDelta(value: number): number { return Math.max(0, Math.min(1_000, Number.isFinite(value) ? value : 0)); } export function fixedSimulationTickCount( accumulatorMs: number, wallDeltaMs: number, speed: DungeonSimulationSpeed, ): { readonly ticks: number; readonly accumulatorMs: number } { const total = Math.max(0, accumulatorMs) + clampWallDelta(wallDeltaMs) * speed; const ticks = Math.min(MAX_TICKS_PER_ADVANCE, Math.floor(total / DUNGEON_SIMULATION_FIXED_STEP_MS)); return { ticks, accumulatorMs: total - ticks * DUNGEON_SIMULATION_FIXED_STEP_MS }; } export const useDungeonSimulationStore = create((set, get) => ({ ...initialState, startLive: ({ dungeonId, route, mode, seed, startedAt = Date.now() }) => set({ ...initialState, sessionMode: "spectate-live", mode, runState: "running", dungeonId, routeId: route.id, routeHash: route.hash, seed, startedAt, objectiveId: route.legs[0]?.objectiveId ?? null, }), startReplay: (replay) => set({ ...initialState, sessionMode: "spectate-replay", mode: replay.mode, runState: "paused", dungeonId: replay.dungeonId, routeId: replay.routeId, routeHash: replay.routeHash, seed: replay.seed, startedAt: replay.startedAt, result: replay.result, findings: replay.report.findings, pulls: replay.report.pulls, recoveries: replay.report.recoveries, frames: replay.frames, replay, }), leave: () => set(initialState), setRunning: (running) => set((state) => state.sessionMode === "play" || state.runState === "finished" ? state : { runState: running ? "running" : "paused", accumulatorMs: 0 }), toggleRunning: () => { const state = get(); state.setRunning(state.runState !== "running"); }, setSpeed: (speed) => set({ speed }), setCameraMode: (cameraMode) => set({ cameraMode }), setSpectatedMember: (spectatedMemberId) => set({ spectatedMemberId, cameraMode: "follow-member" }), step: () => set((state) => { if (state.runState !== "paused") return state; if (state.sessionMode === "spectate-replay" && state.replay) { const next = state.replay.frames.find((frame) => frame.atMs > state.elapsedMs); return { elapsedMs: next?.atMs ?? state.elapsedMs }; } return { pendingSteps: state.pendingSteps + 1 }; }), seekReplay: (elapsedMs) => set((state) => state.sessionMode !== "spectate-replay" || !state.replay ? state : { elapsedMs: Math.max(0, Math.min(state.replay.durationMs, elapsedMs)), accumulatorMs: 0 }), consumeTicks: (wallDeltaMs) => { const state = get(); if (state.sessionMode === "play" || state.runState === "finished") return []; if (state.sessionMode === "spectate-replay") { if (state.runState !== "running" || !state.replay) return []; const advanced = Math.min(state.replay.durationMs, state.elapsedMs + clampWallDelta(wallDeltaMs) * state.speed); set({ elapsedMs: advanced, runState: advanced >= state.replay.durationMs ? "finished" : "running" }); return []; } let ticks = 0; let accumulatorMs = state.accumulatorMs; let pendingSteps = state.pendingSteps; if (state.runState === "running") { const advance = fixedSimulationTickCount(accumulatorMs, wallDeltaMs, state.speed); ticks = advance.ticks; accumulatorMs = advance.accumulatorMs; } else if (state.runState === "paused" && pendingSteps > 0) { ticks = 1; pendingSteps -= 1; accumulatorMs = 0; } const availableTicks = Math.max(0, Math.floor((DUNGEON_SIMULATION_MAX_DURATION_MS - state.elapsedMs) / DUNGEON_SIMULATION_FIXED_STEP_MS)); ticks = Math.min(ticks, availableTicks); const elapsedMs = state.elapsedMs + ticks * DUNGEON_SIMULATION_FIXED_STEP_MS; set({ elapsedMs, accumulatorMs, pendingSteps }); return Array.from({ length: ticks }, (_, index) => state.startedAt + state.elapsedMs + (index + 1) * DUNGEON_SIMULATION_FIXED_STEP_MS); }, setRouteProgress: (objectiveId, checkpointIndex) => set((state) => { const normalized = Math.max(0, Math.trunc(checkpointIndex)); return state.objectiveId === objectiveId && state.checkpointIndex === normalized ? state : { objectiveId, checkpointIndex: normalized }; }), addFinding: (finding) => set((state) => state.findings.some((candidate) => candidate.id === finding.id) ? state : { findings: [...state.findings, finding] }), addPull: (pull) => set((state) => ({ pulls: [...state.pulls, pull] })), addRecovery: () => set((state) => ({ recoveries: state.recoveries + 1 })), addFrame: (frame) => set((state) => { if (state.frames[state.frames.length - 1]?.atMs === frame.atMs) return state; return { frames: [...state.frames, frame] }; }), finish: (result, roster) => { const state = get(); if (state.sessionMode !== "spectate-live" || state.runState === "finished" || !state.dungeonId || !state.routeId || !state.routeHash) return null; const objectiveIds = [...new Set(state.frames.map((frame) => frame.objectiveId).filter((id): id is string => Boolean(id)))]; const objectiveTimings = objectiveIds.map((objectiveId) => { const frames = state.frames.filter((frame) => frame.objectiveId === objectiveId); const laterObjective = state.frames.find((frame) => frame.atMs > (frames.at(-1)?.atMs ?? 0) && frame.objectiveId !== objectiveId); return { objectiveId, startedAtMs: frames[0]?.atMs ?? 0, completedAtMs: laterObjective?.atMs ?? (state.objectiveId === objectiveId ? null : frames.at(-1)?.atMs ?? null), }; }); const pathDistances = objectiveIds.map((objectiveId) => { const frames = state.frames.filter((frame) => frame.objectiveId === objectiveId); let actualDistance = 0; for (let index = 1; index < frames.length; index += 1) { const previous = frames[index - 1].actors[0]?.position; const current = frames[index].actors[0]?.position; if (previous && current) actualDistance += Math.hypot( current[0] - previous[0], current[1] - previous[1], current[2] - previous[2], ); } return { objectiveId, actualDistance }; }); const report: DungeonSimulationReportV1 = { schemaVersion: 1, dungeonId: state.dungeonId, routeId: state.routeId, routeHash: state.routeHash, seed: state.seed, mode: state.mode, startedAt: state.startedAt, durationMs: state.elapsedMs, result, roster, findings: state.findings, pulls: state.pulls, recoveries: state.recoveries, objectiveTimings, actionTimings: state.findings.filter((finding) => finding.code === "route-action" && finding.actionId).map((finding) => ({ actionId: finding.actionId!, objectiveId: finding.objectiveId ?? null, atMs: finding.atMs, })), pathDistances, finalObjectiveId: state.objectiveId, finalCheckpointIndex: state.checkpointIndex, finalState: state.frames.at(-1)?.actors ?? [], }; const replay: DungeonReplayV1 = { schemaVersion: 1, dungeonId: state.dungeonId, routeId: state.routeId, routeHash: state.routeHash, mode: state.mode, seed: state.seed, result, startedAt: state.startedAt, durationMs: state.elapsedMs, report, frames: state.frames, events: state.findings, }; set({ runState: "finished", result, replay }); return replay; }, })); export function dungeonSimulationNow(): number { const state = useDungeonSimulationStore.getState(); return state.sessionMode === "spectate-live" ? state.startedAt + state.elapsedMs : Date.now(); }