diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index ab8e146..ec39c43 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-10T08:14:33.822Z +Generated: 2026-06-10T08:50:57.213Z 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 7c0e80f..ea34726 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-10T08:14:31.514Z", + "generated": "2026-06-10T08:50:54.740Z", "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 38c84e7..c8d2467 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -202,6 +202,7 @@ Mana-Loop/ │ │ │ │ ├── combat-actions.test.ts │ │ │ │ ├── combat-utils.test.ts │ │ │ │ ├── computed-stats.test.ts +│ │ │ │ ├── conversion-pause-bug-regression.test.ts │ │ │ │ ├── crafting-utils-basic.test.ts │ │ │ │ ├── crafting-utils-equipment.test.ts │ │ │ │ ├── crafting-utils-recipe.test.ts diff --git a/src/app/components/LeftPanel.tsx b/src/app/components/LeftPanel.tsx index eec8e31..c526153 100644 --- a/src/app/components/LeftPanel.tsx +++ b/src/app/components/LeftPanel.tsx @@ -15,7 +15,7 @@ import { computeTotalMaxMana, computeTotalRegen, computeTotalClickMana } from '@ 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 { ATTUNEMENTS_DEF, getTotalAttunementRegen } from '@/lib/game/data/attunements'; import type { ElementRegenBreakdown } from '@/components/game/ManaDisplay'; export function LeftPanel() { @@ -80,12 +80,15 @@ export function LeftPanel() { for (const [id, state] of Object.entries(attunements)) { if (!state.active) continue; const def = ATTUNEMENTS_DEF[id]; - if (def?.primaryManaType) { + if (def?.primaryManaType && def.rawManaRegen) { + const levelMult = Math.pow(1.5, (state.level || 1) - 1); grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0) - + (def.conversionRate || 0); + + def.rawManaRegen * levelMult; } } const invokerLevel = attunements.invoker?.active ? (attunements.invoker.level || 1) : 0; + const attunementRegen = getTotalAttunementRegen(attunements); + const totalRawGrossRegen = baseRegen + attunementRegen; const conversionResult = computeConversionRates({ disciplineEffects, attunements, @@ -94,7 +97,7 @@ export function LeftPanel() { invokerLevel, meditationMultiplier, grossRegen, - rawGrossRegen: baseRegen, + rawGrossRegen: totalRawGrossRegen, }); const breakdown: Record = {}; for (const [elem, entry] of Object.entries(conversionResult.rates)) { diff --git a/src/components/game/tabs/StatsTab/ElementStatsSection.tsx b/src/components/game/tabs/StatsTab/ElementStatsSection.tsx index 655ee91..4cc82b9 100644 --- a/src/components/game/tabs/StatsTab/ElementStatsSection.tsx +++ b/src/components/game/tabs/StatsTab/ElementStatsSection.tsx @@ -39,9 +39,10 @@ export function ElementStatsSection({ elemMax, meditationMultiplier, baseRegen } for (const [id, state] of Object.entries(attunements)) { if (!state.active) continue; const def = ATTUNEMENTS_DEF[id]; - if (def?.primaryManaType) { + if (def?.primaryManaType && def.rawManaRegen) { + const levelMult = Math.pow(1.5, (state.level || 1) - 1); grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0) - + (def.conversionRate || 0); + + def.rawManaRegen * levelMult; } } const invokerLevel = attunements.invoker?.active ? (attunements.invoker.level || 1) : 0; diff --git a/src/lib/game/__tests__/conversion-pause-bug-regression.test.ts b/src/lib/game/__tests__/conversion-pause-bug-regression.test.ts new file mode 100644 index 0000000..515bcdb --- /dev/null +++ b/src/lib/game/__tests__/conversion-pause-bug-regression.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect } from 'vitest'; +import { computeConversionRates } from '../utils/conversion-rates'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// REGRESSION TEST: Elemental mana conversions incorrectly paused (Bug #348) +// +// Two root causes: +// 1. rawGrossRegen passed to computeConversionRates was ~2/hr (base only) +// instead of actual regen including attunement contributions (3000+/hr) +// 2. grossRegen per-element used conversionRate (0.2) instead of +// rawManaRegen (0.5) with level scaling +// +// This test verifies that conversions are NOT paused when rawGrossRegen +// is sufficient, and that per-element grossRegen uses rawManaRegen. +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('Bug #348 — conversion pause regression', () => { + describe('Bug 1: rawGrossRegen must include attunement regen', () => { + it('should NOT pause base element conversions when rawGrossRegen is sufficient', () => { + // Simulate a player with enchanter attunement providing ~3342/hr raw regen + const rawGrossRegen = 3342.95; + + const disciplineEffects = { + bonuses: { conversion_earth: 22.04 }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + earth: { rate: 22.04, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: { enchanter: { active: true, level: 5 } }, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: { transference: 0.5, earth: 0.4 }, + rawGrossRegen, + }); + + // Earth conversion: baseRate=22.04, rawCost=100 + // rawDrain = 22.04 * 1.0 * 1.0 * 1.0 * 100 = 2204/hr + // 2204 < 3342.95 => should NOT be paused + const earthEntry = result.rates['earth']; + expect(earthEntry).toBeDefined(); + expect(earthEntry.paused).toBe(false); + expect(earthEntry.finalRate).toBeGreaterThan(0); + }); + + it('should pause conversions when rawGrossRegen is truly insufficient', () => { + // Simulate the bug: rawGrossRegen is only base 2/hr + const rawGrossRegen = 2.0; + + const disciplineEffects = { + bonuses: { conversion_earth: 22.04 }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + earth: { rate: 22.04, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: {}, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: {}, + rawGrossRegen, + }); + + // With only 2/hr raw regen, earth conversion (needing ~2204/hr) should be paused + const earthEntry = result.rates['earth']; + expect(earthEntry).toBeDefined(); + expect(earthEntry.paused).toBe(true); + expect(earthEntry.finalRate).toBe(0); + }); + + it('should produce correct finalRate for earth with high rawGrossRegen', () => { + const rawGrossRegen = 5000; + + const disciplineEffects = { + bonuses: { conversion_earth: 22.04 }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + earth: { rate: 22.04, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: { fabricator: { active: true, level: 1 } }, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: { earth: 0.4 }, + rawGrossRegen, + }); + + const earthEntry = result.rates['earth']; + expect(earthEntry.paused).toBe(false); + // baseRate = 22.04 (discipline) + 0.25 (ATTUNEMENT_BASE_RATES earth) = 22.29 + // attMult = 1 + (1.5^(1-1) - 1) = 1 + // pactMult = 1, medMult = 1 + // finalRate = 22.29 * 1 * 1 * 1 = 22.29 + expect(earthEntry.finalRate).toBeCloseTo(22.29, 1); + }); + }); + + describe('Bug 2: grossRegen per-element must use rawManaRegen with level scaling', () => { + it('should NOT pause composite conversions when component grossRegen is sufficient', () => { + // Metal = Fire + Earth. If fire grossRegen is high enough, metal should not pause. + const disciplineEffects = { + bonuses: { conversion_fire: 10, conversion_metal: 5 }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + fire: { rate: 10, sourceManaTypes: ['raw'] }, + metal: { rate: 5, sourceManaTypes: ['raw'] }, + }, + }; + + // Use rawManaRegen values (0.5 for enchanter at level 1) not conversionRate (0.2) + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: { enchanter: { active: true, level: 1 } }, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: { transference: 0.5 }, // rawManaRegen for enchanter level 1 + rawGrossRegen: 10000, + }); + + // Fire conversion should not be paused (rawGrossRegen is 10000, fire rawDrain = 10*10 = 100) + const fireEntry = result.rates['fire']; + expect(fireEntry.paused).toBe(false); + expect(fireEntry.finalRate).toBeGreaterThan(0); + }); + + it('should correctly compute grossRegen with level scaling', () => { + // Enchanter level 3: rawManaRegen = 0.5 * 1.5^(3-1) = 0.5 * 2.25 = 1.125 + const disciplineEffects = { + bonuses: {}, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: {}, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: { enchanter: { active: true, level: 3 } }, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: { transference: 1.125 }, // 0.5 * 1.5^2 + rawGrossRegen: 10000, + }); + + // With no discipline conversions, nothing should be paused + for (const [elem, entry] of Object.entries(result.rates)) { + if (entry.baseRate > 0) { + expect(entry.paused).toBe(false); + } + } + }); + + it('should pause composite when component grossRegen is truly insufficient', () => { + // Metal = Fire + Earth. If fire grossRegen is 0, metal should pause. + const disciplineEffects = { + bonuses: { conversion_fire: 10, conversion_metal: 5 }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + fire: { rate: 10, sourceManaTypes: ['raw'] }, + metal: { rate: 5, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: {}, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: {}, // No component regen at all + rawGrossRegen: 10000, // Raw is sufficient + }); + + // Metal needs fire and earth as components. With no grossRegen for either, + // metal should be paused due to insufficient component regen. + const metalEntry = result.rates['metal']; + expect(metalEntry.paused).toBe(true); + expect(metalEntry.pauseReason).toContain('Insufficient'); + }); + }); + + describe('End-to-end: both fixes combined', () => { + it('should produce non-zero conversion rates for all base elements with proper setup', () => { + // Simulate a mid-game player with: + // - Enchanter level 5 (rawManaRegen = 0.5 * 1.5^4 ≈ 2.53) + // - Fabricator level 3 (rawManaRegen = 0.4 * 1.5^2 = 0.9) + // - Total attunement regen ≈ 3.43/hr + base 2/hr = ~5.43/hr + // But with discipline bonuses providing high conversion rates + const rawGrossRegen = 5000; // Simulating high raw regen from all sources + + const disciplineEffects = { + bonuses: { + conversion_fire: 15, + conversion_water: 15, + conversion_air: 15, + conversion_earth: 15, + }, + multipliers: {}, + specials: new Set(), + meditationCapBonus: 0, + conversions: { + fire: { rate: 15, sourceManaTypes: ['raw'] }, + water: { rate: 15, sourceManaTypes: ['raw'] }, + air: { rate: 15, sourceManaTypes: ['raw'] }, + earth: { rate: 15, sourceManaTypes: ['raw'] }, + }, + }; + + const result = computeConversionRates({ + disciplineEffects: disciplineEffects as any, + attunements: { + enchanter: { active: true, level: 5 }, + fabricator: { active: true, level: 3 }, + }, + signedPacts: [], + pactElementMap: {}, + invokerLevel: 0, + meditationMultiplier: 1, + grossRegen: { + transference: 2.53, // 0.5 * 1.5^4 + earth: 0.9, // 0.4 * 1.5^2 + }, + rawGrossRegen, + }); + + // All base element conversions should be active + for (const elem of ['fire', 'water', 'air', 'earth']) { + const entry = result.rates[elem]; + expect(entry).toBeDefined(); + expect(entry.paused).toBe(false); + expect(entry.finalRate).toBeGreaterThan(0); + } + + // totalRawDrain should be non-zero (conversions are consuming raw mana) + expect(result.totalRawDrain).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/lib/game/hooks/useGameDerived.ts b/src/lib/game/hooks/useGameDerived.ts index 7c12b3d..7e8157a 100644 --- a/src/lib/game/hooks/useGameDerived.ts +++ b/src/lib/game/hooks/useGameDerived.ts @@ -6,6 +6,7 @@ import { useGameStore } from '../stores/gameStore'; import { useManaStore } from '../stores/manaStore'; import { useCombatStore } from '../stores/combatStore'; import { usePrestigeStore } from '../stores/prestigeStore'; +import { useAttunementStore } from '../stores/attunementStore'; import { computeEffects } from '../effects/upgrade-effects'; import { computeMaxMana, @@ -18,6 +19,7 @@ import { getElementalBonus, } from '../utils'; import { computePactMultiplier, computePactInsightMultiplier } from '../utils/pact-utils'; + import { ELEMENTS, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier, HOURS_PER_TICK, TICK_MS } from '../constants'; import { getGuardianForFloor } from '../data/guardian-encounters'; import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects'; @@ -32,6 +34,7 @@ export function useManaStats() { const meditateTicks = useManaStore((s) => s.meditateTicks); const day = useGameStore((s) => s.day); const hour = useGameStore((s) => s.hour); + const attunements = useAttunementStore((s) => s.attunements); const disciplineEffects = useMemo( () => computeDisciplineEffects(), @@ -49,8 +52,8 @@ export function useManaStats() { ); const baseRegen = useMemo( - () => computeRegen({ prestigeUpgrades, attunements: {} } as any, upgradeEffects), - [prestigeUpgrades, upgradeEffects] + () => computeRegen({ prestigeUpgrades, attunements } as any, upgradeEffects), + [prestigeUpgrades, upgradeEffects, attunements] ); const clickMana = useMemo( diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index da38298..b76d8cc 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -117,7 +117,7 @@ export const useGameStore = create()( disciplineEffects, ); const baseRegen = computeRegen( - { prestigeUpgrades: ctx.prestige.prestigeUpgrades, attunements: {} }, + { prestigeUpgrades: ctx.prestige.prestigeUpgrades, attunements: ctx.attunement.attunements }, undefined, disciplineEffects, ) * (1 + (disciplineEffects.multipliers.regenMultiplier || 0)); @@ -389,9 +389,10 @@ function buildConversionParams( for (const [id, state] of Object.entries(attunements)) { if (!state.active) continue; const def = ATTUNEMENTS_DEF[id]; - if (def?.primaryManaType) { + if (def?.primaryManaType && def.rawManaRegen) { + const levelMult = Math.pow(1.5, (state.level || 1) - 1); grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0) - + (def.conversionRate || 0); + + def.rawManaRegen * levelMult; } } return { pactElementMap, grossRegen };