import { create } from "zustand"; import { clearSession, assignRomSecondaryClass, createCharacter as persistCharacter, deleteCharacter as removeCharacter, listCharacters, loadCombatPresentationSettings, restoreSession as restoreStoredSession, saveSession, updateCharacterActionBindings, updateCharacterEquipment, updateCharacterInventory, updateCharacterManastormProgress, updateCharacterDungeonRunCheckpoint, updateCharacterProgression, updateCharacterSettings, updateCharacterSpecialization, updateCombatPresentationSettings as persistCombatPresentationSettings, swapRomClasses, } from "./accountRepository"; import { clampAppearance, classById, classesForRace, createDefaultAppearance, normalizeCharacterName, randomAppearance, raceSupportsCategory, racesForCategory, supportedGenders, validateCharacterName, type RaceId, type RomClassId, } from "./characterCatalog"; import type { ContentCategoryId } from "./contentCategories"; import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from "./types"; import type { DungeonId } from "../game/dungeonRegistry"; import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults"; import type { InventoryItem } from "../game/lootTypes"; import type { EquipmentAssignments } from "../game/equipment"; import type { GameplaySettings } from "../game/combatStore"; import { DEFAULT_COMBAT_PRESENTATION_SETTINGS, normalizeCombatPresentationSettings, type CombatPresentationSettings, } from "../game/combatPresentation"; import type { PartyRole } from "../game/partyRoles"; import type { ManastormPartySize, ManastormProgress } from "../game/manastormProgress"; import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint"; import { dungeonShellRuntime } from "./dungeonShellRuntimeBridge"; import { manastormShellRuntime } from "./manastormShellRuntimeBridge"; import { activateOnlineSession, hydrateOnlineSession, logoutOnlineSession, refreshOnlineSession, } from "./onlineAccountClient"; import { isGameMasterSession } from "../game/manastormAdminConfig"; import { useGameStore } from "../game/store"; import { normalizeSpecializationId, specializationById, specializationsForClass, } from "../game/specializationCatalog"; export interface ShellState { phase: ShellPhase; session: PlayerSession | null; characters: CharacterProfile[]; selectedCharacterId: string | null; activeCharacter: CharacterProfile | null; draft: CharacterDraft; notice: string; activeDungeonId: DungeonId; activeDungeonRole: PartyRole; combatPresentationSettings: CombatPresentationSettings; beginSession: (session: PlayerSession) => void; signOut: () => void; selectCharacter: (characterId: string) => void; openCreator: () => void; cancelCreator: () => void; updateDraft: (patch: Partial) => void; setCreatorStep: (step: CharacterDraft["step"]) => void; randomizeDraft: () => void; createDraftCharacter: () => boolean; deleteSelectedCharacter: () => void; openMainMenu: () => boolean; openDungeons: () => boolean; openManastorms: () => boolean; openArenas: () => boolean; openGmAdmin: () => boolean; openClassHall: () => boolean; changeSelectedSpecialization: (specializationId: string, resetTalents: boolean) => boolean; chooseRomSecondaryClass: (classId: RomClassId) => boolean; swapSelectedRomClasses: () => boolean; returnToMainMenu: () => void; enterDungeon: (dungeonId: string, playerRole?: PartyRole) => boolean; enterManastorm: ( playerRole?: PartyRole, partySize?: ManastormPartySize, startingLevel?: number, ) => boolean; abandonManastormRun: () => void; failActiveManastorm: () => void; /** Compatibility alias for the original single-dungeon entry point. */ enterWorld: () => boolean; returnToCharacters: () => void; saveActiveProgression: (level: number, experience: number, talentRanks: Record) => void; saveActiveActionBindings: (actionBindings: Readonly>) => void; saveActiveInventory: (inventory: readonly InventoryItem[]) => void; saveActiveEquipment: (equipment: EquipmentAssignments) => void; saveActiveSettings: (settings: GameplaySettings) => void; updateCombatPresentationSettings: (patch: Partial) => void; resetCombatPresentationSettings: () => void; saveActiveManastormProgress: (progress: ManastormProgress) => void; saveActiveDungeonRunCheckpoint: (checkpoint: DungeonRunCheckpoint | null) => void; saveSelectedManastormLoadout: (loadout: readonly string[]) => boolean; setNotice: (notice: string) => void; } function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft { const raceId: RaceId = categoryId === "rom" ? "rom-human" : "human"; const classId = categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest"; return { name: "", categoryId, raceId, classId, specializationId: specializationsForClass(classId)[0]?.id ?? null, gender: "female", appearance: createDefaultAppearance(), rotation: -12, step: "identity", }; } function restoredState(): Pick { const session = restoreStoredSession(); if (!session) return { phase: "login", session: null, characters: [], selectedCharacterId: null, combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS, }; const characters = listCharacters(session.ownerId); return { phase: "characters", session, characters, selectedCharacterId: characters[0]?.id ?? null, combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), }; } const restored = restoredState(); export const useShellStore = create((set, get) => ({ ...restored, activeDungeonId: DEFAULT_DUNGEON_ID, activeDungeonRole: "healer", activeCharacter: null, draft: defaultDraft(), notice: "", beginSession: (session) => { activateOnlineSession(session); saveSession(session); const characters = listCharacters(session.ownerId); set({ phase: "characters", session, characters, selectedCharacterId: characters[0]?.id ?? null, activeCharacter: null, combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), notice: session.kind === "offline" ? "Offline roster loaded." : `Welcome, ${session.displayName}.`, }); }, signOut: () => { useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); const session = get().session; void logoutOnlineSession(session); clearSession(); set({ phase: "login", session: null, characters: [], selectedCharacterId: null, activeCharacter: null, draft: defaultDraft(), notice: "Signed out.", combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS, }); }, selectCharacter: (selectedCharacterId) => set({ selectedCharacterId, notice: "" }), openCreator: () => set({ phase: "create-character", draft: defaultDraft(), notice: "" }), cancelCreator: () => set({ phase: "characters", notice: "" }), updateDraft: (patch) => set((state) => { const categoryChanged = patch.categoryId !== undefined && patch.categoryId !== state.draft.categoryId; const next = categoryChanged ? { ...defaultDraft(patch.categoryId), name: state.draft.name, rotation: state.draft.rotation } : { ...state.draft, ...patch }; if (patch.name !== undefined) next.name = normalizeCharacterName(patch.name); if (!raceSupportsCategory(next.raceId, next.categoryId)) { const targetRace = racesForCategory(next.categoryId)[0]; next.raceId = targetRace.id; next.classId = classesForRace(targetRace.id, next.categoryId)[0].id; next.gender = supportedGenders(targetRace.id)[0] ?? "male"; next.appearance = createDefaultAppearance(); } const availableGenders = supportedGenders(next.raceId); if (!availableGenders.includes(next.gender)) next.gender = availableGenders[0] ?? "male"; const availableClasses = classesForRace(next.raceId, next.categoryId); if (!availableClasses.some((definition) => definition.id === next.classId)) { next.classId = availableClasses[0].id; } next.specializationId = next.categoryId === "rom" ? null : normalizeSpecializationId(next.classId, next.specializationId) ?? specializationsForClass(next.classId)[0]?.id ?? null; next.appearance = clampAppearance(next.raceId, next.gender, next.appearance); return { draft: next, notice: "" }; }), setCreatorStep: (step) => set((state) => ({ draft: { ...state.draft, step }, notice: "" })), randomizeDraft: () => set((state) => ({ draft: { ...state.draft, appearance: randomAppearance(state.draft.raceId, state.draft.gender), rotation: Math.round(Math.random() * 48 - 24), }, notice: "Appearance randomized.", })), createDraftCharacter: () => { const { session, draft } = get(); if (!session) { set({ phase: "login", notice: "Start a session before creating a character." }); return false; } const nameError = validateCharacterName(draft.name); if (nameError) { set({ notice: nameError }); return false; } if (!classesForRace(draft.raceId, draft.categoryId).some((definition) => definition.id === draft.classId)) { set({ notice: "That class is not available to the selected race." }); return false; } if (!supportedGenders(draft.raceId).includes(draft.gender)) { set({ notice: "That gender model is not available for the selected race." }); return false; } if (draft.categoryId !== "rom" && !normalizeSpecializationId(draft.classId, draft.specializationId)) { set({ notice: "Choose a specialization before creating this character." }); return false; } const result = persistCharacter({ ownerId: session.ownerId, name: normalizeCharacterName(draft.name), categoryId: draft.categoryId, raceId: draft.raceId, classId: draft.classId, specializationId: draft.categoryId === "rom" ? null : draft.specializationId, gender: draft.gender, appearance: clampAppearance(draft.raceId, draft.gender, draft.appearance), }); if (!result.ok || !result.character) { set({ notice: result.error ?? "Character could not be created." }); return false; } const characters = listCharacters(session.ownerId); set({ phase: "characters", characters, selectedCharacterId: result.character.id, draft: defaultDraft(), notice: `${result.character.name} is ready for the expedition.`, }); return true; }, deleteSelectedCharacter: () => { const { session, selectedCharacterId } = get(); if (!session || !selectedCharacterId) return; removeCharacter(session.ownerId, selectedCharacterId); const characters = listCharacters(session.ownerId); set({ characters, selectedCharacterId: characters[0]?.id ?? null, activeCharacter: null, notice: "Character deleted.", }); }, openMainMenu: () => { const { session, selectedCharacterId, characters } = get(); if (!session || !selectedCharacterId) { set({ notice: "Select or create a character first." }); return false; } if (!characters.some((character) => character.id === selectedCharacterId)) { set({ notice: "That character is no longer available." }); return false; } const character = characters.find((entry) => entry.id === selectedCharacterId)!; if (character.categoryId !== "rom" && !character.specializationId) { set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." }); return true; } set({ phase: "main-menu", activeCharacter: null, notice: "" }); return true; }, openDungeons: () => { const { session, selectedCharacterId, characters } = get(); if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) { set({ phase: session ? "characters" : "login", notice: "Select or create a character first." }); return false; } const character = characters.find((entry) => entry.id === selectedCharacterId)!; if (character.categoryId !== "rom" && !character.specializationId) { set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." }); return false; } set({ phase: "dungeons", activeCharacter: null, notice: "" }); return true; }, openManastorms: () => { const { session, selectedCharacterId, characters } = get(); if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) { set({ phase: session ? "characters" : "login", notice: "Select or create a character first." }); return false; } const character = characters.find((entry) => entry.id === selectedCharacterId)!; if (character.categoryId !== "rom" && !character.specializationId) { set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." }); return false; } set({ phase: "manastorms", activeCharacter: null, notice: "" }); return true; }, openArenas: () => { const { session, selectedCharacterId, characters } = get(); if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) { set({ phase: session ? "characters" : "login", notice: "Select or create a character first." }); return false; } set({ phase: "arenas", activeCharacter: null, notice: "" }); return true; }, openGmAdmin: () => { const { session, selectedCharacterId, characters } = get(); if (!isGameMasterSession(session)) { set({ notice: "Game Master access is required." }); return false; } if (!selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) { set({ phase: "characters", notice: "Select a character before opening GM Tools." }); return false; } set({ phase: "gm-admin", activeCharacter: null, notice: "" }); return true; }, openClassHall: () => { const { session, selectedCharacterId, characters } = get(); const character = characters.find((entry) => entry.id === selectedCharacterId); if (!session || !character) { set({ notice: "Select a character before opening the Class Hall." }); return false; } set({ phase: "class-hall", activeCharacter: null, notice: "" }); return true; }, changeSelectedSpecialization: (specializationId, resetTalents) => { const { session, selectedCharacterId, phase } = get(); if (!session || !selectedCharacterId || phase !== "class-hall") { set({ notice: "Specializations may only be changed inside the Class Hall." }); return false; } const definition = specializationById(specializationId); if (!definition) { set({ notice: "That specialization is unavailable." }); return false; } const result = updateCharacterSpecialization( session.ownerId, selectedCharacterId, definition.id, resetTalents, ); if (!result.ok || !result.character) { set({ notice: result.error ?? "The specialization could not be changed." }); return false; } set((state) => ({ characters: state.characters.map((entry) => entry.id === result.character!.id ? result.character! : entry), notice: `${definition.displayName} selected. ${resetTalents ? "Talents reset." : "Talents kept."}`, })); return true; }, chooseRomSecondaryClass: (classId) => { const { session, selectedCharacterId } = get(); if (!session || !selectedCharacterId) return false; const result = assignRomSecondaryClass(session.ownerId, selectedCharacterId, classId); if (!result.ok || !result.character) { set({ notice: result.error ?? "The secondary class could not be selected." }); return false; } set((state) => ({ characters: state.characters.map((entry) => entry.id === result.character?.id ? result.character : entry), notice: `${result.character?.name} permanently learned ${result.character ? classById(result.character.secondaryClassId!).name : "the secondary class"}.`, })); return true; }, swapSelectedRomClasses: () => { const { session, selectedCharacterId, phase } = get(); if (!session || !selectedCharacterId || phase !== "class-hall") { set({ notice: "Classes may only be swapped inside the Class Hall." }); return false; } const result = swapRomClasses(session.ownerId, selectedCharacterId); if (!result.ok || !result.character) { set({ notice: result.error ?? "The classes could not be swapped." }); return false; } set((state) => ({ characters: state.characters.map((entry) => entry.id === result.character?.id ? result.character : entry), notice: `${classById(result.character!.classId).name} is now the active primary class.`, })); return true; }, returnToMainMenu: () => { useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); manastormShellRuntime()?.leaveActiveGame(); const { session, characters, activeCharacter, selectedCharacterId } = get(); if (!session) { set({ phase: "login", activeCharacter: null }); return; } const currentId = activeCharacter?.id ?? selectedCharacterId; if (!currentId || !characters.some((character) => character.id === currentId)) { set({ phase: "characters", selectedCharacterId: characters[0]?.id ?? null, activeCharacter: null, notice: "Select a character." }); return; } set({ phase: "main-menu", selectedCharacterId: currentId, activeCharacter: null, notice: "" }); }, enterDungeon: (dungeonId, requestedRole) => { const runtime = dungeonShellRuntime(); if (runtime) return runtime.enterDungeon(dungeonId, requestedRole); set({ notice: "Dungeon data is still loading. Try again in a moment." }); return false; }, enterManastorm: (requestedRole, requestedPartySize = 5, requestedStartingLevel) => { const runtime = manastormShellRuntime(); if (runtime) return runtime.enterManastorm(requestedRole, requestedPartySize, requestedStartingLevel); set({ notice: "Manastorm data is still loading. Try again in a moment." }); return false; }, abandonManastormRun: () => manastormShellRuntime()?.abandonRun(), failActiveManastorm: () => manastormShellRuntime()?.failRun(), enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID), returnToCharacters: () => { useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); const session = get().session; if (!session) { set({ phase: "login", activeCharacter: null }); return; } const characters = listCharacters(session.ownerId); const currentId = get().activeCharacter?.id ?? get().selectedCharacterId; set({ phase: "characters", characters, selectedCharacterId: characters.some((character) => character.id === currentId) ? currentId : characters[0]?.id ?? null, activeCharacter: null, notice: "", combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), }); }, saveActiveProgression: (level, experience, talentRanks) => { const { session, activeCharacter } = get(); if (!session || !activeCharacter) return; const updated = updateCharacterProgression(session.ownerId, activeCharacter.id, { level, experience, talentRanks, }); if (!updated) return; set((state) => ({ activeCharacter: updated, characters: state.characters.map((character) => character.id === updated.id ? updated : character), })); }, saveActiveActionBindings: (actionBindings) => { const { session, activeCharacter } = get(); if (!session || !activeCharacter) return; const updated = updateCharacterActionBindings( session.ownerId, activeCharacter.id, actionBindings, ); if (!updated) return; set((state) => ({ activeCharacter: updated, characters: state.characters.map((character) => character.id === updated.id ? updated : character), })); }, saveActiveInventory: (inventory) => { const { session, activeCharacter } = get(); if (!session || !activeCharacter) return; const updated = updateCharacterInventory( session.ownerId, activeCharacter.id, inventory, ); if (!updated) return; set((state) => ({ activeCharacter: updated, characters: state.characters.map((character) => character.id === updated.id ? updated : character), })); }, saveActiveEquipment: (equipment) => { const { session, activeCharacter } = get(); if (!session || !activeCharacter) return; const updated = updateCharacterEquipment( session.ownerId, activeCharacter.id, equipment, ); if (!updated) return; set((state) => ({ activeCharacter: updated, characters: state.characters.map((character) => character.id === updated.id ? updated : character), })); }, saveActiveSettings: (settings) => { const { session, activeCharacter } = get(); if (!session || !activeCharacter) return; const updated = updateCharacterSettings( session.ownerId, activeCharacter.id, settings, ); if (!updated) return; set((state) => ({ activeCharacter: updated, characters: state.characters.map((character) => character.id === updated.id ? updated : character), })); }, updateCombatPresentationSettings: (patch) => { const { session, combatPresentationSettings } = get(); const next = normalizeCombatPresentationSettings({ ...combatPresentationSettings, ...patch, }, combatPresentationSettings); const persisted = session ? persistCombatPresentationSettings(session.ownerId, next) : next; set({ combatPresentationSettings: persisted }); }, resetCombatPresentationSettings: () => { const { session } = get(); const settings = session ? persistCombatPresentationSettings(session.ownerId, DEFAULT_COMBAT_PRESENTATION_SETTINGS) : DEFAULT_COMBAT_PRESENTATION_SETTINGS; set({ combatPresentationSettings: settings }); }, saveActiveManastormProgress: (progress) => { const { session, activeCharacter } = get(); if (!session || !activeCharacter) return; const updated = updateCharacterManastormProgress( session.ownerId, activeCharacter.id, progress, ); if (!updated) return; set((state) => ({ activeCharacter: updated, characters: state.characters.map((character) => character.id === updated.id ? updated : character), })); }, saveActiveDungeonRunCheckpoint: (checkpoint) => { const { session, activeCharacter } = get(); if (!session || !activeCharacter) return; const updated = updateCharacterDungeonRunCheckpoint( session.ownerId, activeCharacter.id, checkpoint, ); if (!updated) return; set((state) => ({ activeCharacter: updated, characters: state.characters.map((character) => character.id === updated.id ? updated : character), })); }, saveSelectedManastormLoadout: (loadout) => { const runtime = manastormShellRuntime(); if (runtime) return runtime.saveSelectedLoadout(loadout); set({ notice: "Manastorm data is still loading. Try again in a moment." }); return false; }, setNotice: (notice) => set({ notice }), })); export function selectedCharacter(state: ShellState): CharacterProfile | null { return state.characters.find((character) => character.id === state.selectedCharacterId) ?? null; } if (restored.session?.accessToken) { activateOnlineSession(restored.session); void Promise.all([ hydrateOnlineSession(restored.session), refreshOnlineSession(restored.session), ]).then(([characters, refreshedSession]) => { const current = useShellStore.getState().session; if (current?.ownerId !== restored.session?.ownerId) return; saveSession(refreshedSession); useShellStore.setState({ session: refreshedSession, characters, selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId) ? useShellStore.getState().selectedCharacterId : characters[0]?.id ?? null, notice: "Cloud roster synchronized.", }); }).catch((error) => { useShellStore.setState({ notice: error instanceof Error ? `${error.message} Using the cached roster.` : "Cloud sync failed. Using the cached roster.", }); }); }