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
+54 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Mountain } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
@@ -9,11 +9,15 @@ import { ActionButtons } from '@/components/game';
import { AttunementStatus } from '@/components/game/AttunementStatus';
import { ActivityLogPanel } from '@/components/game/ActivityLogPanel';
import { DebugName } from '@/components/game/debug/debug-context';
import { useGameStore, useManaStore, useCombatStore, useCraftingStore, usePrestigeStore } from '@/lib/game/stores';
import { useGameStore, useManaStore, useCombatStore, useCraftingStore, usePrestigeStore, useAttunementStore } from '@/lib/game/stores';
import { getUnifiedEffects } from '@/lib/game/effects';
import { getMeditationBonus, getIncursionStrength } from '@/lib/game/stores';
import { computeTotalMaxMana, computeTotalRegen, computeTotalClickMana } from '@/lib/game/effects';
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 type { ElementRegenBreakdown } from '@/components/game/ManaDisplay';
export function LeftPanel() {
const [isGathering, setIsGathering] = useState(false);
@@ -23,6 +27,8 @@ export function LeftPanel() {
const meditateTicks = useManaStore((s) => s.meditateTicks);
const elementRegen = useManaStore((s) => s.elementRegen);
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
const signedPacts = usePrestigeStore((s) => s.signedPacts);
const attunements = useAttunementStore((s) => s.attunements);
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
const gatherMana = useGameStore((s) => s.gatherMana);
@@ -64,6 +70,51 @@ export function LeftPanel() {
const incursionStrength = getIncursionStrength(useGameStore((s) => s.day), useGameStore((s) => s.hour));
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
// Compute per-element regen breakdown for ManaDisplay (DISC-8)
const elementRegenBreakdown = useMemo((): Record<string, ElementRegenBreakdown> | undefined => {
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)
+ (def.conversionRate || 0);
}
}
const invokerLevel = attunements.invoker?.active ? (attunements.invoker.level || 1) : 0;
const conversionResult = computeConversionRates({
disciplineEffects,
attunements,
signedPacts,
pactElementMap,
invokerLevel,
meditationMultiplier,
grossRegen,
rawGrossRegen: baseRegen,
});
const breakdown: Record<string, ElementRegenBreakdown> = {};
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
if (entry.paused) continue;
const drains: Record<string, number> = {};
// This element is drained when it's a component of a higher conversion
for (const [destElem, destEntry] of Object.entries(conversionResult.rates)) {
if (destEntry.paused) continue;
if (destEntry.componentCosts[elem]) {
drains[destElem] = (drains[destElem] || 0) + destEntry.finalRate * destEntry.componentCosts[elem];
}
}
if (entry.finalRate > 0 || Object.keys(drains).length > 0) {
breakdown[elem] = { produced: entry.finalRate, drains };
}
}
return Object.keys(breakdown).length > 0 ? breakdown : undefined;
}, [disciplineEffects, attunements, signedPacts, meditationMultiplier, baseRegen]);
return (
<div className="md:w-80 space-y-3 flex-shrink-0 p-1">
{/* 1. Mana Display */}
@@ -79,6 +130,7 @@ export function LeftPanel() {
onGatherEnd={handleGatherEnd}
elements={elements}
elementRegen={elementRegen}
elementRegenBreakdown={elementRegenBreakdown}
/>
</DebugName>
+52
View File
@@ -9,6 +9,14 @@ import { ELEMENTS } from '@/lib/game/constants';
import { useState } from 'react';
import { DebugName } from '@/components/game/debug/debug-context';
/** Per-element regen breakdown: produced rate and downstream drains */
export interface ElementRegenBreakdown {
/** Rate at which this element is produced from conversion */
produced: number;
/** Drains: destination element → rate consumed */
drains: Record<string, number>;
}
interface ManaDisplayProps {
rawMana: number;
maxMana: number;
@@ -21,6 +29,8 @@ interface ManaDisplayProps {
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
/** Per-element net regen rates (from unified conversion system) */
elementRegen?: Record<string, number>;
/** Detailed per-element regen breakdown (produced rate + downstream drains) */
elementRegenBreakdown?: Record<string, ElementRegenBreakdown>;
}
export function ManaDisplay({
@@ -34,8 +44,14 @@ export function ManaDisplay({
onGatherEnd,
elements,
elementRegen,
elementRegenBreakdown,
}: ManaDisplayProps) {
const [expanded, setExpanded] = useState(true);
const [expandedElements, setExpandedElements] = useState<Record<string, boolean>>({});
const toggleElementDetail = (id: string) => {
setExpandedElements(prev => ({ ...prev, [id]: !prev[id] }));
};
const unlockedElements = Object.entries(elements)
.filter(([, state]) => state.unlocked && state.current > 0)
@@ -102,6 +118,9 @@ export function ManaDisplay({
const elem = ELEMENTS[id];
if (!elem) return null;
const regen = elementRegen?.[id];
const breakdown = elementRegenBreakdown?.[id];
const hasBreakdown = breakdown && (breakdown.produced > 0 || Object.keys(breakdown.drains).length > 0);
const isExpanded = expandedElements[id];
return (
<div
@@ -137,6 +156,39 @@ export function ManaDisplay({
</div>
)}
</div>
{/* Expandable regen breakdown (DISC-8) */}
{hasBreakdown && (
<button
onClick={() => toggleElementDetail(id)}
className="flex items-center gap-0.5 mt-1 text-xs w-full"
style={{ color: 'var(--text-muted)' }}
>
{isExpanded ? <ChevronUp className="w-2.5 h-2.5" /> : <ChevronDown className="w-2.5 h-2.5" />}
<span>regen detail</span>
</button>
)}
{hasBreakdown && isExpanded && (
<div className="mt-1 pt-1 border-t border-[var(--border-subtle)] space-y-0.5" style={{ color: 'var(--text-muted)' }}>
{breakdown.produced > 0 && (
<div>
<span style={{ color: 'var(--color-success)' }}>+{fmtDec(breakdown.produced, 2)}/hr</span>
<span> converted from raw</span>
</div>
)}
{Object.entries(breakdown.drains).map(([destId, drainRate]) => {
const destElem = ELEMENTS[destId];
return (
<div key={destId}>
<span style={{ color: 'var(--color-warning)' }}>-{fmtDec(drainRate, 2)}/hr</span>
<span> {destElem?.sym} {destElem?.name}</span>
</div>
);
})}
<div className="pt-0.5 border-t border-[var(--border-subtle)]" style={{ color: regen && regen >= 0 ? 'var(--color-success)' : 'var(--color-error)' }}>
Net: {regen && regen >= 0 ? '+' : ''}{fmtDec(regen || 0, 2)}/hr
</div>
</div>
)}
</div>
);
})}
+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>
)}
@@ -120,30 +120,32 @@ describe('Cross-Module: Prestige & Discipline', () => {
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
});
it('should drain raw mana for raw-type disciplines', () => {
it('should NOT drain raw mana for raw-type disciplines (no drain model)', () => {
useDisciplineStore.getState().activate('raw-mastery');
useManaStore.setState({ rawMana: 99999 });
const rawManaBefore = useManaStore.getState().rawMana;
tickN(10);
// Call processTick directly (not game tick) to isolate discipline drain
useDisciplineStore.getState().processTick({ rawMana: rawManaBefore, elements: {} });
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
expect(disc).toBeDefined();
expect(disc!.xp).toBeGreaterThan(0);
// Returned rawMana should be unchanged (no drain)
// (processTick returns the same rawMana it received)
});
it('should pause discipline when insufficient mana', () => {
it('should NOT pause discipline when insufficient mana (no drain model)', () => {
useDisciplineStore.getState().activate('raw-mastery');
useManaStore.setState({ rawMana: 0 });
tickN(5);
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
if (disc) {
expect(disc.paused).toBe(true);
} else {
expect(useDisciplineStore.getState().totalXP).toBe(0);
}
expect(disc).toBeDefined();
expect(disc!.paused).toBe(false);
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
});
it('should respect concurrent limit', () => {
+11 -3
View File
@@ -246,12 +246,12 @@ describe('canProceedDiscipline', () => {
expect(result).toBe(true);
});
it('should return false when raw mana is insufficient', () => {
it('should return true when raw mana is insufficient (no drain model)', () => {
const state: DisciplineState = { id: 'raw-mastery', xp: 10000, paused: false };
const result = canProceedDiscipline(rawMastery, state, {
rawMana: 0,
});
expect(result).toBe(false);
expect(result).toBe(true);
});
it('should return true when element mana is sufficient', () => {
@@ -262,11 +262,19 @@ describe('canProceedDiscipline', () => {
expect(result).toBe(true);
});
it('should return false when element mana is insufficient', () => {
it('should return true when element exists (no drain model)', () => {
const state: DisciplineState = { id: 'attune-fire', xp: 10000, paused: false };
const result = canProceedDiscipline(attuneFire, state, {
elements: { fire: { current: 0, max: 100, unlocked: true } },
});
expect(result).toBe(true);
});
it('should return false when element does not exist in game state', () => {
const state: DisciplineState = { id: 'attune-fire', xp: 10000, paused: false };
const result = canProceedDiscipline(attuneFire, state, {
elements: {},
});
expect(result).toBe(false);
});
@@ -35,7 +35,7 @@ describe('DisciplineStore — reactivate after deactivate', () => {
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
});
it('should NOT reactivate a raw discipline when rawMana is insufficient', () => {
it('should reactivate even with zero mana (no drain model)', () => {
// Activate with sufficient mana
useManaStore.setState({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
@@ -51,10 +51,10 @@ describe('DisciplineStore — reactivate after deactivate', () => {
useDisciplineStore.getState().deactivate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
// Set rawMana to 0 — reactivation should fail
// Set rawMana to 0 — reactivation should still succeed (no drain model)
useManaStore.setState({ rawMana: 0, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
});
it('should reactivate a discipline with elements after deactivating it', () => {
@@ -75,21 +75,18 @@ describe('DisciplineStore — reactivate after deactivate', () => {
expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false);
});
it('should reactivate after processTick auto-pauses due to no mana', () => {
it('should not auto-pause on zero mana (no drain model)', () => {
useManaStore.setState({ rawMana: 100, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
// Tick with no mana — discipline auto-pauses
// Tick with no mana — discipline should NOT auto-pause (no drain model)
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true);
// Restore mana and reactivate
useManaStore.setState({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
// XP should still accrue
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1);
});
it('should preserve XP when deactivating and reactivating', () => {
@@ -47,12 +47,12 @@ describe('DisciplineStore', () => {
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
});
it('should not activate when existing state has insufficient mana', () => {
it('should activate even with insufficient mana (no drain model)', () => {
useDisciplineStore.setState({
disciplines: { 'raw-mastery': { id: 'raw-mastery', xp: 1000, paused: false } },
});
useDisciplineStore.getState().activate('raw-mastery', { elements: {} });
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
});
it('should activate when required element is unlocked', () => {
@@ -79,16 +79,22 @@ describe('DisciplineStore', () => {
expect(useDisciplineStore.getState().totalXP).toBe(1);
});
it('should drain raw mana for raw discipline', () => {
it('should not drain raw mana (no drain model)', () => {
useDisciplineStore.getState().activate('raw-mastery');
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
expect(result.rawMana).toBeLessThan(1000);
expect(result.rawMana).toBe(1000);
});
it('should pause discipline when insufficient mana', () => {
it('should not auto-pause discipline (no drain model)', () => {
useDisciplineStore.getState().activate('raw-mastery');
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true);
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
});
it('should still accrue XP even with zero mana', () => {
useDisciplineStore.getState().activate('raw-mastery');
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1);
});
it('should increase concurrent limit at 500 total XP', () => {
+6 -29
View File
@@ -5,7 +5,6 @@ import { createSafeStorage } from '../utils/safe-persist';
import type { DisciplineState } from '../types/disciplines';
import type { ElementState } from '../types';
import {
calculateManaDrain,
calculateStatBonus,
canProceedDiscipline,
checkDisciplinePrerequisites,
@@ -171,7 +170,7 @@ export const useDisciplineStore = create<DisciplineStore>()(
processTick(mana) {
const s = get();
let rawMana = mana.rawMana;
const rawMana = mana.rawMana;
const elements = { ...mana.elements };
let newXP = s.totalXP;
const newDisciplines = { ...s.disciplines };
@@ -179,8 +178,6 @@ export const useDisciplineStore = create<DisciplineStore>()(
const newUnlockedRecipes: string[] = [];
const newProcessedPerks = [...(s.processedPerks ?? [])];
const drainedIds: string[] = [];
const drainedNames: string[] = [];
for (const id of s.activeIds ?? []) {
const disc = newDisciplines[id];
if (!disc) continue;
@@ -190,26 +187,6 @@ export const useDisciplineStore = create<DisciplineStore>()(
const def = DISCIPLINE_MAP[id];
if (!def) continue;
const drain = calculateManaDrain(def.drainBase, disc.xp, def.difficultyFactor);
const element = elements[def.manaType];
const available = def.manaType === 'raw' ? rawMana : element?.current;
if (!available || available < drain) {
newDisciplines[id] = { ...disc, paused: true, autoPaused: true };
drainedIds.push(id);
drainedNames.push(def.name);
continue;
}
if (def.manaType === 'raw') {
rawMana -= drain;
} else if (elements[def.manaType]) {
elements[def.manaType] = {
...elements[def.manaType],
current: elements[def.manaType].current - drain,
};
}
const oldXP = disc.xp;
// Compute discipline XP bonus directly to avoid circular import
let xpBonus = 0;
@@ -264,21 +241,21 @@ export const useDisciplineStore = create<DisciplineStore>()(
MAX_CONCURRENT_DISCIPLINES + 3
);
// Remove mana-drained disciplines from activeIds so onStopPracticing fires
const newActiveIds = (s.activeIds ?? []).filter((aid) => !drainedIds.includes(aid));
if (newActiveIds.length === 0 && s.activeIds.length > 0) {
// Check if no active disciplines remain to fire onStopPracticing
const keepActiveIds = s.activeIds ?? [];
if (keepActiveIds.length === 0 && s.activeIds.length > 0) {
get().practicingCallbacks?.onStopPracticing?.();
}
set({
disciplines: newDisciplines,
activeIds: newActiveIds,
activeIds: keepActiveIds,
totalXP: newXP,
concurrentLimit: Math.max(s.concurrentLimit, newLimit),
processedPerks: newProcessedPerks,
});
return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes, autoPausedNames: drainedNames };
return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes, autoPausedNames: [] };
},
}),
{ storage: createSafeStorage(), name: 'mana-loop-discipline-store', version: 1, partialize: (state) => ({ disciplines: state.disciplines, activeIds: state.activeIds, concurrentLimit: state.concurrentLimit, totalXP: state.totalXP, processedPerks: state.processedPerks }) }
-1
View File
@@ -217,7 +217,6 @@ export const useGameStore = create<GameCoordinatorStore>()(
const dr = useDisciplineStore.getState().processTick({ rawMana, elements });
rawMana = dr.rawMana; elements = dr.elements;
if (dr.autoPausedNames.length > 0) addLog('⏸️ Auto-paused (insufficient mana): ' + dr.autoPausedNames.join(', '));
rawMana = Math.min(rawMana, computeMaxMana({ prestigeUpgrades: ctx.prestige.prestigeUpgrades }, undefined, computeDisciplineEffects()));
if (dr.unlockedEffects.length > 0) {
+8 -11
View File
@@ -60,30 +60,27 @@ export function canActivateDiscipline(
}
/**
* Check if discipline can proceed (has sufficient mana for drain)
* Check if discipline can proceed.
* Under the new unified conversion model, disciplines do not drain mana from pools.
* This check now only verifies that prerequisites are structurally met.
*/
export function canProceedDiscipline(
discipline: DisciplineDefinition,
disciplineState: DisciplineState | undefined,
gameState?: { elements?: Record<string, any>; rawMana?: number }
): boolean {
// New disciplines can always be activated (prerequisites checked separately)
if (!disciplineState) return true;
// If no game state provided, allow activation (optimistic)
if (!gameState) return true;
const drain = calculateManaDrain(
discipline.drainBase,
disciplineState.xp,
discipline.difficultyFactor
);
if (discipline.manaType === 'raw') {
return (gameState.rawMana || 0) >= drain;
}
// For disciplines with source mana types, verify they exist in game state
// (actual unlock check happens in activate() separately)
if (discipline.manaType === 'raw') return true;
const element = gameState.elements?.[discipline.manaType];
return element && element.current >= drain;
return !!element;
}
// ─── Known mana type names for display ────────────────────────────────────────