fix: remove discipline pool-drain model, add conversion stats UI per mana-conversion-spec
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m17s

DISC-2: Removed old pool-drain model from discipline-slice.ts processTick()
- Disciplines no longer drain rawMana or element pools
- canProceedDiscipline() no longer checks mana sufficiency
- Removed auto-pause on insufficient mana from processTick()
- Removed drain display and auto-paused message from DisciplineCard.tsx
- Removed auto-paused log from gameStore.ts tick()

DISC-4: Audited crafting pipeline — no composite crafting logic remains
- craftComposite already removed from manaStore.ts (comment only)
- No other composite crafting references found

DISC-5: Added collapsible formula reference to Conversion Stats section
- Shows unified formula, multipliers, cost formulas, and constraints

DISC-6: Added per-element net regen summary line
- Shows 'Net Fire Regen: +0.50/hr − 0.15/hr = +0.35/hr' per element

DISC-7: Created dedicated 'Conversion Stats' section in Stats tab
- Renamed from 'Conversion Breakdown' to dedicated section header

DISC-8: Added detailed per-element regen breakdown to ManaDisplay
- Each element card now expandable to show produced rate and downstream drains
- New ElementRegenBreakdown type and elementRegenBreakdown prop

Tests: Updated 4 test files to reflect new no-drain behavior
- All 1090 tests pass
This commit is contained in:
2026-06-09 11:18:41 +02:00
parent c89d8fd2d8
commit 3ad919a047
13 changed files with 230 additions and 93 deletions
+3 -12
View File
@@ -1,9 +1,9 @@
import React, { useMemo } from 'react';
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import { ELEMENTS } from '@/lib/game/constants/elements';
import { calculateStatBonus, calculateManaDrain } from '@/lib/game/utils/discipline-math';
import { calculateStatBonus } from '@/lib/game/utils/discipline-math';
import clsx from 'clsx';
import { TICKS_PER_SECOND, computePerkCurrentEffect, computeTotalPerkBonusForStat, isRateStat } from './disciplines-utils';
import { computePerkCurrentEffect, computeTotalPerkBonusForStat, isRateStat } from './disciplines-utils';
import type { ComputedPerkEffect } from './disciplines-utils';
// ─── Props ────────────────────────────────────────────────────────────────────
@@ -29,14 +29,13 @@ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
}) => {
const {
id, name, description, manaType, perks,
statBonus, drainBase, difficultyFactor, scalingFactor,
statBonus, scalingFactor,
conversionRate, sourceManaTypes,
} = definition;
const displayXp = xp;
const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100);
const activeStatBonus = calculateStatBonus(statBonus.baseValue, displayXp, scalingFactor);
const drainPerSecond = calculateManaDrain(drainBase, displayXp, difficultyFactor) * TICKS_PER_SECOND;
const elementDef = ELEMENTS[manaType];
const manaColor = elementDef?.color ?? '#888888';
@@ -98,7 +97,6 @@ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
{/* Stats Row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-400">
<span><strong>Drain:</strong> {drainPerSecond.toFixed(1)}/sec</span>
<span><strong>XP:</strong> {displayXp}</span>
</div>
@@ -160,13 +158,6 @@ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
</div>
)}
{/* Auto-paused mana feedback (fix #244) */}
{isActive && isPaused && autoPaused && (
<div className="mt-2 text-xs text-amber-400 bg-amber-900/20 rounded px-2 py-1">
Auto-paused insufficient {manaName} mana to continue practicing.
</div>
)}
{/* Action Button */}
<div className="mt-4 flex justify-end">
<button
@@ -3,14 +3,14 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DebugName } from '@/components/game/debug/debug-context';
import { Separator } from '@/components/ui/separator';
import { FlaskConical } from 'lucide-react';
import { FlaskConical, ChevronDown, ChevronUp } from 'lucide-react';
import { ELEMENTS } from '@/lib/game/constants';
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 } from '@/lib/game/data/attunements';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import type { ElementState } from '@/lib/game/types';
interface ElementStatsSectionProps {
@@ -25,6 +25,7 @@ export function ElementStatsSection({ elemMax, meditationMultiplier, baseRegen }
const elements = useManaStore((s) => s.elements);
const attunements = useAttunementStore((s) => s.attunements);
const disciplines = useDisciplineStore((s) => s.disciplines);
const [formulaExpanded, setFormulaExpanded] = useState(false);
// Compute conversion breakdown
const conversionData = useMemo(() => {
@@ -118,11 +119,45 @@ export function ElementStatsSection({ elemMax, meditationMultiplier, baseRegen }
);
})}
</div>
{/* Conversion Breakdown */}
{/* ── Conversion Stats (dedicated section per spec §11) ── */}
{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="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>Conversion Stats:</div>
{/* Collapsible formula reference (DISC-5) */}
<div className="mb-3">
<button
onClick={() => setFormulaExpanded(!formulaExpanded)}
className="flex items-center gap-1 text-xs font-medium transition-colors"
style={{ color: 'var(--text-muted)' }}
>
{formulaExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
Conversion Rate Formula
</button>
{formulaExpanded && (
<div className="mt-2 p-2 rounded text-xs space-y-1" style={{ background: 'var(--bg-sunken)/40', color: 'var(--text-muted)' }}>
<div>finalRate = (disciplineRate + attunementBase + pactBase) × attunementMult × pactMult × meditationMult</div>
<div style={{ color: 'var(--text-muted)', opacity: 0.7 }}>where:</div>
<div style={{ paddingLeft: '8px' }}>attunementMult = 1 + Σ(relevantAttunementLevel × 0.5)</div>
<div style={{ paddingLeft: '8px' }}>pactMult = 1 + Σ(pactCount_element × invokerLevel × 0.25)</div>
<div style={{ paddingLeft: '8px' }}>meditationMult = 1 + (meditationMultiplier - 1) / elementDistance</div>
<div className="mt-1 pt-1 border-t border-[var(--border-subtle)]">
Cost per 1 unit of destination:
</div>
<div style={{ paddingLeft: '8px' }}>rawCost = 10^(distance+1)</div>
<div style={{ paddingLeft: '8px' }}>componentCost = 10 × (distance+1) per component</div>
<div className="mt-1 pt-1 border-t border-[var(--border-subtle)]" style={{ color: 'var(--color-warning)' }}>
All costs deducted from source regen (not from mana pool).
</div>
<div style={{ color: 'var(--color-warning)' }}>
Conversions pause if source regen &lt; conversion cost.
</div>
</div>
)}
</div>
<div className="space-y-2">
{activeConversions.map((entry) => (
<ConversionRow key={entry.element} entry={entry} elementDrain={conversionData.elementDrain} />
@@ -158,8 +193,10 @@ function ConversionRow({ entry, elementDrain }: {
const def = ELEMENTS[entry.element];
const effectiveRate = entry.paused ? 0 : entry.finalRate;
const rawDrain = effectiveRate * entry.rawCost;
const isComponentDrained = Object.keys(entry.componentCosts).length > 0 &&
Object.keys(elementDrain).length > 0;
const downstreamDrain = elementDrain[entry.element] || 0;
// Net regen: produced - downstream consumption (DISC-6)
const netRegen = entry.paused ? 0 : effectiveRate - downstreamDrain;
return (
<div className="p-2 rounded text-xs" style={{ border: `1px solid ${def?.color}20`, background: 'var(--bg-sunken)/30' }}>
@@ -199,13 +236,30 @@ function ConversionRow({ entry, elementDrain }: {
})}
</div>
{/* Downstream drain (this element consumed by higher conversions) */}
{isComponentDrained && elementDrain[entry.element] > 0 && (
{downstreamDrain > 0 && (
<div className="mt-1" style={{ color: 'var(--color-warning)' }}>
Drained by downstream: -{fmtDec(elementDrain[entry.element], 2)} {def?.name}/hr
Drained by downstream: -{fmtDec(downstreamDrain, 2)} {def?.name}/hr
</div>
)}
</div>
)}
{/* Net regen summary line (DISC-6) */}
{!entry.paused && (
<div className="mt-1.5 pt-1.5 border-t border-[var(--border-subtle)]">
<span style={{ color: 'var(--text-muted)' }}>Net {def?.name} Regen: </span>
<span style={{ color: 'var(--color-success)' }}>+{fmtDec(effectiveRate, 2)}/hr</span>
{downstreamDrain > 0 && (
<>
<span style={{ color: 'var(--text-muted)' }}> </span>
<span style={{ color: 'var(--color-warning)' }}>{fmtDec(downstreamDrain, 2)}/hr</span>
</>
)}
<span style={{ color: 'var(--text-muted)' }}> = </span>
<span style={{ color: netRegen >= 0 ? 'var(--color-success)' : 'var(--color-error)' }}>
{netRegen >= 0 ? '+' : ''}{fmtDec(netRegen, 2)}/hr
</span>
</div>
)}
{entry.paused && entry.pauseReason && (
<div className="mt-1" style={{ color: 'var(--color-error)' }}> {entry.pauseReason}</div>
)}