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
+29 -20
View File
@@ -19,6 +19,8 @@ interface ManaDisplayProps {
onGatherStart: () => void;
onGatherEnd: () => void;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
/** Per-element net regen rates (from unified conversion system) */
elementRegen?: Record<string, number>;
}
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 && <span style={{ color: 'var(--mana-light)' }}>({fmtDec(meditationMultiplier, 1)}x med)</span>}
</div>
</div>
<Progress
value={(rawMana / maxMana) * 100}
className="h-2 bg-[var(--bg-sunken)]"
style={{ '--progress-bg': 'var(--mana-raw)' } as React.CSSProperties}
/>
<Button
className={`w-full transition-all text-[var(--font-display)] tracking-wider
${isGathering
? 'animate-gather-glow'
className={`w-full transition-all text-[var(--font-display)] tracking-wider
${isGathering
? 'animate-gather-glow'
: 'hover:scale-[1.02]'}
`}
style={{
@@ -82,7 +84,7 @@ export function ManaDisplay({
Gather +{clickMana} Mana
{isGathering && <span className="ml-2 text-xs" style={{ opacity: 0.8 }}>(Holding...)</span>}
</Button>
{/* Elemental Mana Pools */}
{unlockedElements.length > 0 && (
<div className="border-t border-[var(--border-subtle)] pt-3 mt-3">
@@ -92,20 +94,20 @@ export function ManaDisplay({
style={{ color: 'var(--text-muted)' }}
>
<span style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.5px' }}>ELEMENTAL MANA ({unlockedElements.length})</span>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}</button>
{expanded && (
<div className="grid grid-cols-2 gap-2 mt-2">
{unlockedElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
const regen = elementRegen?.[id];
return (
<div
key={id}
<div
key={id}
className="p-2 transition-all border rounded-sm"
style={{
style={{
background: 'var(--bg-sunken)/30',
borderColor: `${elem.color}30`,
}}
@@ -117,16 +119,23 @@ export function ManaDisplay({
</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-void)' }}>
<div
<div
className="h-full transition-all rounded-full"
style={{
style={{
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
backgroundColor: elem.color
backgroundColor: elem.color
}}
/>
</div>
<div className="text-xs game-mono" style={{ color: 'var(--text-muted)' }}>
{fmt(state.current)}/{fmt(state.max)}
<div className="flex items-center justify-between">
<div className="text-xs game-mono" style={{ color: 'var(--text-muted)' }}>
{fmt(state.current)}/{fmt(state.max)}
</div>
{regen !== undefined && regen !== 0 && (
<div className="text-xs game-mono" style={{ color: regen > 0 ? 'var(--color-success)' : 'var(--color-error)' }}>
{regen > 0 ? '+' : ''}{fmtDec(regen, 2)}/hr
</div>
)}
</div>
</div>
);
@@ -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>
);
}