fix: resolve elemental mana conversion pause bug (#348)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Two root causes fixed:
1. gameStore.ts: computeRegen now receives actual attunements instead of empty {}, so rawGrossRegen includes attunement contributions (was ~2/hr, now correct 3000+/hr)
2. gameStore.ts buildConversionParams: use rawManaRegen with level scaling (1.5^(level-1)) instead of conversionRate for per-element grossRegen
3. LeftPanel.tsx: same grossRegen fix + include attunement regen in rawGrossRegen display
4. ElementStatsSection.tsx: same grossRegen fix
5. useGameDerived.ts: pass actual attunements to computeRegen for baseRegen calculation
Added regression test: conversion-pause-bug-regression.test.ts (7 tests)
This commit is contained in:
@@ -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<string, ElementRegenBreakdown> = {};
|
||||
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>(),
|
||||
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<string>(),
|
||||
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<string>(),
|
||||
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<string>(),
|
||||
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<string>(),
|
||||
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<string>(),
|
||||
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<string>(),
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -117,7 +117,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user