import { create } from "zustand"; import { resolveAreaNameForDungeon } from "./dungeonManifest"; import { DEFAULT_DUNGEON_ID, requireDungeonDefinition, type DungeonId } from "./dungeonRegistry"; import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes"; import type { ActionBindingLayer } from "./actionBindings"; import type { GmReviewTarget } from "./gmContentReview"; import { validBossDifficulty } from "./dungeonDifficulty"; export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error"; export type InputMode = "keyboard" | "gamepad"; export type GameMode = "dungeon" | "manastorm"; export type GameOverlay = "pause" | "boss-test-review" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null; export interface BossTestSession { readonly key: string; readonly name: string; readonly sourceDungeonId: string; readonly entityId: string; readonly runtimeIds: readonly string[]; } interface PlayerSnapshot { position: Vector3Tuple; yaw: number; distanceDelta?: number; grounded?: boolean; } export interface GameState { activeDungeonId: DungeonId; activeDifficultyId: string; activeBossDifficulty: number; gameMode: GameMode; gmTestMode: boolean; bossTest: BossTestSession | null; activeGmReviewTarget: GmReviewTarget | null; activeSpawn: WorldSpawn; sessionRevision: number; dungeonIntroActive: boolean; dungeonIntroCompletedAt: number | null; raceStartBlocked: boolean; overlay: GameOverlay; paused: boolean; mapOpen: boolean; companionOpen: boolean; cameraLookActive: boolean; inputMode: InputMode; actionLayer: ActionBindingLayer; assetStatus: AssetStatus; assetMessage: string | null; playerPosition: Vector3Tuple; playerNavigationPosition: Vector3Tuple; playerGrounded: boolean; cameraYaw: number; areaName: string; distanceTravelled: number; hasMoved: boolean; resetRevision: number; /** Renderer relocation signal that does not imply a combat/encounter reset. */ relocationRevision: number; /** Explicit local reset request; dungeon activation performs its reset before hydration. */ encounterResetRevision: number; activateDungeon: ( dungeonId: DungeonId, difficultyId?: string, options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn; readonly gmTestMode?: boolean; readonly bossTest?: BossTestSession | null; readonly gmReviewTarget?: GmReviewTarget | null; readonly bossDifficulty?: number; readonly showDungeonIntro?: boolean; }, ) => void; completeDungeonIntro: (sessionRevision: number) => void; openOverlay: (overlay: Exclude) => void; toggleOverlay: (overlay: Exclude) => void; closeOverlay: () => void; togglePause: () => void; setPaused: (paused: boolean) => void; toggleMap: () => void; closeMap: () => void; setCompanionOpen: (open: boolean) => void; setCameraLookActive: (active: boolean) => void; setInputMode: (mode: InputMode) => void; setActionLayer: (layer: ActionBindingLayer) => void; setAssetStatus: (status: AssetStatus, message?: string | null) => void; updatePlayerSnapshot: (snapshot: PlayerSnapshot) => void; relocatePlayer: (spawn: WorldSpawn, makeActiveSpawn?: boolean) => void; /** Returns the party to its saved run spawn without requesting a full dungeon reset. */ resetAfterDefeat: () => void; resetAtActiveSpawn: () => void; resetAtEntrance: () => void; } const initialDungeon = requireDungeonDefinition(DEFAULT_DUNGEON_ID); const entrance = initialDungeon.entrance; export const useGameStore = create((set) => ({ activeDungeonId: DEFAULT_DUNGEON_ID, activeDifficultyId: initialDungeon.defaultDifficultyId, activeBossDifficulty: 1, gameMode: "dungeon", gmTestMode: false, bossTest: null, activeGmReviewTarget: null, activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw }, sessionRevision: 0, dungeonIntroActive: false, dungeonIntroCompletedAt: null, raceStartBlocked: false, overlay: null, paused: false, mapOpen: false, companionOpen: false, cameraLookActive: false, inputMode: "keyboard", actionLayer: "primary", assetStatus: "checking", assetMessage: null, playerPosition: entrance.footPosition, playerNavigationPosition: entrance.footPosition, playerGrounded: true, cameraYaw: entrance.yaw, areaName: resolveAreaNameForDungeon(initialDungeon, entrance.footPosition), distanceTravelled: 0, hasMoved: false, resetRevision: 0, relocationRevision: 0, encounterResetRevision: 0, activateDungeon: (activeDungeonId, requestedDifficultyId, options) => set((state) => { const definition = requireDungeonDefinition(activeDungeonId); const activeDifficultyId = definition.difficultyVariants.some((variant) => variant.id === requestedDifficultyId) ? requestedDifficultyId! : definition.defaultDifficultyId; const activeSpawn = options?.spawn ?? { footPosition: definition.entrance.footPosition, yaw: definition.entrance.yaw, }; return { activeDungeonId, activeDifficultyId, activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1, gameMode: options?.gameMode ?? "dungeon", gmTestMode: options?.gmTestMode ?? false, bossTest: options?.bossTest ?? null, activeGmReviewTarget: options?.gmReviewTarget ?? null, activeSpawn, sessionRevision: state.sessionRevision + 1, dungeonIntroActive: options?.showDungeonIntro ?? false, dungeonIntroCompletedAt: null, raceStartBlocked: false, overlay: null, paused: false, mapOpen: false, companionOpen: false, cameraLookActive: false, actionLayer: "primary", assetStatus: "checking", assetMessage: null, playerPosition: activeSpawn.footPosition, playerNavigationPosition: activeSpawn.footPosition, playerGrounded: true, cameraYaw: activeSpawn.yaw, areaName: resolveAreaNameForDungeon(definition, activeSpawn.footPosition), distanceTravelled: 0, hasMoved: false, resetRevision: state.resetRevision + 1, relocationRevision: state.relocationRevision + 1, }; }), completeDungeonIntro: (sessionRevision) => set((state) => state.sessionRevision === sessionRevision && state.dungeonIntroActive ? { dungeonIntroActive: false, dungeonIntroCompletedAt: Date.now() } : state), openOverlay: (overlay) => set({ overlay, paused: true, mapOpen: overlay === "map", cameraLookActive: false, }), toggleOverlay: (overlay) => set((state) => { const nextOverlay = state.overlay === overlay ? null : overlay; return { overlay: nextOverlay, paused: nextOverlay !== null, mapOpen: nextOverlay === "map", cameraLookActive: false, }; }), closeOverlay: () => set({ overlay: null, paused: false, mapOpen: false, cameraLookActive: false }), togglePause: () => set((state) => { const nextOverlay = state.overlay === "pause" ? null : "pause"; return { overlay: nextOverlay, paused: nextOverlay !== null, mapOpen: false, cameraLookActive: false, }; }), setPaused: (paused) => set({ overlay: paused ? "pause" : null, paused, mapOpen: false, cameraLookActive: false, }), toggleMap: () => set((state) => { const nextOverlay = state.overlay === "map" ? null : "map"; return { overlay: nextOverlay, paused: nextOverlay !== null, mapOpen: nextOverlay === "map", cameraLookActive: false, }; }), closeMap: () => set((state) => state.overlay === "map" ? { overlay: null, paused: false, mapOpen: false } : { mapOpen: false }), setCompanionOpen: (companionOpen) => set((state) => ({ companionOpen, cameraLookActive: companionOpen ? false : state.cameraLookActive, })), setCameraLookActive: (cameraLookActive) => set((state) => state.cameraLookActive === cameraLookActive ? state : { cameraLookActive }), setInputMode: (inputMode) => set((state) => state.inputMode === inputMode ? state : { inputMode }), setActionLayer: (actionLayer) => set((state) => state.actionLayer === actionLayer ? state : { actionLayer }), setAssetStatus: (assetStatus, assetMessage = null) => set((state) => { const isStaleLoadingWrite = state.assetStatus === "ready" && (assetStatus === "checking" || assetStatus === "loading"); if (isStaleLoadingWrite) return state; return { assetStatus, assetMessage }; }), updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0, grounded = true }) => set((state) => { const definition = requireDungeonDefinition(state.activeDungeonId); return { playerPosition: position, playerNavigationPosition: grounded ? position : state.playerNavigationPosition, playerGrounded: grounded, cameraYaw: yaw, areaName: resolveAreaNameForDungeon(definition, position), distanceTravelled: state.distanceTravelled + Math.max(0, distanceDelta), hasMoved: state.hasMoved || distanceDelta > 0.02, }; }), relocatePlayer: (spawn, makeActiveSpawn = false) => set((state) => { const definition = requireDungeonDefinition(state.activeDungeonId); return { ...(makeActiveSpawn ? { activeSpawn: spawn } : {}), playerPosition: spawn.footPosition, playerNavigationPosition: spawn.footPosition, playerGrounded: true, cameraYaw: spawn.yaw, areaName: resolveAreaNameForDungeon(definition, spawn.footPosition), cameraLookActive: false, relocationRevision: state.relocationRevision + 1, }; }), resetAfterDefeat: () => set((state) => { const definition = requireDungeonDefinition(state.activeDungeonId); return { overlay: null, paused: false, mapOpen: false, cameraLookActive: false, playerPosition: state.activeSpawn.footPosition, playerNavigationPosition: state.activeSpawn.footPosition, playerGrounded: true, cameraYaw: state.activeSpawn.yaw, areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition), distanceTravelled: 0, hasMoved: false, actionLayer: "primary", resetRevision: state.resetRevision + 1, relocationRevision: state.relocationRevision + 1, }; }), resetAtActiveSpawn: () => set((state) => { const definition = requireDungeonDefinition(state.activeDungeonId); return { overlay: null, paused: false, mapOpen: false, cameraLookActive: false, playerPosition: state.activeSpawn.footPosition, playerNavigationPosition: state.activeSpawn.footPosition, playerGrounded: true, cameraYaw: state.activeSpawn.yaw, areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition), distanceTravelled: 0, hasMoved: false, actionLayer: "primary", resetRevision: state.resetRevision + 1, relocationRevision: state.relocationRevision + 1, encounterResetRevision: state.encounterResetRevision + 1, }; }), resetAtEntrance: () => set((state) => { const activeEntrance = requireDungeonDefinition(state.activeDungeonId).entrance; const definition = requireDungeonDefinition(state.activeDungeonId); const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw }; return { gameMode: "dungeon", gmTestMode: Boolean(state.bossTest), activeGmReviewTarget: null, activeSpawn, overlay: null, paused: false, mapOpen: false, cameraLookActive: false, playerPosition: activeEntrance.footPosition, playerNavigationPosition: activeEntrance.footPosition, playerGrounded: true, cameraYaw: activeEntrance.yaw, areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition), distanceTravelled: 0, hasMoved: false, actionLayer: "primary", resetRevision: state.resetRevision + 1, relocationRevision: state.relocationRevision + 1, encounterResetRevision: state.encounterResetRevision + 1, }; }), }));