Files
healer-man/src/scene/DungeonSimulationSceneBridge.tsx
T
2026-09-16 20:58:10 -04:00

461 lines
17 KiB
TypeScript

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<void> {
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<string, number>;
observedPulls: Set<string>;
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<DiagnosticCursor>(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);
// Keep the default frame priority. A positive priority takes ownership of
// R3F rendering and would require this bridge to call gl.render() itself.
});
if (sessionMode === "play") return null;
return (
<group name="dungeon-simulation-route-overlay">
{leg && leg.checkpoints.length > 1 && (
<Line points={leg.checkpoints} color="#60d9ff" lineWidth={2} transparent opacity={0.72} />
)}
{leg?.checkpoints.map((checkpoint, index) => (
<mesh key={`${leg.id}:${index}`} position={checkpoint}>
<sphereGeometry args={[index === checkpointIndex ? 0.28 : 0.14, 8, 8]} />
<meshBasicMaterial color={index === checkpointIndex ? "#ffe56c" : "#59bde8"} transparent opacity={0.85} />
</mesh>
))}
{trace.length > 1 && (
<Line points={trace} color="#ff9f43" lineWidth={1.5} transparent opacity={0.8} />
)}
</group>
);
}