// ─── Discipline Store Slice ──────────────────────────────────────────────────── import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { createSafeStorage } from '../utils/safe-persist'; import type { DisciplineState } from '../types/disciplines'; import type { ElementState } from '../types'; import { calculateStatBonus, canProceedDiscipline, checkDisciplinePrerequisites, getUnlockedPerks } from '../utils/discipline-math'; import { baseDisciplines } from '../data/disciplines/base'; import { elementalAttunementDisciplines } from '../data/disciplines/elemental'; import { elementalRegenDisciplines } from '../data/disciplines/elemental-regen'; import { elementalRegenAdvancedDisciplines } from '../data/disciplines/elemental-regen-advanced'; import { enchanterDisciplines } from '../data/disciplines/enchanter'; import { enchanterUtilityDisciplines } from '../data/disciplines/enchanter-utility'; import { enchanterSpellDisciplines } from '../data/disciplines/enchanter-spells'; import { enchanterSpecialDisciplines } from '../data/disciplines/enchanter-special'; import { fabricatorDisciplines } from '../data/disciplines/fabricator'; import { invokerDisciplines } from '../data/disciplines/invoker'; import { MAX_CONCURRENT_DISCIPLINES } from '../types/disciplines'; import { useManaStore } from './manaStore'; import { usePrestigeStore } from './prestigeStore'; const ALL_DISCIPLINES = [ ...baseDisciplines, ...elementalAttunementDisciplines, ...elementalRegenDisciplines, ...elementalRegenAdvancedDisciplines, ...enchanterDisciplines, ...enchanterUtilityDisciplines, ...enchanterSpellDisciplines, ...enchanterSpecialDisciplines, ...fabricatorDisciplines, ...invokerDisciplines, ]; const DISCIPLINE_MAP = Object.fromEntries(ALL_DISCIPLINES.map((d) => [d.id, d])); export interface DisciplineStoreState { disciplines: Record; activeIds: string[]; concurrentLimit: number; totalXP: number; processedPerks: string[]; practicingCallbacks: { onStartPracticing: () => void; onStopPracticing: () => void } | null; } export interface DisciplineStoreActions { activate: (id: string, gameStateOverrides?: { elements?: Record; rawMana?: number; signedPacts?: number[] }) => void; deactivate: (id: string) => void; processTick: (mana: { rawMana: number; elements: Record }) => { rawMana: number; elements: Record; unlockedEffects: string[]; unlockedRecipes: string[]; autoPausedNames: string[]; }; setPracticingCallbacks(callbacks: { onStartPracticing: () => void; onStopPracticing: () => void }): void; resetDisciplines: () => void; } export type DisciplineStore = DisciplineStoreState & DisciplineStoreActions; export const useDisciplineStore = create()( persist( (set, get) => ({ disciplines: {}, activeIds: [], concurrentLimit: MAX_CONCURRENT_DISCIPLINES, totalXP: 0, processedPerks: [], practicingCallbacks: null, activate(id, gameStateOverrides) { set((s) => { const def = DISCIPLINE_MAP[id]; if (!def) return s; // Guard against corrupted persisted state const activeIds = s.activeIds ?? []; // Allow re-activation if discipline exists but is paused const existing = s.disciplines[id]; if (activeIds.includes(id)) { // If already active and paused (manually or auto), un-pause it if (existing?.paused) { return { disciplines: { ...s.disciplines, [id]: { ...existing, paused: false, autoPaused: false } }, }; } return s; } const nonPaused = activeIds.filter((aid) => { const d = s.disciplines[aid]; return d && !d.paused; }).length; if (nonPaused >= s.concurrentLimit) return s; // Resolve game state: use overrides if provided (for tests), otherwise read // directly from the mana and prestige stores. This prevents the UI from needing // to manually construct and pass a gameState bag — eliminating the class of bug // where a missing field (e.g. rawMana) silently prevents reactivation. const resolvedState = gameStateOverrides ?? { rawMana: useManaStore.getState().rawMana, elements: useManaStore.getState().elements, signedPacts: usePrestigeStore.getState().signedPacts, }; if (!canProceedDiscipline(def, existing, resolvedState)) return s; // Check discipline prerequisites (requires field → discipline XP or mana type unlock) const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES, resolvedState.elements, resolvedState.signedPacts); if (!prereqCheck.canProceed) return s; // For conversion disciplines: gate on having all source mana types unlocked if (def.sourceManaTypes && def.sourceManaTypes.length > 0) { const elements = resolvedState.elements; if (elements) { for (const srcType of def.sourceManaTypes) { if (srcType === 'raw') continue; // raw is always available const srcElem = elements[srcType]; if (!srcElem || !srcElem.unlocked) return s; } } } const discState = existing || { id, xp: 0, paused: false }; // Set currentAction to 'practicing' (only overrides 'meditate') get().practicingCallbacks?.onStartPracticing?.(); return { disciplines: { ...s.disciplines, [id]: { ...discState, paused: false } }, activeIds: [...activeIds, id], }; }); }, deactivate(id) { set((s) => { const newActiveIds = s.activeIds.filter((aid) => aid !== id); // If no more active disciplines, restore currentAction to 'meditate' if (newActiveIds.length === 0) { get().practicingCallbacks?.onStopPracticing?.(); } return { activeIds: newActiveIds, disciplines: s.disciplines[id] ? { ...s.disciplines, [id]: { ...s.disciplines[id], paused: true } } : s.disciplines, }; }); }, setPracticingCallbacks(callbacks) { set({ practicingCallbacks: callbacks }); }, resetDisciplines() { set({ disciplines: {}, activeIds: [], concurrentLimit: MAX_CONCURRENT_DISCIPLINES, totalXP: 0, processedPerks: [], }); }, processTick(mana) { const s = get(); const rawMana = mana.rawMana; const elements = { ...mana.elements }; let newXP = s.totalXP; const newDisciplines = { ...s.disciplines }; const newUnlockedEffects: string[] = []; const newUnlockedRecipes: string[] = []; const newProcessedPerks = [...(s.processedPerks ?? [])]; for (const id of s.activeIds ?? []) { const disc = newDisciplines[id]; if (!disc) continue; if (disc.paused) continue; if (disc.autoPaused) continue; // already auto-paused, don't re-process const def = DISCIPLINE_MAP[id]; if (!def) continue; const oldXP = disc.xp; // Compute discipline XP bonus directly to avoid circular import let xpBonus = 0; for (const [did, dState] of Object.entries(newDisciplines)) { if (!dState || dState.xp <= 0) continue; const dDef = DISCIPLINE_MAP[did]; if (!dDef) continue; // Only disciplines with disciplineXpBonus stat contribute if (dDef.statBonus.stat === 'disciplineXpBonus') { xpBonus += calculateStatBonus( dDef.statBonus.baseValue, dState.xp, dDef.scalingFactor ); } // Perk bonuses for disciplineXpBonus const perks = getUnlockedPerks(dDef, dState.xp); for (const perk of perks) { if (perk.bonus && perk.bonus.stat === 'disciplineXpBonus') { xpBonus += perk.bonus.amount; } } } const xpGain = 1 + xpBonus; newDisciplines[id] = { ...disc, xp: disc.xp + xpGain }; newXP += xpGain; // Check for newly unlocked perks that unlock effects if (def.perks.length > 0) { const oldPerks = getUnlockedPerks(def, oldXP); const newPerks = getUnlockedPerks(def, disc.xp + 1); const oldPerkIds = new Set(oldPerks.map(p => p.id)); for (const perk of newPerks) { if (!oldPerkIds.has(perk.id)) { const perkKey = `${id}:${perk.id}`; if (!newProcessedPerks.includes(perkKey)) { if (perk.unlocksEffects) { newUnlockedEffects.push(...perk.unlocksEffects); } if (perk.unlocksRecipes) { newUnlockedRecipes.push(...perk.unlocksRecipes); } newProcessedPerks.push(perkKey); } } } } } const newLimit = Math.min( MAX_CONCURRENT_DISCIPLINES + Math.floor(newXP / 500), MAX_CONCURRENT_DISCIPLINES + 3 ); // Check if no active disciplines remain to fire onStopPracticing const keepActiveIds = s.activeIds ?? []; if (keepActiveIds.length === 0 && s.activeIds.length > 0) { get().practicingCallbacks?.onStopPracticing?.(); } set({ disciplines: newDisciplines, activeIds: keepActiveIds, totalXP: newXP, concurrentLimit: Math.max(s.concurrentLimit, newLimit), processedPerks: newProcessedPerks, }); return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes, autoPausedNames: [] }; }, }), { storage: createSafeStorage(), name: 'mana-loop-discipline-store', version: 1, partialize: (state) => ({ disciplines: state.disciplines, activeIds: state.activeIds, concurrentLimit: state.concurrentLimit, totalXP: state.totalXP, processedPerks: state.processedPerks }) } ) );