From bdf2b0050f6062f7f8864167ff9792ebf6584c9f Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 10 Jun 2026 10:50:40 +0200 Subject: [PATCH] fix: apply mana drain when practicing disciplines - processTick() now calls calculateManaDrain() for non-conversion disciplines - Insufficient mana triggers auto-paused state (skips XP accrual) - Conversion disciplines (sourceManaTypes) correctly skip pool drain - Auto-paused disciplines can be re-activated when mana is restored - Updated 7 tests across 3 files to reflect drain model --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- .../cross-module-prestige-discipline.test.ts | 15 ++-- .../discipline-reactivate-bug.test.ts | 15 ++-- .../store-actions-discipline.test.ts | 89 +++++++++++++++++-- src/lib/game/stores/discipline-slice.ts | 48 +++++++++- 6 files changed, 145 insertions(+), 26 deletions(-) diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 2cb0f47..ab8e146 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-10T08:00:19.784Z +Generated: 2026-06-10T08:14:33.822Z 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 8fc4f54..7c0e80f 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-10T08:00:17.492Z", + "generated": "2026-06-10T08:14:31.514Z", "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/lib/game/__tests__/cross-module-prestige-discipline.test.ts b/src/lib/game/__tests__/cross-module-prestige-discipline.test.ts index 7c8f40d..12da359 100644 --- a/src/lib/game/__tests__/cross-module-prestige-discipline.test.ts +++ b/src/lib/game/__tests__/cross-module-prestige-discipline.test.ts @@ -120,23 +120,23 @@ describe('Cross-Module: Prestige & Discipline', () => { expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0); }); - it('should NOT drain raw mana for raw-type disciplines (no drain model)', () => { + it('should drain raw mana for raw-type disciplines', () => { useDisciplineStore.getState().activate('raw-mastery'); useManaStore.setState({ rawMana: 99999 }); const rawManaBefore = useManaStore.getState().rawMana; // Call processTick directly (not game tick) to isolate discipline drain - useDisciplineStore.getState().processTick({ rawMana: rawManaBefore, elements: {} }); + const result = useDisciplineStore.getState().processTick({ rawMana: rawManaBefore, elements: {} }); expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0); const disc = useDisciplineStore.getState().disciplines['raw-mastery']; expect(disc).toBeDefined(); expect(disc!.xp).toBeGreaterThan(0); - // Returned rawMana should be unchanged (no drain) - // (processTick returns the same rawMana it received) + // raw-mastery: drainBase=1, xp=0 → drain=1; rawMana should be reduced by 1 + expect(result.rawMana).toBe(rawManaBefore - 1); }); - it('should NOT pause discipline when insufficient mana (no drain model)', () => { + it('should auto-pause discipline when insufficient mana and not accrue XP', () => { useDisciplineStore.getState().activate('raw-mastery'); useManaStore.setState({ rawMana: 0 }); @@ -144,8 +144,9 @@ describe('Cross-Module: Prestige & Discipline', () => { const disc = useDisciplineStore.getState().disciplines['raw-mastery']; expect(disc).toBeDefined(); - expect(disc!.paused).toBe(false); - expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0); + expect(disc!.autoPaused).toBe(true); + // XP should NOT accrue while auto-paused due to insufficient mana + expect(useDisciplineStore.getState().totalXP).toBe(0); }); it('should respect concurrent limit', () => { diff --git a/src/lib/game/__tests__/discipline-reactivate-bug.test.ts b/src/lib/game/__tests__/discipline-reactivate-bug.test.ts index 0475f58..e629515 100644 --- a/src/lib/game/__tests__/discipline-reactivate-bug.test.ts +++ b/src/lib/game/__tests__/discipline-reactivate-bug.test.ts @@ -35,7 +35,7 @@ describe('DisciplineStore — reactivate after deactivate', () => { expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false); }); - it('should reactivate even with zero mana (no drain model)', () => { + it('should reactivate even with zero mana (activation is optimistic, drain applies on tick)', () => { // Activate with sufficient mana useManaStore.setState({ rawMana: 1000, elements: {} }); useDisciplineStore.getState().activate('raw-mastery'); @@ -51,7 +51,8 @@ describe('DisciplineStore — reactivate after deactivate', () => { useDisciplineStore.getState().deactivate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); - // Set rawMana to 0 — reactivation should still succeed (no drain model) + // Set rawMana to 0 — reactivation should still succeed (activation is optimistic) + // but the discipline will auto-pause on the next tick due to insufficient mana useManaStore.setState({ rawMana: 0, elements: {} }); useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); @@ -75,18 +76,18 @@ describe('DisciplineStore — reactivate after deactivate', () => { expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false); }); - it('should not auto-pause on zero mana (no drain model)', () => { + it('should auto-pause on zero mana and not accrue XP', () => { useManaStore.setState({ rawMana: 100, elements: {} }); useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); - // Tick with no mana — discipline should NOT auto-pause (no drain model) + // Tick with no mana — discipline should auto-pause (drain model) useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); - expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].autoPaused).toBe(true); - // XP should still accrue - expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1); + // XP should NOT accrue when auto-paused + expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(0); }); it('should preserve XP when deactivating and reactivating', () => { diff --git a/src/lib/game/__tests__/store-actions-discipline.test.ts b/src/lib/game/__tests__/store-actions-discipline.test.ts index 5ed19d4..b0fa236 100644 --- a/src/lib/game/__tests__/store-actions-discipline.test.ts +++ b/src/lib/game/__tests__/store-actions-discipline.test.ts @@ -79,22 +79,97 @@ describe('DisciplineStore', () => { expect(useDisciplineStore.getState().totalXP).toBe(1); }); - it('should not drain raw mana (no drain model)', () => { + it('should drain raw mana when practicing with sufficient mana', () => { useDisciplineStore.getState().activate('raw-mastery'); const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); + // raw-mastery: drainBase=1, xp=0, difficultyFactor=100 + // drain = 1 * (1 + (0/100)^0.4) = 1 + expect(result.rawMana).toBe(999); + }); + + it('should auto-pause discipline when raw mana is insufficient', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].autoPaused).toBe(true); + }); + + it('should not accrue XP when auto-paused due to insufficient mana', () => { + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(0); + }); + + it('should auto-pause discipline and report name when element mana is insufficient', () => { + useDisciplineStore.setState({ + disciplines: {}, + activeIds: [], + concurrentLimit: 1, + totalXP: 0, + }); + // attune-fire has drainBase=2, manaType=fire + useDisciplineStore.getState().activate('attune-fire', { + elements: { fire: { unlocked: true, current: 1, max: 100, baseMax: 100 } }, + }); + const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 1, max: 100, baseMax: 100 } } }); + expect(useDisciplineStore.getState().disciplines['attune-fire'].autoPaused).toBe(true); + expect(result.autoPausedNames).toContain('Fire Mana Capacity'); + }); + + it('should drain element mana when practicing element discipline with sufficient mana', () => { + useDisciplineStore.setState({ + disciplines: {}, + activeIds: [], + concurrentLimit: 1, + totalXP: 0, + }); + useDisciplineStore.getState().activate('attune-fire', { + elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } }, + }); + const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } } }); + // attune-fire: drainBase=2, xp=0, difficultyFactor=150 + // drain = 2 * (1 + (0/150)^0.4) = 2 + expect(result.elements.fire.current).toBe(98); + }); + + it('should not drain mana for conversion disciplines (sourceManaTypes)', () => { + useDisciplineStore.setState({ + disciplines: {}, + activeIds: [], + concurrentLimit: 1, + totalXP: 0, + }); + // regen-fire is a conversion discipline with sourceManaTypes: ['raw'] + useDisciplineStore.getState().activate('regen-fire', { + elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } }, + }); + const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } } }); + // Conversion disciplines should NOT drain from pools expect(result.rawMana).toBe(1000); + expect(result.elements.fire.current).toBe(100); }); - it('should not auto-pause discipline (no drain model)', () => { + it('should re-activate auto-paused discipline when mana is restored', () => { useDisciplineStore.getState().activate('raw-mastery'); + // Auto-pause with 0 mana useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); - expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].autoPaused).toBe(true); + // Re-activate (simulates mana being restored) + useDisciplineStore.getState().activate('raw-mastery'); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].autoPaused).toBe(false); + expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); }); - it('should still accrue XP even with zero mana', () => { - useDisciplineStore.getState().activate('raw-mastery'); - useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); - expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1); + it('should scale drain with XP', () => { + useDisciplineStore.setState({ + disciplines: { 'raw-mastery': { id: 'raw-mastery', xp: 100, paused: false } }, + activeIds: ['raw-mastery'], + concurrentLimit: 1, + totalXP: 100, + }); + const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); + // raw-mastery: drainBase=1, xp=100, difficultyFactor=100 + // drain = 1 * (1 + (100/100)^0.4) = 1 * (1 + 1) = 2 + expect(result.rawMana).toBe(998); }); it('should increase concurrent limit at 500 total XP', () => { diff --git a/src/lib/game/stores/discipline-slice.ts b/src/lib/game/stores/discipline-slice.ts index 88cd0bb..92b1c55 100644 --- a/src/lib/game/stores/discipline-slice.ts +++ b/src/lib/game/stores/discipline-slice.ts @@ -6,6 +6,7 @@ import type { DisciplineState } from '../types/disciplines'; import type { ElementState } from '../types'; import { calculateStatBonus, + calculateManaDrain, canProceedDiscipline, checkDisciplinePrerequisites, getUnlockedPerks @@ -85,12 +86,18 @@ export const useDisciplineStore = create()( // 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 already active and paused (manually or auto-paused due to insufficient mana), un-pause it if (existing?.paused) { return { disciplines: { ...s.disciplines, [id]: { ...existing, paused: false, autoPaused: false } }, }; } + // If auto-paused due to insufficient mana, allow re-activation (un-pause when mana is restored) + if (existing?.autoPaused) { + return { + disciplines: { ...s.disciplines, [id]: { ...existing, autoPaused: false } }, + }; + } return s; } @@ -170,13 +177,14 @@ export const useDisciplineStore = create()( processTick(mana) { const s = get(); - const rawMana = mana.rawMana; + let 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 ?? [])]; + const autoPausedNames: string[] = []; for (const id of s.activeIds ?? []) { const disc = newDisciplines[id]; @@ -187,6 +195,40 @@ export const useDisciplineStore = create()( const def = DISCIPLINE_MAP[id]; if (!def) continue; + // ── Mana drain (skip for conversion disciplines) ────────────── + // Conversion disciplines (with sourceManaTypes) are handled by + // the unified conversion system — they contribute to conversion + // rates, not direct pool drain. + const isConversionDiscipline = !!(def.sourceManaTypes && def.sourceManaTypes.length > 0); + + if (!isConversionDiscipline) { + const drain = calculateManaDrain(def.drainBase, disc.xp, def.difficultyFactor); + + // Determine which mana pool to drain from + if (def.manaType === 'raw') { + if (rawMana < drain) { + // Insufficient raw mana — auto-pause + newDisciplines[id] = { ...disc, autoPaused: true }; + autoPausedNames.push(def.name); + continue; // skip XP accrual + } + rawMana = Math.max(0, rawMana - drain); + } else { + // Element-specific drain + const elemState = elements[def.manaType]; + if (!elemState || elemState.current < drain) { + // Insufficient element mana — auto-pause + newDisciplines[id] = { ...disc, autoPaused: true }; + autoPausedNames.push(def.name); + continue; // skip XP accrual + } + elements[def.manaType] = { + ...elemState, + current: Math.max(0, elemState.current - drain), + }; + } + } + const oldXP = disc.xp; // Compute discipline XP bonus directly to avoid circular import let xpBonus = 0; @@ -255,7 +297,7 @@ export const useDisciplineStore = create()( processedPerks: newProcessedPerks, }); - return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes, autoPausedNames: [] }; + 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 }) }