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,274 @@
|
||||
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;
|
||||
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;
|
||||
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-leader" as DungeonSpectatorCameraMode,
|
||||
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 }),
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user