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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user