feat: overhaul mana conversion system to unified regen-deduction model
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user