From 3ad919a0476b58a86ae1a858680bc4a3a4ea521c Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Tue, 9 Jun 2026 11:18:41 +0200 Subject: [PATCH] fix: remove discipline pool-drain model, add conversion stats UI per mana-conversion-spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DISC-2: Removed old pool-drain model from discipline-slice.ts processTick() - Disciplines no longer drain rawMana or element pools - canProceedDiscipline() no longer checks mana sufficiency - Removed auto-pause on insufficient mana from processTick() - Removed drain display and auto-paused message from DisciplineCard.tsx - Removed auto-paused log from gameStore.ts tick() DISC-4: Audited crafting pipeline — no composite crafting logic remains - craftComposite already removed from manaStore.ts (comment only) - No other composite crafting references found DISC-5: Added collapsible formula reference to Conversion Stats section - Shows unified formula, multipliers, cost formulas, and constraints DISC-6: Added per-element net regen summary line - Shows 'Net Fire Regen: +0.50/hr − 0.15/hr = +0.35/hr' per element DISC-7: Created dedicated 'Conversion Stats' section in Stats tab - Renamed from 'Conversion Breakdown' to dedicated section header DISC-8: Added detailed per-element regen breakdown to ManaDisplay - Each element card now expandable to show produced rate and downstream drains - New ElementRegenBreakdown type and elementRegenBreakdown prop Tests: Updated 4 test files to reflect new no-drain behavior - All 1090 tests pass --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 4 +- src/app/components/LeftPanel.tsx | 56 ++++++++++++++- src/components/game/ManaDisplay.tsx | 52 ++++++++++++++ src/components/game/tabs/DisciplineCard.tsx | 15 +--- .../tabs/StatsTab/ElementStatsSection.tsx | 70 ++++++++++++++++--- .../cross-module-prestige-discipline.test.ts | 18 ++--- .../game/__tests__/discipline-math.test.ts | 14 +++- .../discipline-reactivate-bug.test.ts | 19 +++-- .../store-actions-discipline.test.ts | 18 +++-- src/lib/game/stores/discipline-slice.ts | 35 ++-------- src/lib/game/stores/gameStore.ts | 1 - src/lib/game/utils/discipline-math.ts | 19 +++-- 13 files changed, 230 insertions(+), 93 deletions(-) diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index aef2dae..8362560 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-09T08:00:04.568Z +Generated: 2026-06-09T08:14:36.361Z 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 201f3ed..ddb926c 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-09T08:00:02.543Z", + "generated": "2026-06-09T08:14:34.357Z", "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." }, @@ -646,6 +646,8 @@ "data/disciplines/enchanter.ts", "data/disciplines/fabricator.ts", "data/disciplines/invoker.ts", + "stores/manaStore.ts", + "stores/prestigeStore.ts", "types.ts", "types/disciplines.ts", "utils/discipline-math.ts", diff --git a/src/app/components/LeftPanel.tsx b/src/app/components/LeftPanel.tsx index 7d6b74e..620854a 100644 --- a/src/app/components/LeftPanel.tsx +++ b/src/app/components/LeftPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Mountain } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; @@ -9,11 +9,15 @@ import { ActionButtons } from '@/components/game'; import { AttunementStatus } from '@/components/game/AttunementStatus'; import { ActivityLogPanel } from '@/components/game/ActivityLogPanel'; import { DebugName } from '@/components/game/debug/debug-context'; -import { useGameStore, useManaStore, useCombatStore, useCraftingStore, usePrestigeStore } from '@/lib/game/stores'; +import { useGameStore, useManaStore, useCombatStore, useCraftingStore, usePrestigeStore, useAttunementStore } from '@/lib/game/stores'; import { getUnifiedEffects } from '@/lib/game/effects'; import { getMeditationBonus, getIncursionStrength } from '@/lib/game/stores'; import { computeTotalMaxMana, computeTotalRegen, computeTotalClickMana } from '@/lib/game/effects'; import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects'; +import { computeConversionRates } from '@/lib/game/utils/conversion-rates'; +import { getGuardianForFloor } from '@/lib/game/data/guardian-encounters'; +import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements'; +import type { ElementRegenBreakdown } from '@/components/game/ManaDisplay'; export function LeftPanel() { const [isGathering, setIsGathering] = useState(false); @@ -23,6 +27,8 @@ export function LeftPanel() { const meditateTicks = useManaStore((s) => s.meditateTicks); const elementRegen = useManaStore((s) => s.elementRegen); const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades); + const signedPacts = usePrestigeStore((s) => s.signedPacts); + const attunements = useAttunementStore((s) => s.attunements); const equippedInstances = useCraftingStore((s) => s.equippedInstances); const equipmentInstances = useCraftingStore((s) => s.equipmentInstances); const gatherMana = useGameStore((s) => s.gatherMana); @@ -64,6 +70,51 @@ export function LeftPanel() { const incursionStrength = getIncursionStrength(useGameStore((s) => s.day), useGameStore((s) => s.hour)); const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier; + // Compute per-element regen breakdown for ManaDisplay (DISC-8) + const elementRegenBreakdown = useMemo((): Record | undefined => { + const pactElementMap: Record = {}; + for (const floor of signedPacts) { + const g = getGuardianForFloor(floor); + if (g?.element?.length) pactElementMap[floor] = g.element[0]; + } + const grossRegen: Record = {}; + for (const [id, state] of Object.entries(attunements)) { + if (!state.active) continue; + const def = ATTUNEMENTS_DEF[id]; + if (def?.primaryManaType) { + grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0) + + (def.conversionRate || 0); + } + } + const invokerLevel = attunements.invoker?.active ? (attunements.invoker.level || 1) : 0; + const conversionResult = computeConversionRates({ + disciplineEffects, + attunements, + signedPacts, + pactElementMap, + invokerLevel, + meditationMultiplier, + grossRegen, + rawGrossRegen: baseRegen, + }); + const breakdown: Record = {}; + for (const [elem, entry] of Object.entries(conversionResult.rates)) { + if (entry.paused) continue; + const drains: Record = {}; + // This element is drained when it's a component of a higher conversion + for (const [destElem, destEntry] of Object.entries(conversionResult.rates)) { + if (destEntry.paused) continue; + if (destEntry.componentCosts[elem]) { + drains[destElem] = (drains[destElem] || 0) + destEntry.finalRate * destEntry.componentCosts[elem]; + } + } + if (entry.finalRate > 0 || Object.keys(drains).length > 0) { + breakdown[elem] = { produced: entry.finalRate, drains }; + } + } + return Object.keys(breakdown).length > 0 ? breakdown : undefined; + }, [disciplineEffects, attunements, signedPacts, meditationMultiplier, baseRegen]); + return (
{/* 1. Mana Display */} @@ -79,6 +130,7 @@ export function LeftPanel() { onGatherEnd={handleGatherEnd} elements={elements} elementRegen={elementRegen} + elementRegenBreakdown={elementRegenBreakdown} /> diff --git a/src/components/game/ManaDisplay.tsx b/src/components/game/ManaDisplay.tsx index 4e3432a..701db9e 100755 --- a/src/components/game/ManaDisplay.tsx +++ b/src/components/game/ManaDisplay.tsx @@ -9,6 +9,14 @@ import { ELEMENTS } from '@/lib/game/constants'; import { useState } from 'react'; import { DebugName } from '@/components/game/debug/debug-context'; +/** Per-element regen breakdown: produced rate and downstream drains */ +export interface ElementRegenBreakdown { + /** Rate at which this element is produced from conversion */ + produced: number; + /** Drains: destination element → rate consumed */ + drains: Record; +} + interface ManaDisplayProps { rawMana: number; maxMana: number; @@ -21,6 +29,8 @@ interface ManaDisplayProps { elements: Record; /** Per-element net regen rates (from unified conversion system) */ elementRegen?: Record; + /** Detailed per-element regen breakdown (produced rate + downstream drains) */ + elementRegenBreakdown?: Record; } export function ManaDisplay({ @@ -34,8 +44,14 @@ export function ManaDisplay({ onGatherEnd, elements, elementRegen, + elementRegenBreakdown, }: ManaDisplayProps) { const [expanded, setExpanded] = useState(true); + const [expandedElements, setExpandedElements] = useState>({}); + + const toggleElementDetail = (id: string) => { + setExpandedElements(prev => ({ ...prev, [id]: !prev[id] })); + }; const unlockedElements = Object.entries(elements) .filter(([, state]) => state.unlocked && state.current > 0) @@ -102,6 +118,9 @@ export function ManaDisplay({ const elem = ELEMENTS[id]; if (!elem) return null; const regen = elementRegen?.[id]; + const breakdown = elementRegenBreakdown?.[id]; + const hasBreakdown = breakdown && (breakdown.produced > 0 || Object.keys(breakdown.drains).length > 0); + const isExpanded = expandedElements[id]; return (
)}
+ {/* Expandable regen breakdown (DISC-8) */} + {hasBreakdown && ( + + )} + {hasBreakdown && isExpanded && ( +
+ {breakdown.produced > 0 && ( +
+ +{fmtDec(breakdown.produced, 2)}/hr + converted from raw +
+ )} + {Object.entries(breakdown.drains).map(([destId, drainRate]) => { + const destElem = ELEMENTS[destId]; + return ( +
+ -{fmtDec(drainRate, 2)}/hr + → {destElem?.sym} {destElem?.name} +
+ ); + })} +
= 0 ? 'var(--color-success)' : 'var(--color-error)' }}> + Net: {regen && regen >= 0 ? '+' : ''}{fmtDec(regen || 0, 2)}/hr +
+
+ )}
); })} diff --git a/src/components/game/tabs/DisciplineCard.tsx b/src/components/game/tabs/DisciplineCard.tsx index 76e4438..a7ec0b4 100644 --- a/src/components/game/tabs/DisciplineCard.tsx +++ b/src/components/game/tabs/DisciplineCard.tsx @@ -1,9 +1,9 @@ import React, { useMemo } from 'react'; import type { DisciplineDefinition } from '@/lib/game/types/disciplines'; import { ELEMENTS } from '@/lib/game/constants/elements'; -import { calculateStatBonus, calculateManaDrain } from '@/lib/game/utils/discipline-math'; +import { calculateStatBonus } from '@/lib/game/utils/discipline-math'; import clsx from 'clsx'; -import { TICKS_PER_SECOND, computePerkCurrentEffect, computeTotalPerkBonusForStat, isRateStat } from './disciplines-utils'; +import { computePerkCurrentEffect, computeTotalPerkBonusForStat, isRateStat } from './disciplines-utils'; import type { ComputedPerkEffect } from './disciplines-utils'; // ─── Props ──────────────────────────────────────────────────────────────────── @@ -29,14 +29,13 @@ export const DisciplineCard: React.FC = ({ }) => { const { id, name, description, manaType, perks, - statBonus, drainBase, difficultyFactor, scalingFactor, + statBonus, scalingFactor, conversionRate, sourceManaTypes, } = definition; const displayXp = xp; const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100); const activeStatBonus = calculateStatBonus(statBonus.baseValue, displayXp, scalingFactor); - const drainPerSecond = calculateManaDrain(drainBase, displayXp, difficultyFactor) * TICKS_PER_SECOND; const elementDef = ELEMENTS[manaType]; const manaColor = elementDef?.color ?? '#888888'; @@ -98,7 +97,6 @@ export const DisciplineCard: React.FC = ({ {/* Stats Row */}
- Drain: {drainPerSecond.toFixed(1)}/sec XP: {displayXp}
@@ -160,13 +158,6 @@ export const DisciplineCard: React.FC = ({ )} - {/* Auto-paused mana feedback (fix #244) */} - {isActive && isPaused && autoPaused && ( -
- ⏸️ Auto-paused — insufficient {manaName} mana to continue practicing. -
- )} - {/* Action Button */}
- {/* Conversion Breakdown */} + + {/* ── Conversion Stats (dedicated section per spec §11) ── */} {activeConversions.length > 0 && ( <> -
Conversion Breakdown:
+
Conversion Stats:
+ + {/* Collapsible formula reference (DISC-5) */} +
+ + {formulaExpanded && ( +
+
finalRate = (disciplineRate + attunementBase + pactBase) × attunementMult × pactMult × meditationMult
+
where:
+
attunementMult = 1 + Σ(relevantAttunementLevel × 0.5)
+
pactMult = 1 + Σ(pactCount_element × invokerLevel × 0.25)
+
meditationMult = 1 + (meditationMultiplier - 1) / elementDistance
+
+ Cost per 1 unit of destination: +
+
rawCost = 10^(distance+1)
+
componentCost = 10 × (distance+1) per component
+
+ All costs deducted from source regen (not from mana pool). +
+
+ Conversions pause if source regen < conversion cost. +
+
+ )} +
+
{activeConversions.map((entry) => ( @@ -158,8 +193,10 @@ function ConversionRow({ entry, elementDrain }: { const def = ELEMENTS[entry.element]; const effectiveRate = entry.paused ? 0 : entry.finalRate; const rawDrain = effectiveRate * entry.rawCost; - const isComponentDrained = Object.keys(entry.componentCosts).length > 0 && - Object.keys(elementDrain).length > 0; + const downstreamDrain = elementDrain[entry.element] || 0; + + // Net regen: produced - downstream consumption (DISC-6) + const netRegen = entry.paused ? 0 : effectiveRate - downstreamDrain; return (
@@ -199,13 +236,30 @@ function ConversionRow({ entry, elementDrain }: { })}
{/* Downstream drain (this element consumed by higher conversions) */} - {isComponentDrained && elementDrain[entry.element] > 0 && ( + {downstreamDrain > 0 && (
- ↓ Drained by downstream: -{fmtDec(elementDrain[entry.element], 2)} {def?.name}/hr + ↓ Drained by downstream: -{fmtDec(downstreamDrain, 2)} {def?.name}/hr
)}
)} + {/* Net regen summary line (DISC-6) */} + {!entry.paused && ( +
+ Net {def?.name} Regen: + +{fmtDec(effectiveRate, 2)}/hr + {downstreamDrain > 0 && ( + <> + + {fmtDec(downstreamDrain, 2)}/hr + + )} + = + = 0 ? 'var(--color-success)' : 'var(--color-error)' }}> + {netRegen >= 0 ? '+' : ''}{fmtDec(netRegen, 2)}/hr + +
+ )} {entry.paused && entry.pauseReason && (
⚠️ {entry.pauseReason}
)} 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 9b37519..7c8f40d 100644 --- a/src/lib/game/__tests__/cross-module-prestige-discipline.test.ts +++ b/src/lib/game/__tests__/cross-module-prestige-discipline.test.ts @@ -120,30 +120,32 @@ describe('Cross-Module: Prestige & Discipline', () => { expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0); }); - it('should drain raw mana for raw-type disciplines', () => { + it('should NOT drain raw mana for raw-type disciplines (no drain model)', () => { useDisciplineStore.getState().activate('raw-mastery'); useManaStore.setState({ rawMana: 99999 }); + const rawManaBefore = useManaStore.getState().rawMana; - tickN(10); + // Call processTick directly (not game tick) to isolate discipline drain + 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) }); - it('should pause discipline when insufficient mana', () => { + it('should NOT pause discipline when insufficient mana (no drain model)', () => { useDisciplineStore.getState().activate('raw-mastery'); useManaStore.setState({ rawMana: 0 }); tickN(5); const disc = useDisciplineStore.getState().disciplines['raw-mastery']; - if (disc) { - expect(disc.paused).toBe(true); - } else { - expect(useDisciplineStore.getState().totalXP).toBe(0); - } + expect(disc).toBeDefined(); + expect(disc!.paused).toBe(false); + expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0); }); it('should respect concurrent limit', () => { diff --git a/src/lib/game/__tests__/discipline-math.test.ts b/src/lib/game/__tests__/discipline-math.test.ts index 95a8574..4b02a99 100644 --- a/src/lib/game/__tests__/discipline-math.test.ts +++ b/src/lib/game/__tests__/discipline-math.test.ts @@ -246,12 +246,12 @@ describe('canProceedDiscipline', () => { expect(result).toBe(true); }); - it('should return false when raw mana is insufficient', () => { + it('should return true when raw mana is insufficient (no drain model)', () => { const state: DisciplineState = { id: 'raw-mastery', xp: 10000, paused: false }; const result = canProceedDiscipline(rawMastery, state, { rawMana: 0, }); - expect(result).toBe(false); + expect(result).toBe(true); }); it('should return true when element mana is sufficient', () => { @@ -262,11 +262,19 @@ describe('canProceedDiscipline', () => { expect(result).toBe(true); }); - it('should return false when element mana is insufficient', () => { + it('should return true when element exists (no drain model)', () => { const state: DisciplineState = { id: 'attune-fire', xp: 10000, paused: false }; const result = canProceedDiscipline(attuneFire, state, { elements: { fire: { current: 0, max: 100, unlocked: true } }, }); + expect(result).toBe(true); + }); + + it('should return false when element does not exist in game state', () => { + const state: DisciplineState = { id: 'attune-fire', xp: 10000, paused: false }; + const result = canProceedDiscipline(attuneFire, state, { + elements: {}, + }); expect(result).toBe(false); }); diff --git a/src/lib/game/__tests__/discipline-reactivate-bug.test.ts b/src/lib/game/__tests__/discipline-reactivate-bug.test.ts index 2a6bb95..0475f58 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 NOT reactivate a raw discipline when rawMana is insufficient', () => { + it('should reactivate even with zero mana (no drain model)', () => { // Activate with sufficient mana useManaStore.setState({ rawMana: 1000, elements: {} }); useDisciplineStore.getState().activate('raw-mastery'); @@ -51,10 +51,10 @@ describe('DisciplineStore — reactivate after deactivate', () => { useDisciplineStore.getState().deactivate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); - // Set rawMana to 0 — reactivation should fail + // Set rawMana to 0 — reactivation should still succeed (no drain model) useManaStore.setState({ rawMana: 0, elements: {} }); useDisciplineStore.getState().activate('raw-mastery'); - expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); + expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); }); it('should reactivate a discipline with elements after deactivating it', () => { @@ -75,21 +75,18 @@ describe('DisciplineStore — reactivate after deactivate', () => { expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false); }); - it('should reactivate after processTick auto-pauses due to no mana', () => { + it('should not auto-pause on zero mana (no drain model)', () => { useManaStore.setState({ rawMana: 100, elements: {} }); useDisciplineStore.getState().activate('raw-mastery'); expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); - // Tick with no mana — discipline auto-pauses + // Tick with no mana — discipline should NOT auto-pause (no drain model) useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); - expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); - expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true); - - // 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); + + // XP should still accrue + expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1); }); 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 71ecdaf..5ed19d4 100644 --- a/src/lib/game/__tests__/store-actions-discipline.test.ts +++ b/src/lib/game/__tests__/store-actions-discipline.test.ts @@ -47,12 +47,12 @@ describe('DisciplineStore', () => { expect(useDisciplineStore.getState().activeIds).toContain('attune-fire'); }); - it('should not activate when existing state has insufficient mana', () => { + it('should activate even with insufficient mana (no drain model)', () => { useDisciplineStore.setState({ disciplines: { 'raw-mastery': { id: 'raw-mastery', xp: 1000, paused: false } }, }); useDisciplineStore.getState().activate('raw-mastery', { elements: {} }); - expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); + expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); }); it('should activate when required element is unlocked', () => { @@ -79,16 +79,22 @@ describe('DisciplineStore', () => { expect(useDisciplineStore.getState().totalXP).toBe(1); }); - it('should drain raw mana for raw discipline', () => { + it('should not drain raw mana (no drain model)', () => { useDisciplineStore.getState().activate('raw-mastery'); const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); - expect(result.rawMana).toBeLessThan(1000); + expect(result.rawMana).toBe(1000); }); - it('should pause discipline when insufficient mana', () => { + it('should not auto-pause discipline (no drain model)', () => { useDisciplineStore.getState().activate('raw-mastery'); useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); - expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false); + }); + + 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 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 18de995..88cd0bb 100644 --- a/src/lib/game/stores/discipline-slice.ts +++ b/src/lib/game/stores/discipline-slice.ts @@ -5,7 +5,6 @@ import { createSafeStorage } from '../utils/safe-persist'; import type { DisciplineState } from '../types/disciplines'; import type { ElementState } from '../types'; import { - calculateManaDrain, calculateStatBonus, canProceedDiscipline, checkDisciplinePrerequisites, @@ -171,7 +170,7 @@ export const useDisciplineStore = create()( processTick(mana) { const s = get(); - let rawMana = mana.rawMana; + const rawMana = mana.rawMana; const elements = { ...mana.elements }; let newXP = s.totalXP; const newDisciplines = { ...s.disciplines }; @@ -179,8 +178,6 @@ export const useDisciplineStore = create()( const newUnlockedRecipes: string[] = []; const newProcessedPerks = [...(s.processedPerks ?? [])]; - const drainedIds: string[] = []; - const drainedNames: string[] = []; for (const id of s.activeIds ?? []) { const disc = newDisciplines[id]; if (!disc) continue; @@ -190,26 +187,6 @@ export const useDisciplineStore = create()( const def = DISCIPLINE_MAP[id]; if (!def) continue; - const drain = calculateManaDrain(def.drainBase, disc.xp, def.difficultyFactor); - const element = elements[def.manaType]; - const available = def.manaType === 'raw' ? rawMana : element?.current; - - if (!available || available < drain) { - newDisciplines[id] = { ...disc, paused: true, autoPaused: true }; - drainedIds.push(id); - drainedNames.push(def.name); - continue; - } - - if (def.manaType === 'raw') { - rawMana -= drain; - } else if (elements[def.manaType]) { - elements[def.manaType] = { - ...elements[def.manaType], - current: elements[def.manaType].current - drain, - }; - } - const oldXP = disc.xp; // Compute discipline XP bonus directly to avoid circular import let xpBonus = 0; @@ -264,21 +241,21 @@ export const useDisciplineStore = create()( MAX_CONCURRENT_DISCIPLINES + 3 ); - // Remove mana-drained disciplines from activeIds so onStopPracticing fires - const newActiveIds = (s.activeIds ?? []).filter((aid) => !drainedIds.includes(aid)); - if (newActiveIds.length === 0 && s.activeIds.length > 0) { + // 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: newActiveIds, + activeIds: keepActiveIds, totalXP: newXP, concurrentLimit: Math.max(s.concurrentLimit, newLimit), processedPerks: newProcessedPerks, }); - return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes, autoPausedNames: drainedNames }; + 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 }) } diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 4cb300b..4a6032a 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -217,7 +217,6 @@ export const useGameStore = create()( const dr = useDisciplineStore.getState().processTick({ rawMana, elements }); rawMana = dr.rawMana; elements = dr.elements; - if (dr.autoPausedNames.length > 0) addLog('⏸️ Auto-paused (insufficient mana): ' + dr.autoPausedNames.join(', ')); rawMana = Math.min(rawMana, computeMaxMana({ prestigeUpgrades: ctx.prestige.prestigeUpgrades }, undefined, computeDisciplineEffects())); if (dr.unlockedEffects.length > 0) { diff --git a/src/lib/game/utils/discipline-math.ts b/src/lib/game/utils/discipline-math.ts index e5c481b..a883bf8 100644 --- a/src/lib/game/utils/discipline-math.ts +++ b/src/lib/game/utils/discipline-math.ts @@ -60,30 +60,27 @@ export function canActivateDiscipline( } /** - * Check if discipline can proceed (has sufficient mana for drain) + * Check if discipline can proceed. + * Under the new unified conversion model, disciplines do not drain mana from pools. + * This check now only verifies that prerequisites are structurally met. */ export function canProceedDiscipline( discipline: DisciplineDefinition, disciplineState: DisciplineState | undefined, gameState?: { elements?: Record; rawMana?: number } ): boolean { + // New disciplines can always be activated (prerequisites checked separately) if (!disciplineState) return true; // If no game state provided, allow activation (optimistic) if (!gameState) return true; - const drain = calculateManaDrain( - discipline.drainBase, - disciplineState.xp, - discipline.difficultyFactor - ); - - if (discipline.manaType === 'raw') { - return (gameState.rawMana || 0) >= drain; - } + // For disciplines with source mana types, verify they exist in game state + // (actual unlock check happens in activate() separately) + if (discipline.manaType === 'raw') return true; const element = gameState.elements?.[discipline.manaType]; - return element && element.current >= drain; + return !!element; } // ─── Known mana type names for display ────────────────────────────────────────