Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -0,0 +1,458 @@
|
||||
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);
|
||||
}, 1);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user