feat: overhaul mana conversion system to unified regen-deduction model
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s

- 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
This commit is contained in:
2026-06-04 18:12:41 +02:00
parent 94a2b671b9
commit ab3afae2a6
19 changed files with 742 additions and 572 deletions
@@ -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<number, string> = {};
for (const floor of signedPacts) {
const g = getGuardianForFloor(floor);
if (g?.element?.length) pactElementMap[floor] = g.element[0];
}
const grossRegen: Record<string, number> = {};
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 (
<DebugName name="ElementStatsSection">
@@ -36,12 +78,20 @@ export function ElementStatsSection({ elemMax }: ElementStatsSectionProps) {
<span style={{ color: 'var(--text-muted)' }}>Prestige Attunement:</span>
<span style={{ color: 'var(--color-success)' }}>+{(prestigeUpgrades.elementalAttune || 0) * 25}</span>
</div>
<div className="flex justify-between text-sm">
<span style={{ color: 'var(--text-muted)' }}>Raw Conversion Drain:</span>
<span style={{ color: 'var(--color-warning)' }}>-{fmtDec(conversionData.totalRawDrain, 2)}/hr</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span style={{ color: 'var(--text-muted)' }}>Unlocked Elements:</span>
<span style={{ color: 'var(--color-success)' }}>{Object.values(elements || {}).filter((e: ElementState) => e.unlocked).length} / {Object.keys(ELEMENTS).length}</span>
</div>
<div className="flex justify-between text-sm">
<span style={{ color: 'var(--text-muted)' }}>Active Conversions:</span>
<span style={{ color: 'var(--color-success)' }}>{activeConversions.filter(e => !e.paused).length}</span>
</div>
</div>
</div>
<Separator className="bg-[var(--border-subtle)] my-3" />
@@ -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 (
<div key={id} className="p-2 rounded transition-colors" style={{ border: `1px solid ${def?.color}30`, background: 'var(--bg-sunken)/50', textAlign: 'center' }}>
<div className="text-lg" style={{ color: def?.color }}>{def?.sym}</div>
<div className="text-xs" style={{ color: 'var(--text-muted)' }}>{fmtDec(state.current, 2)}/{fmtDec(state.max, 0)}</div>
{conv && conv.finalRate > 0 && !conv.paused && (
<div className="text-xs mt-0.5" style={{ color: 'var(--color-success)' }}>+{fmtDec(conv.finalRate, 2)}/hr</div>
)}
{conv?.paused && (
<div className="text-xs mt-0.5" style={{ color: 'var(--color-error)' }}></div>
)}
</div>
);
})}
</div>
{/* Conversion Breakdown */}
{activeConversions.length > 0 && (
<>
<Separator className="bg-[var(--border-subtle)] my-3" />
<div className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>Conversion Breakdown:</div>
<div className="space-y-2">
{activeConversions.map((entry) => (
<ConversionRow key={entry.element} entry={entry} />
))}
</div>
</>
)}
</CardContent>
</Card>
</DebugName>
);
}
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 (
<div className="p-2 rounded text-xs" style={{ border: `1px solid ${def?.color}20`, background: 'var(--bg-sunken)/30' }}>
<div className="flex items-center gap-1 mb-1" style={{ color: def?.color }}>
<span>{def?.sym}</span>
<span className="font-medium">{def?.name}</span>
<span style={{ color: 'var(--text-muted)' }}>(d={entry.distance})</span>
{entry.paused && <span style={{ color: 'var(--color-error)' }}> PAUSED</span>}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5" style={{ color: 'var(--text-muted)' }}>
<div>Discipline: <span style={{ color: 'var(--color-success)' }}>+{fmtDec(entry.disciplineRate, 2)}/hr</span></div>
<div>Attunement: <span style={{ color: 'var(--color-success)' }}>+{fmtDec(entry.attunementBase, 2)}/hr</span></div>
<div>Pact: <span style={{ color: 'var(--color-success)' }}>+{fmtDec(entry.pactBase, 2)}/hr</span></div>
<div>Base: <span>{fmtDec(entry.baseRate, 2)}/hr</span></div>
<div>Att mult: <span>×{fmtDec(entry.attunementMult, 2)}</span></div>
<div>Pact mult: <span>×{fmtDec(entry.pactMult, 2)}</span></div>
<div>Med mult: <span>×{fmtDec(entry.meditationMult, 2)}</span></div>
<div>Final: <span style={{ color: entry.paused ? 'var(--color-error)' : 'var(--color-success)' }}>{entry.paused ? '0.00' : fmtDec(entry.finalRate, 2)}/hr</span></div>
</div>
{entry.paused && entry.pauseReason && (
<div className="mt-1" style={{ color: 'var(--color-error)' }}> {entry.pauseReason}</div>
)}
</div>
);
}