From ab3afae2a60c38d272011cc27e7e7e170252d976 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Thu, 4 Jun 2026 18:12:41 +0200 Subject: [PATCH] feat: overhaul mana conversion system to unified regen-deduction model - New files: element-distance.ts, conversion-costs.ts, conversion-rates.ts - All conversion types (discipline, attunement, pact) use unified formula - Conversion costs scale exponentially by element tier (10^(d+1) raw, 10*(d+1) per component) - Costs deducted from regen, not from mana pool - Auto-pause on insufficient regen with UI warning - Meditation boosts conversion rates (reduced by distance) - Attunement levels provide +50% multiplicative bonus per level - Guardian pacts provide +0.15/hr base rate + invoker level bonus - Removed convertMana, processConvertAction, craftComposite from manaStore - Stats tab shows per-element conversion breakdown with formulas - ManaDisplay shows per-element net regen rates - All 916 tests pass, all files under 400 lines --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 3 + src/components/game/ManaDisplay.tsx | 49 ++-- .../tabs/StatsTab/ElementStatsSection.tsx | 98 +++++++- .../cross-module-combat-meditation.test.ts | 22 -- src/lib/game/__tests__/mana-utils.test.ts | 12 +- .../game/__tests__/store-actions-mana.test.ts | 93 +------ src/lib/game/__tests__/store-actions.test.ts | 93 +------ src/lib/game/data/attunements.ts | 94 +++---- src/lib/game/data/conversion-costs.ts | 118 +++++++++ .../disciplines/elemental-regen-advanced.ts | 57 ++--- .../game/data/disciplines/elemental-regen.ts | 49 ++-- src/lib/game/effects/discipline-effects.ts | 38 ++- src/lib/game/stores/gameStore.ts | 208 ++++++---------- src/lib/game/stores/manaStore.ts | 81 +----- src/lib/game/utils/conversion-rates.ts | 230 ++++++++++++++++++ src/lib/game/utils/element-distance.ts | 58 +++++ src/lib/game/utils/mana-utils.ts | 7 +- 19 files changed, 742 insertions(+), 572 deletions(-) create mode 100644 src/lib/game/data/conversion-costs.ts create mode 100644 src/lib/game/utils/conversion-rates.ts create mode 100644 src/lib/game/utils/element-distance.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 50dec10..5aec3f8 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-06-04T09:37:34.683Z +Generated: 2026-06-04T11:37:52.108Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index ef37d35..f546e64 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-04T09:37:32.833Z", + "generated": "2026-06-04T11:37:49.892Z", "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/docs/project-structure.txt b/docs/project-structure.txt index 08ef869..29a8d4b 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -327,6 +327,7 @@ Mana-Loop/ │ │ │ │ │ └── utils.ts │ │ │ │ ├── achievements.ts │ │ │ │ ├── attunements.ts +│ │ │ │ ├── conversion-costs.ts │ │ │ │ ├── crafting-recipes.ts │ │ │ │ ├── enchantment-effects.ts │ │ │ │ ├── enchantment-types.ts @@ -389,8 +390,10 @@ Mana-Loop/ │ │ │ ├── utils/ │ │ │ │ ├── activity-log.ts │ │ │ │ ├── combat-utils.ts +│ │ │ │ ├── conversion-rates.ts │ │ │ │ ├── discipline-math.ts │ │ │ │ ├── element-cap-bonus.ts +│ │ │ │ ├── element-distance.ts │ │ │ │ ├── enemy-generator.ts │ │ │ │ ├── enemy-utils.ts │ │ │ │ ├── floor-utils.ts diff --git a/src/components/game/ManaDisplay.tsx b/src/components/game/ManaDisplay.tsx index c97d382..4e3432a 100755 --- a/src/components/game/ManaDisplay.tsx +++ b/src/components/game/ManaDisplay.tsx @@ -19,6 +19,8 @@ interface ManaDisplayProps { onGatherStart: () => void; onGatherEnd: () => void; elements: Record; + /** Per-element net regen rates (from unified conversion system) */ + elementRegen?: Record; } export function ManaDisplay({ @@ -31,10 +33,10 @@ export function ManaDisplay({ onGatherStart, onGatherEnd, elements, + elementRegen, }: ManaDisplayProps) { const [expanded, setExpanded] = useState(true); - - // Get unlocked elements with current > 0, sorted by current amount + const unlockedElements = Object.entries(elements) .filter(([, state]) => state.unlocked && state.current > 0) .sort((a, b) => b[1].current - a[1].current); @@ -53,17 +55,17 @@ export function ManaDisplay({ +{fmtDec(effectiveRegen)} mana/hr {meditationMultiplier > 1.01 && ({fmtDec(meditationMultiplier, 1)}x med)} - + - + - + {/* Elemental Mana Pools */} {unlockedElements.length > 0 && (
@@ -92,20 +94,20 @@ export function ManaDisplay({ style={{ color: 'var(--text-muted)' }} > ELEMENTAL MANA ({unlockedElements.length}) - {expanded ? : } - - + {expanded ? : } + {expanded && (
{unlockedElements.map(([id, state]) => { const elem = ELEMENTS[id]; if (!elem) return null; - + const regen = elementRegen?.[id]; + return ( -
-
-
- {fmt(state.current)}/{fmt(state.max)} +
+
+ {fmt(state.current)}/{fmt(state.max)} +
+ {regen !== undefined && regen !== 0 && ( +
0 ? 'var(--color-success)' : 'var(--color-error)' }}> + {regen > 0 ? '+' : ''}{fmtDec(regen, 2)}/hr +
+ )}
); diff --git a/src/components/game/tabs/StatsTab/ElementStatsSection.tsx b/src/components/game/tabs/StatsTab/ElementStatsSection.tsx index 1c867fa..3596fc6 100644 --- a/src/components/game/tabs/StatsTab/ElementStatsSection.tsx +++ b/src/components/game/tabs/StatsTab/ElementStatsSection.tsx @@ -5,7 +5,12 @@ import { DebugName } from '@/components/game/debug/debug-context'; import { Separator } from '@/components/ui/separator'; import { FlaskConical } from 'lucide-react'; import { ELEMENTS } from '@/lib/game/constants'; -import { usePrestigeStore, useManaStore, fmtDec } from '@/lib/game/stores'; +import { usePrestigeStore, useManaStore, useAttunementStore, useDisciplineStore, fmtDec } from '@/lib/game/stores'; +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, getAttunementConversionRate } from '@/lib/game/data/attunements'; +import { useMemo } from 'react'; import type { ElementState } from '@/lib/game/types'; interface ElementStatsSectionProps { @@ -14,7 +19,44 @@ interface ElementStatsSectionProps { export function ElementStatsSection({ elemMax }: ElementStatsSectionProps) { const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades); + const signedPacts = usePrestigeStore((s) => s.signedPacts); const elements = useManaStore((s) => s.elements); + const attunements = useAttunementStore((s) => s.attunements); + const disciplines = useDisciplineStore((s) => s.disciplines); + + // Compute conversion breakdown + const conversionData = useMemo(() => { + const disciplineEffects = computeDisciplineEffects(); + 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) + + getAttunementConversionRate(id, state.level || 1); + } + } + const invokerLevel = attunements.invoker?.active ? (attunements.invoker.level || 1) : 0; + return computeConversionRates({ + disciplineEffects, + attunements, + signedPacts, + pactElementMap, + invokerLevel, + meditationMultiplier: 1, + grossRegen, + rawGrossRegen: 2, + }); + }, [disciplines, attunements, signedPacts]); + + const activeConversions = Object.values(conversionData.rates).filter( + (e) => e.baseRate > 0 || e.disciplineRate > 0 || e.attunementBase > 0 || e.pactBase > 0, + ); return ( @@ -36,12 +78,20 @@ export function ElementStatsSection({ elemMax }: ElementStatsSectionProps) { Prestige Attunement: +{(prestigeUpgrades.elementalAttune || 0) * 25}
+
+ Raw Conversion Drain: + -{fmtDec(conversionData.totalRawDrain, 2)}/hr +
Unlocked Elements: {Object.values(elements || {}).filter((e: ElementState) => e.unlocked).length} / {Object.keys(ELEMENTS).length}
+
+ Active Conversions: + {activeConversions.filter(e => !e.paused).length} +
@@ -51,16 +101,62 @@ export function ElementStatsSection({ elemMax }: ElementStatsSectionProps) { .filter((entry): entry is [string, ElementState] => entry[1].unlocked) .map(([id, state]) => { const def = ELEMENTS[id]; + const conv = conversionData.rates[id]; return (
{def?.sym}
{fmtDec(state.current, 2)}/{fmtDec(state.max, 0)}
+ {conv && conv.finalRate > 0 && !conv.paused && ( +
+{fmtDec(conv.finalRate, 2)}/hr
+ )} + {conv?.paused && ( +
⏸️
+ )}
); })} + {/* Conversion Breakdown */} + {activeConversions.length > 0 && ( + <> + +
Conversion Breakdown:
+
+ {activeConversions.map((entry) => ( + + ))} +
+ + )} ); } + +function ConversionRow({ entry }: { entry: { element: string; distance: number; disciplineRate: number; attunementBase: number; pactBase: number; baseRate: number; attunementMult: number; pactMult: number; meditationMult: number; finalRate: number; paused: boolean; pauseReason: string | null } }) { + const def = ELEMENTS[entry.element]; + return ( +
+
+ {def?.sym} + {def?.name} + (d={entry.distance}) + {entry.paused && ⏸️ PAUSED} +
+
+
Discipline: +{fmtDec(entry.disciplineRate, 2)}/hr
+
Attunement: +{fmtDec(entry.attunementBase, 2)}/hr
+
Pact: +{fmtDec(entry.pactBase, 2)}/hr
+
Base: {fmtDec(entry.baseRate, 2)}/hr
+
Att mult: ×{fmtDec(entry.attunementMult, 2)}
+
Pact mult: ×{fmtDec(entry.pactMult, 2)}
+
Med mult: ×{fmtDec(entry.meditationMult, 2)}
+
Final: {entry.paused ? '0.00' : fmtDec(entry.finalRate, 2)}/hr
+
+ {entry.paused && entry.pauseReason && ( +
⚠️ {entry.pauseReason}
+ )} +
+ ); +} diff --git a/src/lib/game/__tests__/cross-module-combat-meditation.test.ts b/src/lib/game/__tests__/cross-module-combat-meditation.test.ts index 94e2541..e992beb 100644 --- a/src/lib/game/__tests__/cross-module-combat-meditation.test.ts +++ b/src/lib/game/__tests__/cross-module-combat-meditation.test.ts @@ -168,26 +168,4 @@ describe('Cross-Module: Combat & Meditation', () => { expect(highIncursionMana).toBeLessThan(lowIncursionMana); }); }); - - describe('convert action via tick', () => { - it('should convert raw mana to elements when action is convert', () => { - useManaStore.getState().unlockElement('fire', 0); - useManaStore.setState({ rawMana: 500 }); - useCombatStore.setState({ currentAction: 'convert' }); - - tickN(10); - - expect(useManaStore.getState().elements.fire.current).toBeGreaterThan(0); - }); - - it('should increase element mana when converting', () => { - useManaStore.getState().unlockElement('fire', 0); - useManaStore.setState({ rawMana: 500 }); - useCombatStore.setState({ currentAction: 'convert' }); - - tickN(10); - - expect(useManaStore.getState().elements.fire.current).toBeGreaterThan(0); - }); - }); }); diff --git a/src/lib/game/__tests__/mana-utils.test.ts b/src/lib/game/__tests__/mana-utils.test.ts index 2a1e28f..4e91fcb 100644 --- a/src/lib/game/__tests__/mana-utils.test.ts +++ b/src/lib/game/__tests__/mana-utils.test.ts @@ -225,15 +225,9 @@ describe('computeEffectiveRegenForDisplay', () => { expect(result.effectiveRegen).toBe(2); }); - it('should not let effectiveRegen go below zero', () => { - // This would require a state with high conversion drain - // We can't easily test this without attunement data, but we can verify the Math.max behavior + it('should have effectiveRegen equal to rawRegen since conversion drain is always 0', () => { const result = computeEffectiveRegenForDisplay(baseState); - expect(result.effectiveRegen).toBeGreaterThanOrEqual(0); - }); - - it('should calculate effective as raw minus conversion', () => { - const result = computeEffectiveRegenForDisplay(baseState); - expect(result.effectiveRegen).toBe(Math.max(0, result.rawRegen - result.conversionDrain)); + expect(result.conversionDrain).toBe(0); + expect(result.effectiveRegen).toBe(result.rawRegen); }); }); diff --git a/src/lib/game/__tests__/store-actions-mana.test.ts b/src/lib/game/__tests__/store-actions-mana.test.ts index d2ecc5b..e2ef2ab 100644 --- a/src/lib/game/__tests__/store-actions-mana.test.ts +++ b/src/lib/game/__tests__/store-actions-mana.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useManaStore, makeInitialElements } from '../stores/manaStore'; -import { MANA_PER_ELEMENT } from '../constants'; import { ErrorCode } from '../utils/result'; function resetManaStore() { @@ -71,47 +70,6 @@ describe('ManaStore', () => { }); }); - describe('convertMana', () => { - it('should convert raw mana to element mana', () => { - useManaStore.setState({ rawMana: 500 }); - const result = useManaStore.getState().convertMana('transference', 2); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.converted).toBe(2); - } - expect(useManaStore.getState().rawMana).toBe(500 - 2 * MANA_PER_ELEMENT); - expect(useManaStore.getState().elements.transference.current).toBe(2); - }); - - it('should fail for locked element', () => { - const result = useManaStore.getState().convertMana('fire', 1); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.code).toBe(ErrorCode.ELEMENT_NOT_UNLOCKED); - } - }); - - it('should fail when insufficient raw mana', () => { - useManaStore.setState({ rawMana: 50 }); - const result = useManaStore.getState().convertMana('transference', 1); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); - } - }); - - it('should fail when element is at max', () => { - const elements = useManaStore.getState().elements; - elements.transference.current = elements.transference.max; - useManaStore.setState({ elements }); - const result = useManaStore.getState().convertMana('transference', 1); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.code).toBe(ErrorCode.ELEMENT_MAX_CAPACITY); - } - }); - }); - describe('unlockElement', () => { it('should unlock element and deduct cost', () => { const result = useManaStore.getState().unlockElement('fire', 50); @@ -164,63 +122,16 @@ describe('ManaStore', () => { }); }); - describe('craftComposite', () => { - it('should craft metal from fire + earth', () => { - useManaStore.getState().unlockElement('fire', 0); - useManaStore.getState().unlockElement('earth', 0); - useManaStore.getState().addElementMana('fire', 5, 50); - useManaStore.getState().addElementMana('earth', 5, 50); - const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); - expect(result.success).toBe(true); - expect(useManaStore.getState().elements.fire.current).toBe(4); - expect(useManaStore.getState().elements.earth.current).toBe(4); - expect(useManaStore.getState().elements.metal.current).toBe(1); - expect(useManaStore.getState().elements.metal.unlocked).toBe(true); - }); - - it('should fail when missing ingredients', () => { - const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); - } - }); - }); - - describe('processConvertAction', () => { - it('should auto-convert raw mana to neediest unlocked element', () => { - useManaStore.getState().unlockElement('fire', 0); - const result = useManaStore.getState().processConvertAction(500); - expect(result).not.toBeNull(); - expect(result!.rawMana).toBe(0); - expect(result!.elements.fire.current).toBe(5); - }); - - it('should return null when raw mana < 100', () => { - useManaStore.getState().unlockElement('fire', 0); - const result = useManaStore.getState().processConvertAction(50); - expect(result).toBeNull(); - }); - - it('should return null when no unlocked elements need mana', () => { - const elements = useManaStore.getState().elements; - Object.keys(elements).forEach(k => { elements[k].current = elements[k].max; }); - useManaStore.setState({ elements }); - const result = useManaStore.getState().processConvertAction(500); - expect(result).toBeNull(); - }); - }); - describe('resetMana', () => { it('should reset to initial state for new loop', () => { - useManaStore.getState().resetMana({}, {}, {}, {}); + useManaStore.getState().resetMana({}); expect(useManaStore.getState().rawMana).toBe(10); expect(useManaStore.getState().meditateTicks).toBe(0); expect(useManaStore.getState().totalManaGathered).toBe(0); }); it('should apply prestige upgrades for starting mana', () => { - useManaStore.getState().resetMana({ manaStart: 5 }, {}, {}, {}); + useManaStore.getState().resetMana({ manaStart: 5 }); expect(useManaStore.getState().rawMana).toBe(10 + 5 * 10); }); }); diff --git a/src/lib/game/__tests__/store-actions.test.ts b/src/lib/game/__tests__/store-actions.test.ts index aa43f37..9c92d6e 100644 --- a/src/lib/game/__tests__/store-actions.test.ts +++ b/src/lib/game/__tests__/store-actions.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useManaStore, makeInitialElements } from '../stores/manaStore'; import { useCombatStore } from '../stores/combatStore'; -import { MANA_PER_ELEMENT } from '../constants'; import { getFloorMaxHP } from '../utils'; import { ErrorCode } from '../utils/result'; @@ -102,47 +101,6 @@ describe('ManaStore', () => { }); }); - describe('convertMana', () => { - it('should convert raw mana to element mana', () => { - useManaStore.setState({ rawMana: 500 }); - const result = useManaStore.getState().convertMana('transference', 2); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.converted).toBe(2); - } - expect(useManaStore.getState().rawMana).toBe(500 - 2 * MANA_PER_ELEMENT); - expect(useManaStore.getState().elements.transference.current).toBe(2); - }); - - it('should fail for locked element', () => { - const result = useManaStore.getState().convertMana('fire', 1); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.code).toBe(ErrorCode.ELEMENT_NOT_UNLOCKED); - } - }); - - it('should fail when insufficient raw mana', () => { - useManaStore.setState({ rawMana: 50 }); - const result = useManaStore.getState().convertMana('transference', 1); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); - } - }); - - it('should fail when element is at max', () => { - const elements = useManaStore.getState().elements; - elements.transference.current = elements.transference.max; - useManaStore.setState({ elements }); - const result = useManaStore.getState().convertMana('transference', 1); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.code).toBe(ErrorCode.ELEMENT_MAX_CAPACITY); - } - }); - }); - describe('unlockElement', () => { it('should unlock element and deduct cost', () => { const result = useManaStore.getState().unlockElement('fire', 50); @@ -195,63 +153,16 @@ describe('ManaStore', () => { }); }); - describe('craftComposite', () => { - it('should craft metal from fire + earth', () => { - useManaStore.getState().unlockElement('fire', 0); - useManaStore.getState().unlockElement('earth', 0); - useManaStore.getState().addElementMana('fire', 5, 50); - useManaStore.getState().addElementMana('earth', 5, 50); - const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); - expect(result.success).toBe(true); - expect(useManaStore.getState().elements.fire.current).toBe(4); - expect(useManaStore.getState().elements.earth.current).toBe(4); - expect(useManaStore.getState().elements.metal.current).toBe(1); - expect(useManaStore.getState().elements.metal.unlocked).toBe(true); - }); - - it('should fail when missing ingredients', () => { - const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); - } - }); - }); - - describe('processConvertAction', () => { - it('should auto-convert raw mana to neediest unlocked element', () => { - useManaStore.getState().unlockElement('fire', 0); - const result = useManaStore.getState().processConvertAction(500); - expect(result).not.toBeNull(); - expect(result!.rawMana).toBe(0); - expect(result!.elements.fire.current).toBe(5); - }); - - it('should return null when raw mana < 100', () => { - useManaStore.getState().unlockElement('fire', 0); - const result = useManaStore.getState().processConvertAction(50); - expect(result).toBeNull(); - }); - - it('should return null when no unlocked elements need mana', () => { - const elements = useManaStore.getState().elements; - Object.keys(elements).forEach(k => { elements[k].current = elements[k].max; }); - useManaStore.setState({ elements }); - const result = useManaStore.getState().processConvertAction(500); - expect(result).toBeNull(); - }); - }); - describe('resetMana', () => { it('should reset to initial state for new loop', () => { - useManaStore.getState().resetMana({}, {}, {}, {}); + useManaStore.getState().resetMana({}); expect(useManaStore.getState().rawMana).toBe(10); expect(useManaStore.getState().meditateTicks).toBe(0); expect(useManaStore.getState().totalManaGathered).toBe(0); }); it('should apply prestige upgrades for starting mana', () => { - useManaStore.getState().resetMana({ manaStart: 5 }, {}, {}, {}); + useManaStore.getState().resetMana({ manaStart: 5 }); expect(useManaStore.getState().rawMana).toBe(10 + 5 * 10); }); }); diff --git a/src/lib/game/data/attunements.ts b/src/lib/game/data/attunements.ts index 206777d..e05d086 100755 --- a/src/lib/game/data/attunements.ts +++ b/src/lib/game/data/attunements.ts @@ -1,6 +1,10 @@ // ─── Attunement Definitions ───────────────────────────────────────────────────── // Attunements are class-like abilities tied to body locations // Each provides unique capabilities, primary mana types, and skill access +// +// NEW MODEL: Attunements contribute base conversion rates for their primary mana type. +// Levels provide a multiplicative bonus (+50% per level) to conversions involving +// their primary element (as destination or component). import type { AttunementDef, AttunementSlot } from '../types'; @@ -18,69 +22,62 @@ export const ATTUNEMENT_SLOT_NAMES: Record = { // All attunement definitions export const ATTUNEMENTS_DEF: Record = { // ─── Enchanter (Right Hand) ───────────────────────────────────────────────── - // Unlocks the enchanting system - applying magical effects to equipment - // Primary mana: Transference (used to move/apply enchantments) enchanter: { id: 'enchanter', name: 'Enchanter', desc: 'Channel transference mana through your right hand to apply magical enchantments to equipment. The art of enchanting allows you to imbue items with spell effects, stat bonuses, and special properties.', slot: 'rightHand', icon: '✨', - color: '#1ABC9C', // Teal (transference color) + color: '#1ABC9C', primaryManaType: 'transference', rawManaRegen: 0.5, - conversionRate: 0.2, // Converts 0.2 raw mana to transference per hour - unlocked: true, // Starting attunement + conversionRate: 0.2, // Base rate for transference conversion (per hour) + unlocked: true, capabilities: ['enchanting'], skillCategories: ['enchant', 'effectResearch'], }, // ─── Invoker (Chest/Heart) ─────────────────────────────────────────────────── - // Enables forming pacts with spire guardians - // No primary mana - instead gains mana types from each pact signed invoker: { id: 'invoker', name: 'Invoker', desc: 'Open your heart to the guardians of the spire. Form pacts with defeated guardians to gain their elemental affinity and access to their unique powers. Each pact grants access to a new mana type.', slot: 'chest', icon: '💜', - color: '#9B59B6', // Purple - primaryManaType: undefined, // Invoker has no primary - gains from pacts + color: '#9B59B6', + primaryManaType: undefined, rawManaRegen: 0.3, - conversionRate: 0, // No automatic conversion - mana comes from pacts - unlocked: false, // Unlocked through gameplay + conversionRate: 0, // No automatic conversion — mana comes from pacts + unlocked: false, unlockCondition: 'Defeat your first guardian and choose the path of the Invoker', capabilities: ['pacts', 'guardianPowers', 'elementalMastery'], skillCategories: ['invocation', 'pact'], }, // ─── Fabricator (Left Hand) ────────────────────────────────────────────────── - // Crafts earth golems and earthen gear - // Primary mana: Earth - // Later with fire mana -> metal mana, can craft metallic gear and golems fabricator: { id: 'fabricator', name: 'Fabricator', desc: 'Shape earth and metal through your left hand to craft golems and equipment. Start with earthen constructs, and unlock metalworking when you gain fire mana to create metal mana.', slot: 'leftHand', icon: '⚒️', - color: '#F4A261', // Earth color + color: '#F4A261', primaryManaType: 'earth', rawManaRegen: 0.4, - conversionRate: 0.25, // Converts 0.25 raw mana to earth per hour - unlocked: false, // Unlocked through gameplay + conversionRate: 0.25, // Base rate for earth conversion (per hour) + unlocked: false, unlockCondition: 'Prove your worth as a crafter', capabilities: ['golemCrafting', 'gearCrafting', 'earthShaping'], skillCategories: ['fabrication', 'golemancy'], }, }; -// Helper function to get attunement by slot +// ─── Helpers ────────────────────────────────────────────────────────────────── + export function getAttunementBySlot(slot: AttunementSlot): AttunementDef | undefined { return Object.values(ATTUNEMENTS_DEF).find(a => a.slot === slot); } -// Helper function to get all unlocked attunements for a player export function getUnlockedAttunements(attunements: Record): AttunementDef[] { return Object.entries(attunements) .filter(([, state]) => state.active) @@ -88,71 +85,54 @@ export function getUnlockedAttunements(attunements: Record): number { return Object.entries(attunements) .filter(([, state]) => state.active) .reduce((total, [id, state]) => { const def = ATTUNEMENTS_DEF[id]; if (!def) return total; - // Exponential scaling: base * (1.5 ^ (level - 1)) const levelMult = Math.pow(1.5, (state.level || 1) - 1); return total + def.rawManaRegen * levelMult; }, 0); } -// Helper function to calculate total conversion drain from all active attunements (per hour) -export function getTotalAttunementConversionDrain(attunements: Record): number { - return Object.entries(attunements) - .filter(([, state]) => state.active) - .reduce((total, [id, state]) => { - const def = ATTUNEMENTS_DEF[id]; - if (!def || def.conversionRate <= 0) return total; - // Use the same level scaling as getAttunementConversionRate - const scaledRate = getAttunementConversionRate(id, state.level || 1); - return total + scaledRate; - }, 0); -} - -// Get conversion rate with level scaling +/** + * Get the attunement base conversion rate for a specific attunement. + * This is the base rate contribution to the unified conversion system. + */ export function getAttunementConversionRate(attunementId: string, level: number): number { const def = ATTUNEMENTS_DEF[attunementId]; if (!def || def.conversionRate <= 0) return 0; - // Exponential scaling: base * (1.5 ^ (level - 1)) return def.conversionRate * Math.pow(1.5, (level || 1) - 1); } -// XP required for attunement level +/** + * Get the attunement level multiplier for conversions. + * Each level adds +0.5 to the multiplier. + */ +export function getAttunementLevelMultiplier(level: number): number { + return 1 + (level || 1) * 0.5; +} + +/** XP required for attunement level */ export function getAttunementXPForLevel(level: number): number { - // New scaling: - // Level 2: 1000 XP - // Level 3: 2500 XP - // Level 4: 5000 XP - // Level 5: 10000 XP - // etc. (each level requires 2x the previous, starting from 1000) if (level <= 1) return 0; if (level === 2) return 1000; - // For level 3+: 1000 * 2.5^(level-2), but rounded nicely return Math.floor(1000 * Math.pow(2, level - 2) * (level >= 3 ? 1.25 : 1)); } -// Calculate XP gained from enchanting based on capacity used export function calculateEnchantingXP(capacityUsed: number): number { - // 1 XP per 10 capacity used, floored, minimum 1 return Math.max(1, Math.floor(capacityUsed / 10)); } -// Max attunement level export const MAX_ATTUNEMENT_LEVEL = 10; -// Helper function to get mana types from active attunements and pacts export function getAttunementManaTypes( attunements: Record, signedPacts: number[] ): string[] { const manaTypes: string[] = []; - - // Add primary mana types from active attunements Object.entries(attunements) .filter(([, state]) => state.active) .forEach(([id]) => { @@ -161,30 +141,19 @@ export function getAttunementManaTypes( manaTypes.push(def.primaryManaType); } }); - - // Invoker gains mana types from signed pacts if (attunements.invoker?.active && signedPacts.length > 0) { - // Import GUARDIANS would be circular, so this is handled in the store - // For now, just mark that invoker provides pact-based mana manaTypes.push('pactElements'); } - - return [...new Set(manaTypes)]; // Remove duplicates + return [...new Set(manaTypes)]; } -// Get skill categories available to player based on active attunements export function getAvailableSkillCategories( attunements: Record ): string[] { const categories = new Set(); - - // Always available categories categories.add('mana'); categories.add('study'); categories.add('research'); - // categories.add('ascension'); // removed: banned mechanic - - // Add categories from active attunements Object.entries(attunements) .filter(([, state]) => state.active) .forEach(([id]) => { @@ -193,6 +162,5 @@ export function getAvailableSkillCategories( def.skillCategories.forEach(cat => categories.add(cat)); } }); - return Array.from(categories); } diff --git a/src/lib/game/data/conversion-costs.ts b/src/lib/game/data/conversion-costs.ts new file mode 100644 index 0000000..6e67100 --- /dev/null +++ b/src/lib/game/data/conversion-costs.ts @@ -0,0 +1,118 @@ +// ─── Conversion Cost Ratios ─────────────────────────────────────────────────── +// All conversions produce 1 unit of destination mana. +// Costs are deducted from regen (not from the mana pool). +// +// For a destination element at distance d: +// rawCost = 10^(d+1) +// componentCost = 10 * (d+1) per component + +import type { ElementRecipe } from '../types'; + +export interface ConversionCost { + /** Destination element ID */ + element: string; + /** Distance from raw mana */ + distance: number; + /** Raw mana cost per 1 unit of destination */ + rawCost: number; + /** Component costs: element ID → amount per 1 unit of destination */ + componentCosts: Record; +} + +function computeRawCost(distance: number): number { + return Math.pow(10, distance + 1); +} + +function computeComponentCost(distance: number): number { + return 10 * (distance + 1); +} + +/** Build a ConversionCost for a base element (distance 1, no components) */ +function baseElementCost(element: string): ConversionCost { + return { + element, + distance: 1, + rawCost: computeRawCost(1), // 100 + componentCosts: {}, + }; +} + +/** Build a ConversionCost for a composite element (distance 2) */ +function compositeElementCost(element: string, components: string[]): ConversionCost { + return { + element, + distance: 2, + rawCost: computeRawCost(2), // 1,000 + componentCosts: Object.fromEntries( + components.map(c => [c, computeComponentCost(2)]), // 30 each + ), + }; +} + +/** Build a ConversionCost for an exotic element (distance 3) */ +function exoticElementCost(element: string, components: string[]): ConversionCost { + return { + element, + distance: 3, + rawCost: computeRawCost(3), // 10,000 + componentCosts: Object.fromEntries( + components.map(c => [c, computeComponentCost(3)]), // 40 each + ), + }; +} + +/** Build a ConversionCost for time (distance 4) */ +function timeElementCost(element: string, components: string[]): ConversionCost { + return { + element, + distance: 4, + rawCost: computeRawCost(4), // 100,000 + componentCosts: Object.fromEntries( + components.map(c => [c, computeComponentCost(4)]), // 50 each + ), + }; +} + +// ─── Full Cost Table ────────────────────────────────────────────────────────── + +export const CONVERSION_COSTS: Record = { + // Base (distance 1) + fire: baseElementCost('fire'), + water: baseElementCost('water'), + air: baseElementCost('air'), + earth: baseElementCost('earth'), + light: baseElementCost('light'), + dark: baseElementCost('dark'), + death: baseElementCost('death'), + // Utility (distance 1) + transference: baseElementCost('transference'), + // Composite (distance 2) + metal: compositeElementCost('metal', ['fire', 'earth']), + sand: compositeElementCost('sand', ['earth', 'water']), + lightning: compositeElementCost('lightning', ['fire', 'air']), + frost: compositeElementCost('frost', ['air', 'water']), + blackflame: compositeElementCost('blackflame', ['dark', 'fire']), + radiantflames: compositeElementCost('radiantflames', ['light', 'fire']), + miasma: compositeElementCost('miasma', ['air', 'death']), + shadowglass: compositeElementCost('shadowglass', ['earth', 'dark']), + // Exotic (distance 3) + crystal: exoticElementCost('crystal', ['sand', 'light']), + stellar: exoticElementCost('stellar', ['plasma', 'light']), + void: exoticElementCost('void', ['dark', 'death']), + soul: exoticElementCost('soul', ['light', 'dark', 'transference']), + plasma: exoticElementCost('plasma', ['lightning', 'fire', 'transference']), + // Time (distance 4) + time: timeElementCost('time', ['soul', 'sand', 'transference']), +}; + +/** Get the conversion cost for an element. Returns null if not found. */ +export function getConversionCost(element: string): ConversionCost | null { + return CONVERSION_COSTS[element] ?? null; +} + +/** Get all source types (raw + components) for a conversion */ +export function getConversionSources(element: string): string[] { + const cost = CONVERSION_COSTS[element]; + if (!cost) return []; + return ['raw', ...Object.keys(cost.componentCosts)]; +} diff --git a/src/lib/game/data/disciplines/elemental-regen-advanced.ts b/src/lib/game/data/disciplines/elemental-regen-advanced.ts index 04b16c6..91e0570 100644 --- a/src/lib/game/data/disciplines/elemental-regen-advanced.ts +++ b/src/lib/game/data/disciplines/elemental-regen-advanced.ts @@ -1,6 +1,10 @@ // ─── Elemental Conversion Disciplines (Composite + Exotic) ────────────────────── // Conversion disciplines for composite and exotic mana types. // All are BASE attunement so they are available to every role once the element is unlocked. +// +// NEW MODEL: Disciplines contribute to conversion_{element} stat bonus. +// The unified conversion-rates.ts calculator handles rate computation. +// No direct mana drain — costs are deducted from regen. import { DisciplinesAttunementType } from '../../types/disciplines'; import type { DisciplineDefinition } from '../../types/disciplines'; @@ -16,19 +20,11 @@ interface AdvancedConversionConfig { scalingFactor: number; drainBase: number; sourceManaTypes: DisciplineDefinition['manaType'][]; - customOnceDescription?: string; - customOnceAmount?: number; - customInfiniteDescription?: string; - customInfiniteAmount?: number; infiniteThreshold?: number; } function createAdvancedConversionDiscipline(cfg: AdvancedConversionConfig): DisciplineDefinition { const nameLower = cfg.name.toLowerCase(); - const onceDesc = cfg.customOnceDescription ?? `+${cfg.conversionRate} ${cfg.name} Conversion/sec`; - const onceAmt = cfg.customOnceAmount ?? cfg.conversionRate; - const infDesc = cfg.customInfiniteDescription ?? `Every 100 XP: +${cfg.conversionRate * 0.5} ${cfg.name} Conversion/sec`; - const infAmt = cfg.customInfiniteAmount ?? cfg.conversionRate * 0.5; const infThreshold = cfg.infiniteThreshold ?? 400; return { @@ -41,7 +37,7 @@ function createAdvancedConversionDiscipline(cfg: AdvancedConversionConfig): Disc statBonus: { stat: `conversion_${cfg.manaType}` as DisciplineDefinition['statBonus']['stat'], baseValue: cfg.conversionRate, - label: `${cfg.name} Conversion/sec`, + label: `${cfg.name} Conversion/hr`, }, difficultyFactor: cfg.difficultyFactor, scalingFactor: cfg.scalingFactor, @@ -55,23 +51,23 @@ function createAdvancedConversionDiscipline(cfg: AdvancedConversionConfig): Disc type: 'once', threshold: 150, value: 0, - description: onceDesc, - bonus: { stat: `conversion_${cfg.manaType}`, amount: onceAmt }, + description: `+${cfg.conversionRate} ${cfg.name} Conversion/hr`, + bonus: { stat: `conversion_${cfg.manaType}`, amount: cfg.conversionRate }, }, { id: `${cfg.id}-inf`, type: 'infinite', threshold: infThreshold, value: 100, - description: infDesc, - bonus: { stat: `conversion_${cfg.manaType}`, amount: infAmt }, + description: `Every 100 XP: +${cfg.conversionRate * 0.5} ${cfg.name} Conversion/hr`, + bonus: { stat: `conversion_${cfg.manaType}`, amount: cfg.conversionRate * 0.5 }, }, ], }; } export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [ - // ── Composite Elements ───────────────────────────────────────────────────── + // ── Composite Elements (distance 2, rate 0.35/hr) ──────────────────────── createAdvancedConversionDiscipline({ id: 'regen-metal', name: 'Metal', @@ -120,6 +116,7 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [ drainBase: 2, sourceManaTypes: ['raw', 'air', 'water'], }), + // ── Composite Elements (distance 2, rate 0.30/hr) ──────────────────────── createAdvancedConversionDiscipline({ id: 'regen-blackflame', name: 'BlackFlame', @@ -169,7 +166,7 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [ sourceManaTypes: ['raw', 'earth', 'dark'], }), - // ── Exotic Elements ──────────────────────────────────────────────────────── + // ── Exotic Elements (distance 3, rate 0.25/hr) ────────────────────────── createAdvancedConversionDiscipline({ id: 'regen-crystal', name: 'Crystal', @@ -183,19 +180,6 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [ sourceManaTypes: ['raw', 'sand', 'light'], infiniteThreshold: 500, }), - createAdvancedConversionDiscipline({ - id: 'regen-stellar', - name: 'Stellar', - manaType: 'stellar', - cost: 20, - description: 'Convert raw mana + plasma mana + light mana into stellar mana over time.', - conversionRate: 0.2, - difficultyFactor: 230, - scalingFactor: 115, - drainBase: 3, - sourceManaTypes: ['raw', 'plasma', 'light'], - infiniteThreshold: 500, - }), createAdvancedConversionDiscipline({ id: 'regen-void', name: 'Void', @@ -209,6 +193,21 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [ sourceManaTypes: ['raw', 'dark', 'death'], infiniteThreshold: 500, }), + + // ── Exotic Elements (distance 3, rate 0.20/hr) ────────────────────────── + createAdvancedConversionDiscipline({ + id: 'regen-stellar', + name: 'Stellar', + manaType: 'stellar', + cost: 20, + description: 'Convert raw mana + plasma mana + light mana into stellar mana over time.', + conversionRate: 0.2, + difficultyFactor: 230, + scalingFactor: 115, + drainBase: 3, + sourceManaTypes: ['raw', 'plasma', 'light'], + infiniteThreshold: 500, + }), createAdvancedConversionDiscipline({ id: 'regen-soul', name: 'Soul', @@ -235,6 +234,8 @@ export const elementalRegenAdvancedDisciplines: DisciplineDefinition[] = [ sourceManaTypes: ['raw', 'lightning', 'fire', 'transference'], infiniteThreshold: 550, }), + + // ── Time (distance 4, rate 0.15/hr) ───────────────────────────────────── createAdvancedConversionDiscipline({ id: 'regen-time', name: 'Time', diff --git a/src/lib/game/data/disciplines/elemental-regen.ts b/src/lib/game/data/disciplines/elemental-regen.ts index d23c663..4f45960 100644 --- a/src/lib/game/data/disciplines/elemental-regen.ts +++ b/src/lib/game/data/disciplines/elemental-regen.ts @@ -1,33 +1,31 @@ // ─── Elemental Conversion Disciplines (Base + Utility) ───────────────────────── // One discipline per mana type that converts raw mana into that element. // All are BASE attunement so they are available to every role once the element is unlocked. +// +// NEW MODEL: Disciplines contribute to conversion_{element} stat bonus. +// The unified conversion-rates.ts calculator handles rate computation. +// No direct mana drain — costs are deducted from regen. import { DisciplinesAttunementType } from '../../types/disciplines'; import type { DisciplineDefinition } from '../../types/disciplines'; -const BASE_CONVERSION = 0.5; -const BASE_DRAIN = 1.5; -const BASE_DIFF = 120; -const BASE_SCALE = 60; - interface BaseConversionConfig { id: string; name: string; manaType: string; cost: number; + /** Base conversion rate (per hour) before XP scaling */ conversionRate?: number; difficultyFactor?: number; scalingFactor?: number; drainBase?: number; - sourceManaTypes?: DisciplineDefinition['manaType'][]; } function createManaConversionDiscipline(cfg: BaseConversionConfig): DisciplineDefinition { - const rate = cfg.conversionRate ?? BASE_CONVERSION; - const diff = cfg.difficultyFactor ?? BASE_DIFF; - const scale = cfg.scalingFactor ?? BASE_SCALE; - const drain = cfg.drainBase ?? BASE_DRAIN; - const sources = cfg.sourceManaTypes ?? ['raw' as DisciplineDefinition['manaType']]; + const rate = cfg.conversionRate ?? 0.5; + const diff = cfg.difficultyFactor ?? 120; + const scale = cfg.scalingFactor ?? 60; + const drain = cfg.drainBase ?? 1.5; const nameLower = cfg.name.toLowerCase(); return { @@ -40,13 +38,13 @@ function createManaConversionDiscipline(cfg: BaseConversionConfig): DisciplineDe statBonus: { stat: `conversion_${cfg.manaType}` as DisciplineDefinition['statBonus']['stat'], baseValue: rate, - label: `${cfg.name} Conversion/sec`, + label: `${cfg.name} Conversion/hr`, }, difficultyFactor: diff, scalingFactor: scale, drainBase: drain, conversionRate: rate, - sourceManaTypes: sources, + sourceManaTypes: ['raw' as DisciplineDefinition['manaType']], requires: [cfg.manaType], perks: [ { @@ -54,7 +52,7 @@ function createManaConversionDiscipline(cfg: BaseConversionConfig): DisciplineDe type: 'once', threshold: 100, value: 0, - description: `+${rate} ${cfg.name} Conversion/sec`, + description: `+${rate} ${cfg.name} Conversion/hr`, bonus: { stat: `conversion_${cfg.manaType}`, amount: rate }, }, { @@ -62,24 +60,24 @@ function createManaConversionDiscipline(cfg: BaseConversionConfig): DisciplineDe type: 'infinite', threshold: 300, value: 100, - description: `Every 100 XP: +0.25 ${cfg.name} Conversion/sec`, - bonus: { stat: `conversion_${cfg.manaType}`, amount: 0.25 }, + description: `Every 100 XP: +${rate * 0.5} ${cfg.name} Conversion/hr`, + bonus: { stat: `conversion_${cfg.manaType}`, amount: rate * 0.5 }, }, ], }; } export const elementalRegenDisciplines: DisciplineDefinition[] = [ - // ── Base Elements ────────────────────────────────────────────────────────── - createManaConversionDiscipline({ id: 'regen-fire', name: 'Fire', manaType: 'fire', cost: 8 }), - createManaConversionDiscipline({ id: 'regen-water', name: 'Water', manaType: 'water', cost: 8 }), - createManaConversionDiscipline({ id: 'regen-air', name: 'Air', manaType: 'air', cost: 8 }), - createManaConversionDiscipline({ id: 'regen-earth', name: 'Earth', manaType: 'earth', cost: 8 }), - createManaConversionDiscipline({ id: 'regen-light', name: 'Light', manaType: 'light', cost: 8 }), - createManaConversionDiscipline({ id: 'regen-dark', name: 'Dark', manaType: 'dark', cost: 8 }), - createManaConversionDiscipline({ id: 'regen-death', name: 'Death', manaType: 'death', cost: 8 }), + // ── Base Elements (distance 1, rate 0.5/hr) ────────────────────────────── + createManaConversionDiscipline({ id: 'regen-fire', name: 'Fire', manaType: 'fire', cost: 8 }), + createManaConversionDiscipline({ id: 'regen-water', name: 'Water', manaType: 'water', cost: 8 }), + createManaConversionDiscipline({ id: 'regen-air', name: 'Air', manaType: 'air', cost: 8 }), + createManaConversionDiscipline({ id: 'regen-earth', name: 'Earth', manaType: 'earth', cost: 8 }), + createManaConversionDiscipline({ id: 'regen-light', name: 'Light', manaType: 'light', cost: 8 }), + createManaConversionDiscipline({ id: 'regen-dark', name: 'Dark', manaType: 'dark', cost: 8 }), + createManaConversionDiscipline({ id: 'regen-death', name: 'Death', manaType: 'death', cost: 8 }), - // ── Utility Element ──────────────────────────────────────────────────────── + // ── Utility Element (distance 1, rate 0.4/hr) ──────────────────────────── createManaConversionDiscipline({ id: 'regen-transference', name: 'Transference', @@ -89,6 +87,5 @@ export const elementalRegenDisciplines: DisciplineDefinition[] = [ difficultyFactor: 100, scalingFactor: 50, drainBase: 1, - sourceManaTypes: ['raw'], }), ]; diff --git a/src/lib/game/effects/discipline-effects.ts b/src/lib/game/effects/discipline-effects.ts index 90a60c3..4a328f9 100644 --- a/src/lib/game/effects/discipline-effects.ts +++ b/src/lib/game/effects/discipline-effects.ts @@ -1,5 +1,9 @@ // ─── Discipline Effects ─────────────────────────────────────────────────────── // Computes bonuses from active disciplines and integrates with the unified effect system +// +// NEW MODEL: Conversion disciplines contribute to conversion_{element} stat bonuses. +// The unified conversion-rates.ts calculator handles rate computation and regen deduction. +// This file no longer builds a direct conversions map for the tick pipeline. import type { DisciplineStoreState } from '../stores/discipline-slice'; import type { DisciplineState } from '../types/disciplines'; @@ -32,21 +36,40 @@ const KNOWN_BONUS_STATS = new Set([ 'disciplineXpBonus', 'clickManaMultiplier', 'studySpeed', + // Conversion stat bonuses (one per element) + 'conversion_fire', + 'conversion_water', + 'conversion_air', + 'conversion_earth', + 'conversion_light', + 'conversion_dark', + 'conversion_death', + 'conversion_transference', + 'conversion_metal', + 'conversion_sand', + 'conversion_lightning', + 'conversion_frost', + 'conversion_blackflame', + 'conversion_radiantflames', + 'conversion_miasma', + 'conversion_shadowglass', + 'conversion_crystal', + 'conversion_stellar', + 'conversion_void', + 'conversion_soul', + 'conversion_plasma', + 'conversion_time', ]); export interface DisciplineEffectsResult { bonuses: Record; multipliers: Record; specials: Set; - /** - * Bonus to the meditation multiplier cap from disciplines. - * Each point of meditationCapBonus adds +0.5 to the max meditation multiplier. - */ meditationCapBonus: number; /** * Conversion entries: for each active discipline with a conversionRate, * maps target mana type → { rate, sourceManaTypes }. - * The tick pipeline drains source mana types and adds to the target. + * Used by the unified conversion calculator for rate computation. */ conversions: Record; } @@ -77,15 +100,15 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): Discipl } for (const { disc, def } of activeDiscs) { - // Continuous stat bonus + // Continuous stat bonus (includes conversion_{element} for regen disciplines) const statBonus = calculateStatBonus(def.statBonus.baseValue, disc.xp, def.scalingFactor); if (def.statBonus.stat) { addBonus(def.statBonus.stat, statBonus); } // Conversion entry — if this discipline defines conversionRate + // This is used by the unified conversion calculator if (def.conversionRate && def.sourceManaTypes && def.sourceManaTypes.length > 0) { - // Scale the conversion rate by the stat bonus multiplier const scaledRate = def.conversionRate + statBonus; conversions[def.manaType] = { rate: scaledRate, @@ -102,7 +125,6 @@ export function computeDisciplineEffects(_state?: DisciplineStoreState): Discipl } else if (!perk.unlocksEffects) { specials.add(perk.id); } - // Perks with unlocksEffects are handled by discipline-slice.ts processTick() } else if (perk.type === 'infinite') { if (perk.bonus) { const interval = perk.value; diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index ba9229f..0adfd73 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -7,6 +7,8 @@ import type { ComputedEffects } from '../effects/upgrade-effects.types'; import { computeDisciplineEffects } from '../effects/discipline-effects'; import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils'; +import { getElementDistance } from '../utils/element-distance'; +import { computeConversionRates } from '../utils/conversion-rates'; import { mergePerElementCapBonuses } from '../utils/element-cap-bonus'; import { processPactRitual } from './pipelines/pact-ritual'; import { buildCombatCallbacks } from './pipelines/combat-tick'; @@ -24,6 +26,7 @@ import { createStartNewLoop } from './gameLoopActions'; import { buildTickContext, applyTickWrites } from './tick-pipeline'; import { processEnchantingTicks } from './pipelines/enchanting-tick'; import { buildGolemCombatPipeline } from './pipelines/golem-combat'; +import { getGuardianForFloor } from '../data/guardian-encounters'; import type { TickContext, TickWrites } from './tick-pipeline'; import type { GameCoordinatorState } from './gameStore.types'; @@ -161,123 +164,56 @@ export const useGameStore = create()( meditateTicks = 0; } - let totalConversionPerTick = 0; - let rawManaDelta = 0; - let elements = { ...ctx.mana.elements }; - Object.entries(ctx.attunement.attunements).forEach(([id, state]) => { - if (!state.active) return; - const def = ATTUNEMENTS_DEF[id]; - if (!def || def.conversionRate <= 0 || !def.primaryManaType) return; - const scaledRate = getAttunementConversionRate(id, state.level || 1); - const conversionThisTick = scaledRate * HOURS_PER_TICK; - totalConversionPerTick += conversionThisTick; - // Deduct raw mana to pay for the conversion — without this, attunements produce free element mana - rawManaDelta -= conversionThisTick; - if (elements[def.primaryManaType]) { - if (!elements[def.primaryManaType].unlocked) { - elements[def.primaryManaType] = { ...elements[def.primaryManaType], unlocked: true }; - } - elements[def.primaryManaType].current = Math.min( - elements[def.primaryManaType].max, - elements[def.primaryManaType].current + conversionThisTick, - ); - } + // ── Unified Conversion System ───────────────────────────────────── + const { pactElementMap, grossRegen } = buildConversionParams(ctx.prestige.signedPacts, ctx.attunement.attunements); + const invokerLevel = ctx.attunement.attunements.invoker?.active ? (ctx.attunement.attunements.invoker.level || 1) : 0; + const conversionResult = computeConversionRates({ + disciplineEffects, attunements: ctx.attunement.attunements, + signedPacts: ctx.prestige.signedPacts, pactElementMap, invokerLevel, + meditationMultiplier, grossRegen, rawGrossRegen: baseRegen, }); - const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick); + // Apply conversion results: produce element mana from conversions + let rawMana = ctx.mana.rawMana; + let elements = { ...ctx.mana.elements }; - const rawAfterConversion = ctx.mana.rawMana + rawManaDelta; - const regenFromMeditation = Math.max(0, effectiveRegen * HOURS_PER_TICK); - const roomLeft = Math.max(0, maxMana - Math.max(0, rawAfterConversion)); - // Only count regen that actually fits below the cap (fix #224) - const actualRegenAdded = Math.floor(Math.min(regenFromMeditation, roomLeft) * 1000) / 1000; - let rawMana = Math.max(0, Math.min(rawAfterConversion + effectiveRegen * HOURS_PER_TICK, maxMana)); - let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegenAdded); - - if (ctx.combat.currentAction === 'convert') { - const convertResult = useManaStore.getState().processConvertAction(rawMana); - if (convertResult) { - rawMana = convertResult.rawMana; - elements = convertResult.elements; + // Log paused conversions + for (const [elem, entry] of Object.entries(conversionResult.rates)) { + if (entry.paused && entry.pauseReason) { + addLog(`⚠️ PAUSED: ${elem} conversion — ${entry.pauseReason}`); } } - const pactResult = processPactRitual( - ctx.prestige.pactRitualFloor, - ctx.prestige.pactRitualProgress, - ctx.prestige.signedPacts, - ctx.prestige.defeatedGuardians, - ctx.prestige.prestigeUpgrades.pactAffinity || 0, - disciplineEffects.bonuses.pactAffinityBonus || 0, - ); - if (pactResult.writes) { - writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes }; + // Apply produced element mana (from active conversions) + for (const [elem, entry] of Object.entries(conversionResult.rates)) { + if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue; + if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true }; + elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * HOURS_PER_TICK) }; } + // Net raw regen = gross regen - conversion drains - incursion + const netRawRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain); + const actualRegen = Math.floor(Math.min(netRawRegen * HOURS_PER_TICK, maxMana - rawMana) * 1000) / 1000; + rawMana = Math.max(0, Math.min(rawMana + netRawRegen * HOURS_PER_TICK, maxMana)); + let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegen); + + const pactResult = processPactRitual(ctx.prestige.pactRitualFloor, ctx.prestige.pactRitualProgress, ctx.prestige.signedPacts, ctx.prestige.defeatedGuardians, ctx.prestige.prestigeUpgrades.pactAffinity || 0, disciplineEffects.bonuses.pactAffinityBonus || 0); + if (pactResult.writes) writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes }; pactResult.logs.forEach(l => addLog(l)); - const disciplineResult = useDisciplineStore.getState().processTick({ - rawMana, - elements, - }); - rawMana = disciplineResult.rawMana; - elements = disciplineResult.elements; + 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())); - // Log auto-paused disciplines for better UX feedback (fix #244) - if (disciplineResult.autoPausedNames.length > 0) { - const names = disciplineResult.autoPausedNames.join(', '); - addLog('⏸️ Auto-paused (insufficient mana): ' + names); - } - - // Recompute maxMana after discipline XP gains so clamping uses updated value (fix #246) - const updatedDisciplineEffects = computeDisciplineEffects(); - const updatedMaxMana = computeMaxMana( - { prestigeUpgrades: ctx.prestige.prestigeUpgrades }, - undefined, - updatedDisciplineEffects, - ); - rawMana = Math.min(rawMana, updatedMaxMana); - - for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) { - const conversionAmount = conv.rate * HOURS_PER_TICK; - let canConvert = true; - for (const srcType of conv.sourceManaTypes) { - if (srcType === 'raw') { - if (rawMana < conversionAmount) { canConvert = false; break; } - } else if (!elements[srcType] || !elements[srcType].unlocked || elements[srcType].current < conversionAmount) { - canConvert = false; break; - } - } - if (!canConvert) continue; - // Re-check against actual remaining mana to prevent negative values - // when multiple disciplines share the same source - for (const srcType of conv.sourceManaTypes) { - if (srcType === 'raw' && rawMana < conversionAmount) { canConvert = false; break; } - if (srcType !== 'raw' && elements[srcType] && elements[srcType].current < conversionAmount) { canConvert = false; break; } - } - if (!canConvert) continue; - for (const srcType of conv.sourceManaTypes) { - if (srcType === 'raw') { - rawMana -= conversionAmount; - } else if (elements[srcType]) { - elements[srcType] = { ...elements[srcType], current: Math.max(0, elements[srcType].current - conversionAmount) }; - } - } - if (elements[targetElem]) { - elements[targetElem] = { - ...elements[targetElem], - current: Math.min(elements[targetElem].max, elements[targetElem].current + conversionAmount), - }; - } - } - if (disciplineResult.unlockedEffects.length > 0) { - useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects); - for (const effectId of disciplineResult.unlockedEffects) { + if (dr.unlockedEffects.length > 0) { + useCraftingStore.getState().unlockEffects(dr.unlockedEffects); + for (const effectId of dr.unlockedEffects) { addLog('Discipline insight unlocked: ' + effectId); } } - if (disciplineResult.unlockedRecipes.length > 0) { - useCraftingStore.getState().unlockRecipes(disciplineResult.unlockedRecipes); - for (const recipeId of disciplineResult.unlockedRecipes) { + if (dr.unlockedRecipes.length > 0) { + useCraftingStore.getState().unlockRecipes(dr.unlockedRecipes); + for (const recipeId of dr.unlockedRecipes) { addLog('Fabricator recipe unlocked: ' + recipeId); } } @@ -295,7 +231,7 @@ export const useGameStore = create()( } } - // Combat — delegate to combatStore + // Combat if (ctx.combat.currentAction === 'climb') { const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore }); const roomEnemies = ctx.combat.currentRoom?.enemies ?? []; @@ -303,36 +239,27 @@ export const useGameStore = create()( const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy; const defCtx = { roomType: ctx.combat.currentRoom?.roomType ?? 'combat', enemy: activeEnemy }; const golemPipeline = buildGolemCombatPipeline(addLog); - - // Build equipped swords map for melee auto-attack (spec §3.1) const equippedSwords: Record = {}; - for (const [slot, instanceId] of Object.entries(ctx.crafting.equippedInstances || {})) { - if (!instanceId) continue; - const inst = ctx.crafting.equipmentInstances?.[instanceId]; - if (!inst) continue; - const eqType = EQUIPMENT_TYPES[inst.typeId]; - if (eqType?.category === 'sword') { - equippedSwords[instanceId] = inst; - } + for (const [slot, iid] of Object.entries(ctx.crafting.equippedInstances || {})) { + if (!iid) continue; + const inst = ctx.crafting.equipmentInstances?.[iid]; + if (inst && EQUIPMENT_TYPES[inst.typeId]?.category === 'sword') equippedSwords[iid] = inst; } - - const combatResult = useCombatStore.getState().processCombatTick( + const cr = useCombatStore.getState().processCombatTick( rawMana, elements, maxMana, 1, combatCbs.onFloorCleared, combatCbs.makeOnDamageDealt(() => rawMana, () => elements, defCtx, addLog), ctx.prestige.signedPacts, { activeGolems: golemPipeline.activeGolems }, golemPipeline.golemApplyDamageToRoom, - (dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => applyEnemyDefensesFromPipeline( - dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier, - ), + (dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier) => + applyEnemyDefensesFromPipeline(dmg, enemy, roomType, addLogFn, bypassArmor, bypassBarrier), equippedSwords, ); - rawMana = combatResult.rawMana; - elements = combatResult.elements; - totalManaGathered += combatResult.totalManaGathered || 0; - if (combatResult.logMessages) combatResult.logMessages.forEach(msg => addLog(msg)); - writes.combat = { ...(writes.combat || {}), currentFloor: combatResult.currentFloor, floorHP: combatResult.floorHP, floorMaxHP: combatResult.floorMaxHP, maxFloorReached: combatResult.maxFloorReached, castProgress: combatResult.castProgress, equipmentSpellStates: combatResult.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: combatResult.activeGolems }, meleeSwordProgress: combatResult.meleeSwordProgress, currentRoom: combatResult.currentRoom }; + rawMana = cr.rawMana; elements = cr.elements; + totalManaGathered += cr.totalManaGathered || 0; + if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg)); + writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom }; } if (ctx.combat.currentAction === 'craft') { @@ -358,12 +285,7 @@ export const useGameStore = create()( // Phase 3: Write writes.game = { day, hour, incursionStrength }; - writes.mana = { - rawMana, - meditateTicks, - totalManaGathered, - elements, - }; + writes.mana = { rawMana, meditateTicks, totalManaGathered, elements }; applyTickWrites(writes, storeSetters); } catch (error: unknown) { @@ -396,3 +318,27 @@ export const useGameStore = create()( } ) ); + +/** Build pact element map and gross regen for the unified conversion system */ +function buildConversionParams( + signedPacts: number[], + attunements: Record, +): { pactElementMap: Record; grossRegen: Record } { + const pactElementMap: Record = {}; + for (const floor of signedPacts) { + const guardian = getGuardianForFloor(floor); + if (guardian?.element?.length) { + pactElementMap[floor] = guardian.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) + + getAttunementConversionRate(id, state.level || 1); + } + } + return { pactElementMap, grossRegen }; +} diff --git a/src/lib/game/stores/manaStore.ts b/src/lib/game/stores/manaStore.ts index 2fa1b03..16860ea 100755 --- a/src/lib/game/stores/manaStore.ts +++ b/src/lib/game/stores/manaStore.ts @@ -1,9 +1,12 @@ // ─── Mana Store ─────────────────────────────────────────────────────────────── // Handles raw mana, elements, meditation, and mana regeneration +// +// NEW MODEL: All conversion is passive through the unified conversion system. +// convertMana, processConvertAction, and craftComposite are removed (no-ops for save compat). import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants'; +import { ELEMENTS, BASE_UNLOCKED_ELEMENTS } from '../constants'; import type { ElementState } from '../types'; import { ok, okVoid, fail, ErrorCode } from '../utils/result'; import { createSafeStorage } from '../utils/safe-persist'; @@ -32,27 +35,14 @@ export interface ManaActions { resetMeditateTicks: () => void; // Elements - convertMana: (element: string, amount: number) => Result<{ converted: number }>; unlockElement: (element: string, cost: number) => Result; addElementMana: (element: string, amount: number, max: number) => void; spendElementMana: (element: string, amount: number) => Result; setElementMax: (max: number) => void; - craftComposite: (target: string, recipe: string[]) => Result; - - /** - * Compute and apply per-element max from baseMax + bonuses. - * Caller provides the bonus map (elementCap_* from disciplines/equipment). - * This sets max = baseMax + bonus for each element, preventing double-counting. - */ computeElementMaxWithBonuses: (perElementBonuses: Record) => void; - // Helper for gameStore coordination - processConvertAction: (rawMana: number) => { rawMana: number; elements: Record } | null; - // Reset - resetMana: ( - prestigeUpgrades: Record, - ) => void; + resetMana: (prestigeUpgrades: Record) => void; } // ─── Combined Mana Store Type ──────────────────────────────────────────────── @@ -106,25 +96,6 @@ export const useManaStore = create()( incrementMeditateTicks: () => set((s) => ({ meditateTicks: s.meditateTicks + 1 })), resetMeditateTicks: () => set({ meditateTicks: 0 }), - convertMana: (element: string, amount: number) => { - const state = get(); - const elem = state.elements[element]; - if (!elem?.unlocked) return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`); - - const cost = MANA_PER_ELEMENT * amount; - if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`); - if (elem.current >= elem.max) return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`); - - const canConvert = Math.min(amount, Math.floor(state.rawMana / MANA_PER_ELEMENT), elem.max - elem.current); - if (canConvert <= 0) return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount'); - - set({ - rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT, - elements: { ...state.elements, [element]: { ...elem, current: elem.current + canConvert } }, - }); - return ok({ converted: canConvert }); - }, - unlockElement: (element: string, cost: number) => { const state = get(); if (state.elements[element]?.unlocked) return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`); @@ -177,46 +148,7 @@ export const useManaStore = create()( }); }, - craftComposite: (target: string, recipe: string[]) => { - const state = get(); - const costs: Record = {}; - recipe.forEach(r => { costs[r] = (costs[r] || 0) + 1; }); - - for (const [r, amt] of Object.entries(costs)) { - if ((state.elements[r]?.current || 0) < amt) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`); - } - - const newElems = { ...state.elements }; - const baseMax = state.elements[target]?.baseMax ?? 10; - for (const [r, amt] of Object.entries(costs)) { - newElems[r] = { ...newElems[r], current: newElems[r].current - amt }; - } - - const targetElem = newElems[target]; - newElems[target] = { ...(targetElem || { current: 0, max: 10, baseMax: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true, baseMax }; - set({ elements: newElems }); - return okVoid(); - }, - - processConvertAction: (rawMana: number) => { - const state = get(); - const elements = { ...state.elements }; - - const unlockedElements = Object.entries(elements).filter(([, e]) => e.unlocked && e.current < e.max); - if (unlockedElements.length === 0 || rawMana < MANA_PER_ELEMENT) return null; - - unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current)); - const [targetId, targetState] = unlockedElements[0]; - const canConvert = Math.min(Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current); - if (canConvert <= 0) return null; - - rawMana -= canConvert * MANA_PER_ELEMENT; - return { rawMana, elements: { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } } }; - }, - - resetMana: ( - prestigeUpgrades: Record, - ) => { + resetMana: (prestigeUpgrades: Record) => { const elementMax = 10 + (prestigeUpgrades.elementalAttune || 0) * 25; const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10; set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) }); @@ -228,7 +160,6 @@ export const useManaStore = create()( version: 2, partialize: (state) => ({ rawMana: state.rawMana, meditateTicks: state.meditateTicks, totalManaGathered: state.totalManaGathered, elements: state.elements }), migrate: (persistedState: any, _version) => { - // Migration: add baseMax to elements that don't have it if (persistedState && persistedState.elements) { for (const k of Object.keys(persistedState.elements)) { if (persistedState.elements[k].baseMax === undefined) { diff --git a/src/lib/game/utils/conversion-rates.ts b/src/lib/game/utils/conversion-rates.ts new file mode 100644 index 0000000..2919310 --- /dev/null +++ b/src/lib/game/utils/conversion-rates.ts @@ -0,0 +1,230 @@ +// ─── Unified Conversion Rate Calculator ─────────────────────────────────────── +// Computes conversion rates for all elements using the unified formula: +// +// finalRate = (disciplineRate + attunementBase + pactBase) +// × (1 + attunementLevelBonus + pactLevelBonus) +// × meditationMult +// +// All costs are deducted from regen, not from the mana pool. + +import { CONVERSION_COSTS, getConversionCost } from '../data/conversion-costs'; +import { getElementDistance } from './element-distance'; +import type { DisciplineEffectsResult } from '../effects/discipline-effects'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ConversionRateEntry { + element: string; + distance: number; + /** Base rate from disciplines (includes XP scaling + perks) */ + disciplineRate: number; + /** Base rate from attunements */ + attunementBase: number; + /** Base rate from guardian pacts */ + pactBase: number; + /** Sum of base rates */ + baseRate: number; + /** Attunement level multiplier: 1 + Σ(relevantAttunementLevel × 0.5) */ + attunementMult: number; + /** Pact level multiplier: 1 + Σ(pactCount_element × invokerLevel × 0.25) */ + pactMult: number; + /** Meditation multiplier (reduced by distance) */ + meditationMult: number; + /** Final effective rate (per hour) */ + finalRate: number; + /** Raw cost per unit of destination */ + rawCost: number; + /** Component costs per unit of destination */ + componentCosts: Record; + /** Whether this conversion is paused due to insufficient regen */ + paused: boolean; + /** Reason for pausing (which source is insufficient) */ + pauseReason: string | null; +} + +export interface ConversionRateResult { + /** Per-element conversion rates */ + rates: Record; + /** Total raw regen drain per hour */ + totalRawDrain: number; + /** Per-element regen drain per hour (as component) */ + elementDrain: Record; +} + +// ─── Attunement Base Rates (per spec §5) ────────────────────────────────────── + +const ATTUNEMENT_BASE_RATES: Record = { + transference: 0.2, // Enchanter + earth: 0.25, // Fabricator +}; + +// ─── Main Calculator ────────────────────────────────────────────────────────── + +export interface ConversionRateParams { + /** Discipline effects (includes conversion stat bonuses) */ + disciplineEffects: DisciplineEffectsResult; + /** Active attunements: id → { level } */ + attunements: Record; + /** Signed pact floor numbers */ + signedPacts: number[]; + /** Guardian element lookup: floor → primary element */ + pactElementMap: Record; + /** Invoker attunement level (for pact level bonus) */ + invokerLevel: number; + /** Current meditation multiplier (1 = not meditating) */ + meditationMultiplier: number; + /** Current gross regen per element (before conversion drains) */ + grossRegen: Record; + /** Raw gross regen */ + rawGrossRegen: number; +} + +/** + * Compute unified conversion rates for all elements. + * Returns per-element rates and regen drain totals. + */ +export function computeConversionRates(params: ConversionRateParams): ConversionRateResult { + const { + disciplineEffects, + attunements, + signedPacts, + pactElementMap, + invokerLevel, + meditationMultiplier, + grossRegen, + rawGrossRegen, + } = params; + + const rates: Record = {}; + const elementDrain: Record = {}; + let totalRawDrain = 0; + + // ── Step 1: Compute attunement level bonuses per element ────────── + // Each attunement level adds +0.5 to the multiplier for conversions + // where the attunement's primary element is the destination or a component. + const attunementBonuses: Record = {}; + for (const [id, state] of Object.entries(attunements)) { + if (!state.active) continue; + const level = state.level || 1; + const bonus = level * 0.5; + + // Determine which elements this attunement boosts based on its primary mana type + for (const [elem, cost] of Object.entries(CONVERSION_COSTS)) { + const isDestination = elem === getAttunementPrimaryElement(id); + const isComponent = Object.keys(cost.componentCosts).includes(getAttunementPrimaryElement(id)); + if (isDestination || isComponent) { + attunementBonuses[elem] = (attunementBonuses[elem] || 0) + bonus; + } + } + } + + // ── Step 2: Compute pact bonuses per element ────────────────────── + const pactBaseRates: Record = {}; + const pactBonuses: Record = {}; + for (const floor of signedPacts) { + const element = pactElementMap[floor]; + if (!element) continue; + pactBaseRates[element] = (pactBaseRates[element] || 0) + 0.15; + pactBonuses[element] = (pactBonuses[element] || 0) + invokerLevel * 0.25; + } + + // ── Step 3: Compute rates for each element ──────────────────────── + for (const [elem, cost] of Object.entries(CONVERSION_COSTS)) { + const distance = cost.distance; + + // Discipline rate: from disciplineEffects.conversions or stat bonuses + const discRate = disciplineEffects.conversions[elem]?.rate + || disciplineEffects.bonuses[`conversion_${elem}`] + || 0; + + // Attunement base rate + const attBase = ATTUNEMENT_BASE_RATES[elem] || 0; + + // Pact base rate + const pactBase = pactBaseRates[elem] || 0; + + // Combined base rate + const baseRate = discRate + attBase + pactBase; + + // Multipliers + const attMult = 1 + (attunementBonuses[elem] || 0); + const pactMult = 1 + (pactBonuses[elem] || 0); + + // Meditation multiplier (reduced by distance) + const medMult = distance > 0 + ? 1 + (meditationMultiplier - 1) / distance + : 1; + + // Final rate + const finalRate = baseRate * attMult * pactMult * medMult; + + // Check if paused (insufficient regen for any source) + let paused = false; + let pauseReason: string | null = null; + const rawDrain = finalRate * cost.rawCost; + + if (rawDrain > rawGrossRegen) { + paused = true; + pauseReason = `Insufficient raw regen (need ${rawDrain.toFixed(2)}/hr, have ${rawGrossRegen.toFixed(2)}/hr)`; + } else { + for (const [comp, compCost] of Object.entries(cost.componentCosts)) { + const compDrain = finalRate * compCost; + const compGross = grossRegen[comp] || 0; + if (compDrain > compGross) { + paused = true; + pauseReason = `Insufficient ${comp} regen (need ${compDrain.toFixed(2)}/hr, have ${compGross.toFixed(2)}/hr)`; + break; + } + } + } + + // Only accumulate drains for active (non-paused) conversions + if (!paused) { + totalRawDrain += rawDrain; + for (const [comp, compCost] of Object.entries(cost.componentCosts)) { + elementDrain[comp] = (elementDrain[comp] || 0) + finalRate * compCost; + } + } + + rates[elem] = { + element: elem, + distance, + disciplineRate: discRate, + attunementBase: attBase, + pactBase, + baseRate, + attunementMult: attMult, + pactMult, + meditationMult: medMult, + finalRate: paused ? 0 : finalRate, + rawCost: cost.rawCost, + componentCosts: { ...cost.componentCosts }, + paused, + pauseReason, + }; + } + + return { rates, totalRawDrain, elementDrain }; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getAttunementPrimaryElement(attunementId: string): string { + const map: Record = { + enchanter: 'transference', + fabricator: 'earth', + }; + return map[attunementId] || ''; +} + +/** + * Compute the meditation multiplier for a specific element's conversion. + * Full strength for distance-1, half for distance-2, etc. + */ +export function getMeditationConversionMult( + meditationMultiplier: number, + elementDistance: number, +): number { + if (elementDistance <= 0) return 1; + return 1 + (meditationMultiplier - 1) / elementDistance; +} diff --git a/src/lib/game/utils/element-distance.ts b/src/lib/game/utils/element-distance.ts new file mode 100644 index 0000000..4e7eb3f --- /dev/null +++ b/src/lib/game/utils/element-distance.ts @@ -0,0 +1,58 @@ +// ─── Element Distance from Raw Mana ─────────────────────────────────────────── +// Every mana type has a distance from raw mana. Used for: +// 1. Calculating conversion cost ratios +// 2. Calculating meditation multiplier strength for that element's conversion +// +// Distance tiers: +// Raw = 0 +// Base (7) = 1 +// Utility (1) = 1 +// Composite(8) = 2 +// Exotic (5) = 3 +// Time (1) = 4 + +const ELEMENT_DISTANCES: Record = { + raw: 0, + // Base (distance 1) + fire: 1, + water: 1, + air: 1, + earth: 1, + light: 1, + dark: 1, + death: 1, + // Utility (distance 1) + transference: 1, + // Composite (distance 2) + metal: 2, + sand: 2, + lightning: 2, + frost: 2, + blackflame: 2, + radiantflames: 2, + miasma: 2, + shadowglass: 2, + // Exotic tier 1 (distance 3) + crystal: 3, + stellar: 3, + void: 3, + soul: 3, + plasma: 3, + // Exotic tier 2 (distance 4) + time: 4, +}; + +/** Return the distance of an element from raw mana. Default 0 for unknown. */ +export function getElementDistance(elementId: string): number { + return ELEMENT_DISTANCES[elementId] ?? 0; +} + +/** Return the highest distance among a list of elements. */ +export function getMaxDistance(elementIds: string[]): number { + let max = 0; + for (const id of elementIds) { + const d = getElementDistance(id); + if (d > max) max = d; + } + return max; +} diff --git a/src/lib/game/utils/mana-utils.ts b/src/lib/game/utils/mana-utils.ts index be27cfe..16fd9af 100644 --- a/src/lib/game/utils/mana-utils.ts +++ b/src/lib/game/utils/mana-utils.ts @@ -3,7 +3,7 @@ import type { AttunementState } from '../types'; import type { ComputedEffects } from '../effects/upgrade-effects.types'; import { HOURS_PER_TICK } from '../constants'; -import { getTotalAttunementRegen, getTotalAttunementConversionDrain } from '../data/attunements'; +import { getTotalAttunementRegen } from '../data/attunements'; export interface DisciplineBonuses { bonuses: Record; @@ -84,10 +84,7 @@ export function computeEffectiveRegenForDisplay( discipline?: DisciplineBonuses, ): { rawRegen: number; conversionDrain: number; effectiveRegen: number } { const rawRegen = computeRegen(state, effects, discipline); - const conversionDrain = getTotalAttunementConversionDrain(state.attunements || {}); - const effectiveRegen = Math.max(0, rawRegen - conversionDrain); - - return { rawRegen, conversionDrain, effectiveRegen }; + return { rawRegen, conversionDrain: 0, effectiveRegen: rawRegen }; } // ─── Effective Regen (dynamic) ────────────────────────────────────────────────