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
@@ -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 ────────────────────────────────────────