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
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 < 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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }) }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user