279 lines
11 KiB
TypeScript
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();
|
|
}
|