diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 0719371..aef2dae 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-09T07:33:46.167Z +Generated: 2026-06-09T08:00:04.568Z Found: 2 circular chain(s) — these MUST be fixed before modifying involved files. 1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 96e6ab1..201f3ed 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-09T07:33:43.815Z", + "generated": "2026-06-09T08:00:02.543Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, diff --git a/src/components/game/tabs/DisciplinesTab.tsx b/src/components/game/tabs/DisciplinesTab.tsx index e1e5833..25244bc 100644 --- a/src/components/game/tabs/DisciplinesTab.tsx +++ b/src/components/game/tabs/DisciplinesTab.tsx @@ -85,16 +85,19 @@ export const DisciplinesTab: React.FC = () => { const [activeAttunement, setActiveAttunement] = useState('base'); const elements = useManaStore((s) => s.elements); - const rawMana = useManaStore((s) => s.rawMana); const signedPacts = usePrestigeStore((s) => s.signedPacts); + // NOTE: activate() now reads rawMana/elements/signedPacts directly from the + // mana and prestige stores, so the UI only needs to pass the discipline id. + // This prevents the recurring bug where a missing field in a manually + // constructed gameState bag silently prevented reactivation. const handleToggle = useCallback((id: string, paused: boolean) => { if (paused) { - activate(id, { elements, rawMana, signedPacts }); + activate(id); } else { deactivate(id); } - }, [activate, deactivate, elements, rawMana, signedPacts]); + }, [activate, deactivate]); const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement); diff --git a/src/lib/game/__tests__/discipline-reactivate-bug.test.ts b/src/lib/game/__tests__/discipline-reactivate-bug.test.ts index 4594345..2a6bb95 100644 --- a/src/lib/game/__tests__/discipline-reactivate-bug.test.ts +++ b/src/lib/game/__tests__/discipline-reactivate-bug.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useDisciplineStore } from '../stores/discipline-slice'; +import { useManaStore } from '../stores/manaStore'; function resetDisciplineStore() { useDisciplineStore.setState({ @@ -12,11 +13,14 @@ function resetDisciplineStore() { }); } -describe('DisciplineStore — reactivate after deactivate (bug #163)', () => { +describe('DisciplineStore — reactivate after deactivate', () => { beforeEach(resetDisciplineStore); it('should reactivate a raw discipline after deactivate when rawMana is sufficient', () => { - // First activation succeeds because disciplineState is undefined (optimistic) + // Set up mana store with sufficient raw mana + useManaStore.setState({ rawMana: 1000, elements: {} }); + + // First activation succeeds (reads rawMana from mana store) useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); @@ -25,15 +29,19 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => { expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true); - // Reactivate WITH sufficient rawMana — should succeed - useDisciplineStore.getState().activate('raw-mastery', { rawMana: 1000, elements: {}, signedPacts: [] }); + // Reactivate — reads rawMana=1000 from mana store, should succeed + useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false); }); it('should NOT reactivate a raw discipline when rawMana is insufficient', () => { - // Activate and build up XP + // Activate with sufficient mana + useManaStore.setState({ rawMana: 1000, elements: {} }); useDisciplineStore.getState().activate('raw-mastery'); + expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); + + // Build up XP via ticks useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); const xp = useDisciplineStore.getState().disciplines['raw-mastery'].xp; @@ -43,29 +51,33 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => { useDisciplineStore.getState().deactivate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); - // Reactivate with insufficient rawMana (0) — should fail - useDisciplineStore.getState().activate('raw-mastery', { rawMana: 0, elements: {}, signedPacts: [] }); + // Set rawMana to 0 — reactivation should fail + useManaStore.setState({ rawMana: 0, elements: {} }); + useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); }); it('should reactivate a discipline with elements after deactivating it', () => { - useDisciplineStore.getState().activate('attune-fire', { + useManaStore.setState({ + rawMana: 0, elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } }, }); + + useDisciplineStore.getState().activate('attune-fire'); expect(useDisciplineStore.getState().activeIds).toContain('attune-fire'); useDisciplineStore.getState().deactivate('attune-fire'); expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire'); - useDisciplineStore.getState().activate('attune-fire', { - elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } }, - }); + // Reactivate — reads elements from mana store + useDisciplineStore.getState().activate('attune-fire'); expect(useDisciplineStore.getState().activeIds).toContain('attune-fire'); expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false); }); it('should reactivate after processTick auto-pauses due to no mana', () => { - useDisciplineStore.getState().activate('raw-mastery', { rawMana: 100, elements: {} }); + useManaStore.setState({ rawMana: 100, elements: {} }); + useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); // Tick with no mana — discipline auto-pauses @@ -73,14 +85,16 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => { expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true); - // Reactivate with sufficient mana - useDisciplineStore.getState().activate('raw-mastery', { rawMana: 1000, elements: {} }); + // Restore mana and reactivate + useManaStore.setState({ rawMana: 1000, elements: {} }); + useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false); }); it('should preserve XP when deactivating and reactivating', () => { - useDisciplineStore.getState().activate('raw-mastery', { rawMana: 1000, elements: {} }); + useManaStore.setState({ rawMana: 1000, elements: {} }); + useDisciplineStore.getState().activate('raw-mastery'); useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); @@ -90,11 +104,30 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => { expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(3); expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true); - useDisciplineStore.getState().activate('raw-mastery', { rawMana: 1000, elements: {} }); + useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(3); useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(4); }); + + it('should use gameState overrides when explicitly provided (for test control)', () => { + // Even though mana store has no mana, explicit override should work + useManaStore.setState({ rawMana: 0, elements: {} }); + + // First activation: no state yet, so canProceed returns true (optimistic) + useDisciplineStore.getState().activate('raw-mastery'); + expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); + + useDisciplineStore.getState().deactivate('raw-mastery'); + + // Reactivate with explicit override — bypasses store reads + useDisciplineStore.getState().activate('raw-mastery', { + rawMana: 1000, + elements: {}, + signedPacts: [], + }); + expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); + }); }); diff --git a/src/lib/game/stores/discipline-slice.ts b/src/lib/game/stores/discipline-slice.ts index c593823..18de995 100644 --- a/src/lib/game/stores/discipline-slice.ts +++ b/src/lib/game/stores/discipline-slice.ts @@ -22,6 +22,8 @@ import { enchanterSpecialDisciplines } from '../data/disciplines/enchanter-speci 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 = [ @@ -48,7 +50,7 @@ export interface DisciplineStoreState { } export interface DisciplineStoreActions { - activate: (id: string, gameState?: { elements?: Record; rawMana?: number; signedPacts?: number[] }) => void; + activate: (id: string, gameStateOverrides?: { elements?: Record; rawMana?: number; signedPacts?: number[] }) => void; deactivate: (id: string) => void; processTick: (mana: { rawMana: number; elements: Record }) => { rawMana: number; @@ -73,7 +75,7 @@ export const useDisciplineStore = create()( processedPerks: [], practicingCallbacks: null, - activate(id, gameState) { + activate(id, gameStateOverrides) { set((s) => { const def = DISCIPLINE_MAP[id]; if (!def) return s; @@ -98,15 +100,26 @@ export const useDisciplineStore = create()( return d && !d.paused; }).length; if (nonPaused >= s.concurrentLimit) return s; - if (!canProceedDiscipline(def, existing, gameState)) 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, gameState?.elements, gameState?.signedPacts); + 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 = gameState?.elements; + const elements = resolvedState.elements; if (elements) { for (const srcType of def.sourceManaTypes) { if (srcType === 'raw') continue; // raw is always available