Files
healer-man/src/game/dungeonSimulationStore.ts
T
2026-09-16 20:00:13 -04:00

279 lines
11 KiB
TypeScript

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<DungeonSimulationState>((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();
}