import { Line } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; import { useMemo, useRef } from "react"; import { useShellStore } from "../app/shellStore"; import { saveDungeonSimulationReplay } from "../app/dungeonSimulationClient"; import { useCombatStore } from "../game/combatStore"; import { combatSpatialActor } from "../game/combatSpatial"; import { activeDungeonCompletedObjectiveIds } from "../game/dungeonEncounterRuntime"; import { getMobPosition, updateMobRuntimePosition } from "../game/mobRuntimeRegistry"; import { getPartyRuntimePosition, updatePartyRuntimePosition } from "../game/partyRuntimeRegistry"; import { usePartyStore, type PartyMemberStatus } from "../game/partyStore"; import { dungeonRouteDefinition, positionAlongRouteCorridor, routePointDistanceSquared } from "../game/dungeonRoutes"; import { DUNGEON_REPLAY_KEYFRAME_MS, DUNGEON_REPLAY_SAMPLE_MS, DUNGEON_SIMULATION_MAX_DURATION_MS, useDungeonSimulationStore, } from "../game/dungeonSimulationStore"; import type { DungeonReplayFrame, DungeonSimulationResult, DungeonRouteLeg, DungeonSimulationActorSnapshot, } from "../game/dungeonSimulationTypes"; import type { Vector3Tuple } from "../game/dungeonTypes"; let savingRunKey: string | null = null; function simulationRoster() { return usePartyStore.getState().members.map((member) => ({ id: member.id, name: member.name, role: member.role, classId: member.classId, })); } export async function finalizeDungeonSimulation(result: DungeonSimulationResult): Promise { const simulation = useDungeonSimulationStore.getState(); if (simulation.sessionMode !== "spectate-live" || simulation.runState === "finished") return; const replay = simulation.finish(result, simulationRoster()); if (!replay) return; const key = `${replay.dungeonId}:${replay.routeHash}:${replay.startedAt}`; if (savingRunKey === key) return; savingRunKey = key; try { await saveDungeonSimulationReplay(useShellStore.getState().session, replay); } catch (error) { console.error("Unable to upload dungeon simulation replay", error); } } function actorSnapshots(): readonly DungeonSimulationActorSnapshot[] { const party = usePartyStore.getState(); const companions = party.members.map((member) => ({ id: member.id, position: getPartyRuntimePosition(member.id) ?? [0, 0, 0], yaw: combatSpatialActor(member.id)?.yaw ?? 0, health: member.health, maxHealth: member.maxHealth, targetId: party.memberTargetIds[member.id] ?? party.activeMobId, status: member.status, })); const mobs = Object.values(useCombatStore.getState().mobs).map((mob) => ({ id: mob.id, position: getMobPosition(mob.id) ?? [0, 0, 0] as Vector3Tuple, yaw: combatSpatialActor(mob.id)?.yaw ?? 0, health: mob.health, maxHealth: mob.maxHealth, targetId: mob.targetActorId, status: mob.dead ? "dead" : mob.engaged ? "engaged" : "idle", })); return [...companions, ...mobs]; } function routeProgress(position: Vector3Tuple, leg: DungeonRouteLeg): number { let cumulative = 0; let bestDistance = Number.POSITIVE_INFINITY; let bestProgress = 0; for (let index = 1; index < leg.checkpoints.length; index += 1) { const start = leg.checkpoints[index - 1]; const end = leg.checkpoints[index]; const segmentLength = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]); const projection = routePointDistanceSquared(position, start, end); const score = projection.planarSquared + projection.vertical ** 2; if (score < bestDistance) { bestDistance = score; bestProgress = cumulative + segmentLength * projection.progress; } cumulative += segmentLength; } return bestProgress; } interface DiagnosticCursor { runKey: string; nextSampleAt: number; lastLeaderPosition: Vector3Tuple | null; lastProgressAt: number; offCorridorAt: number | null; routeBlockedAt: number | null; splitAt: Map; observedPulls: Set; objectiveId: string | null; objectiveDistance: number; maximumRouteProgress: number; finalCheckpointAt: number | null; replayFrameAt: number; } function newCursor(runKey: string): DiagnosticCursor { return { runKey, nextSampleAt: 0, lastLeaderPosition: null, lastProgressAt: 0, offCorridorAt: null, routeBlockedAt: null, splitAt: new Map(), observedPulls: new Set(), objectiveId: null, objectiveDistance: 0, maximumRouteProgress: 0, finalCheckpointAt: null, replayFrameAt: -1, }; } function addFinding( id: string, severity: "info" | "warning" | "critical", code: string, message: string, position?: Vector3Tuple, actorId?: string, ): void { const simulation = useDungeonSimulationStore.getState(); simulation.addFinding({ id, atMs: simulation.elapsedMs, severity, code, message, position, actorId, objectiveId: simulation.objectiveId ?? undefined, checkpointIndex: simulation.checkpointIndex, }); } function applyReplayFrame(frame: DungeonReplayFrame): void { const party = usePartyStore.getState(); const combat = useCombatStore.getState(); const snapshots = new Map(frame.actors.map((actor) => [actor.id, actor] as const)); const members = party.members.map((member) => { const snapshot = snapshots.get(member.id); if (!snapshot) return member; updatePartyRuntimePosition(member.id, ...snapshot.position); return { ...member, health: snapshot.health, maxHealth: snapshot.maxHealth, status: snapshot.status as PartyMemberStatus, }; }); const mobs = { ...combat.mobs }; for (const snapshot of frame.actors) { const mob = mobs[snapshot.id]; if (!mob) continue; updateMobRuntimePosition(snapshot.id, ...snapshot.position, snapshot.yaw); mobs[snapshot.id] = { ...mob, health: snapshot.health, maxHealth: snapshot.maxHealth, dead: snapshot.status === "dead" || snapshot.health <= 0, engaged: snapshot.status === "engaged", targetActorId: snapshot.targetId, }; } party.commitRuntime({ members, activeMobId: null, objectiveBossId: frame.objectiveId, memberTargetIds: Object.fromEntries(frame.actors.flatMap((actor) => actor.targetId ? [[actor.id, actor.targetId]] : [])), }); useCombatStore.setState({ mobs }); combat.commitCompanionActors(members); useDungeonSimulationStore.getState().setRouteProgress(frame.objectiveId, frame.checkpointIndex); } function recordPulls(cursor: DiagnosticCursor, leaderPosition: Vector3Tuple, leg: DungeonRouteLeg | null): void { const simulation = useDungeonSimulationStore.getState(); const partyPositions = usePartyStore.getState().members .map((member) => getPartyRuntimePosition(member.id)) .filter((position): position is Vector3Tuple => Boolean(position)); for (const mob of Object.values(useCombatStore.getState().mobs).sort((left, right) => left.id.localeCompare(right.id))) { if (mob.dead || mob.despawned || cursor.observedPulls.has(mob.id)) continue; const position = getMobPosition(mob.id); if (!position) continue; const theoretical = simulation.mode === "navigation-only" && partyPositions.some((partyPosition) => ( Math.hypot(position[0] - partyPosition[0], position[1] - partyPosition[1], position[2] - partyPosition[2]) <= Math.max(0, mob.aggroRange) )); if (!mob.engaged && !theoretical) continue; cursor.observedPulls.add(mob.id); const classification = theoretical ? "theoretical" as const : leg && positionAlongRouteCorridor(position, leg.checkpoints, leg.corridorPlanarDistance, leg.corridorVerticalDistance) ? "expected" as const : "off-route" as const; simulation.addPull({ atMs: simulation.elapsedMs, mobIds: [mob.id], routeLegId: leg?.id ?? null, position: [leaderPosition[0], leaderPosition[1], leaderPosition[2]], classification, }); } } function inspectLiveSimulation(cursor: DiagnosticCursor): void { const simulation = useDungeonSimulationStore.getState(); const party = usePartyStore.getState(); const leader = party.members.find((member) => member.health > 0); const leaderPosition = leader ? getPartyRuntimePosition(leader.id) : null; if (!leader || !leaderPosition) return; const route = simulation.dungeonId ? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary") : null; const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null; if (cursor.objectiveId !== simulation.objectiveId) { if (cursor.objectiveId && route) { const previousLeg = route.legs.find((candidate) => candidate.objectiveId === cursor.objectiveId); if (previousLeg && cursor.objectiveDistance > previousLeg.expectedDistance * 1.5 && cursor.objectiveDistance > previousLeg.expectedDistance + 10) { addFinding( `detour:${cursor.objectiveId}`, "warning", "excessive-detour", `Leg ${previousLeg.id} travelled ${cursor.objectiveDistance.toFixed(1)} m for an expected ${previousLeg.expectedDistance.toFixed(1)} m.`, leaderPosition, leader.id, ); } } cursor.objectiveId = simulation.objectiveId; cursor.objectiveDistance = 0; cursor.maximumRouteProgress = 0; cursor.finalCheckpointAt = null; } if (cursor.lastLeaderPosition) { const moved = Math.hypot( leaderPosition[0] - cursor.lastLeaderPosition[0], leaderPosition[1] - cursor.lastLeaderPosition[1], leaderPosition[2] - cursor.lastLeaderPosition[2], ); cursor.objectiveDistance += moved; if (moved >= 0.1) cursor.lastProgressAt = simulation.elapsedMs; } else cursor.lastProgressAt = simulation.elapsedMs; cursor.lastLeaderPosition = [leaderPosition[0], leaderPosition[1], leaderPosition[2]]; if (party.command === "attack" && simulation.objectiveId && simulation.elapsedMs - cursor.lastProgressAt >= 2_000) { addFinding( `stall:${simulation.objectiveId}:${simulation.checkpointIndex}`, "warning", "movement-stall", `${leader.name} made no movement progress for at least two seconds.`, leaderPosition, leader.id, ); } if (party.routeBlocked) { cursor.routeBlockedAt ??= simulation.elapsedMs; if (simulation.elapsedMs - cursor.routeBlockedAt >= 1_000) { addFinding( `no-path:${simulation.objectiveId}:${simulation.checkpointIndex}`, "critical", "no-path", `No collision-valid path to checkpoint ${simulation.checkpointIndex}.`, leaderPosition, leader.id, ); } } else cursor.routeBlockedAt = null; if (leg) { const inside = positionAlongRouteCorridor( leaderPosition, leg.checkpoints, leg.corridorPlanarDistance, leg.corridorVerticalDistance, ); if (!inside) { cursor.offCorridorAt ??= simulation.elapsedMs; if (simulation.elapsedMs - cursor.offCorridorAt >= 1_000) { addFinding( `off-corridor:${leg.id}:${simulation.checkpointIndex}`, "warning", "off-corridor", `${leader.name} remained outside the ${leg.corridorPlanarDistance} m / ${leg.corridorVerticalDistance} m route corridor for one second.`, leaderPosition, leader.id, ); } } else cursor.offCorridorAt = null; const progress = routeProgress(leaderPosition, leg); cursor.maximumRouteProgress = Math.max(cursor.maximumRouteProgress, progress); if (cursor.maximumRouteProgress - progress >= 8) { addFinding( `backtrack:${leg.id}:${Math.floor(cursor.maximumRouteProgress / 8)}`, "warning", "backtracking", `${leader.name} backtracked at least 8 m on ${leg.id}.`, leaderPosition, leader.id, ); } if (simulation.checkpointIndex >= leg.checkpoints.length - 1) { cursor.finalCheckpointAt ??= simulation.elapsedMs; if ( simulation.mode === "navigation-only" && simulation.elapsedMs - cursor.finalCheckpointAt >= 5_000 ) { addFinding( `scripted-progression:${leg.id}`, "critical", "failed-scripted-progression", `Navigation-only progression did not advance after reaching ${leg.objectiveName}.`, leaderPosition, leader.id, ); } } else cursor.finalCheckpointAt = null; } for (const follower of party.members.filter((member) => member.health > 0 && member.id !== leader.id)) { const position = getPartyRuntimePosition(follower.id); if (!position) continue; const distance = Math.hypot( position[0] - leaderPosition[0], position[1] - leaderPosition[1], position[2] - leaderPosition[2], ); if (distance > 12) { const separatedAt = cursor.splitAt.get(follower.id) ?? simulation.elapsedMs; cursor.splitAt.set(follower.id, separatedAt); if (simulation.elapsedMs - separatedAt >= 2_000) { addFinding( `separation:${follower.id}:${simulation.objectiveId}`, "warning", "party-separation", `${follower.name} remained more than 12 m from the leader for two seconds.`, position, follower.id, ); } } else cursor.splitAt.delete(follower.id); } if (simulation.recoveries >= 2) { addFinding( `repeated-recovery:${simulation.objectiveId}`, "critical", "repeated-recovery", "The simulator required repeated position recovery teleports.", leaderPosition, leader.id, ); } recordPulls(cursor, leaderPosition, leg); while (simulation.elapsedMs >= cursor.nextSampleAt) { const atMs = cursor.nextSampleAt; simulation.addFrame({ atMs, keyframe: atMs % DUNGEON_REPLAY_KEYFRAME_MS === 0, actors: actorSnapshots(), objectiveId: simulation.objectiveId, checkpointIndex: simulation.checkpointIndex, }); cursor.nextSampleAt += DUNGEON_REPLAY_SAMPLE_MS; } if (simulation.elapsedMs >= DUNGEON_SIMULATION_MAX_DURATION_MS) { void finalizeDungeonSimulation("recording-limit"); } else if (!party.members.some((member) => member.health > 0)) { void finalizeDungeonSimulation("wiped"); } else if (simulation.mode === "navigation-only" && simulation.objectiveId === null) { void finalizeDungeonSimulation("completed"); } else if ( simulation.mode === "full-combat" && simulation.elapsedMs >= 1_000 && route && route.legs.every((candidate) => activeDungeonCompletedObjectiveIds(route.dungeonId).includes(candidate.objectiveId)) ) { void finalizeDungeonSimulation("completed"); } } export function DungeonSimulationSceneBridge() { const sessionMode = useDungeonSimulationStore((state) => state.sessionMode); const dungeonId = useDungeonSimulationStore((state) => state.dungeonId); const routeId = useDungeonSimulationStore((state) => state.routeId); const objectiveId = useDungeonSimulationStore((state) => state.objectiveId); const checkpointIndex = useDungeonSimulationStore((state) => state.checkpointIndex); const frames = useDungeonSimulationStore((state) => state.frames); const cursorRef = useRef(newCursor("")); const route = useMemo( () => dungeonId ? dungeonRouteDefinition(dungeonId, routeId ?? "primary") : null, [dungeonId, routeId], ); const leg = route?.legs.find((candidate) => candidate.objectiveId === objectiveId) ?? null; const trace = useMemo(() => frames .map((frame) => frame.actors[0]?.position) .filter((position): position is Vector3Tuple => Boolean(position)) .slice(-2_000), [frames]); useFrame(() => { const simulation = useDungeonSimulationStore.getState(); if (simulation.sessionMode === "play") return; const runKey = `${simulation.sessionMode}:${simulation.dungeonId}:${simulation.startedAt}`; if (cursorRef.current.runKey !== runKey) cursorRef.current = newCursor(runKey); if (simulation.sessionMode === "spectate-replay") { const replay = simulation.replay; if (!replay?.frames.length) return; const frame = [...replay.frames].reverse().find((candidate) => candidate.atMs <= simulation.elapsedMs) ?? replay.frames[0]; if (frame.atMs !== cursorRef.current.replayFrameAt) { cursorRef.current.replayFrameAt = frame.atMs; applyReplayFrame(frame); } return; } if (simulation.runState === "finished") return; inspectLiveSimulation(cursorRef.current); }, 1); if (sessionMode === "play") return null; return ( {leg && leg.checkpoints.length > 1 && ( )} {leg?.checkpoints.map((checkpoint, index) => ( ))} {trace.length > 1 && ( )} ); }