fix: apply mana drain when practicing disciplines
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m25s

- processTick() now calls calculateManaDrain() for non-conversion disciplines
- Insufficient mana triggers auto-paused state (skips XP accrual)
- Conversion disciplines (sourceManaTypes) correctly skip pool drain
- Auto-paused disciplines can be re-activated when mana is restored
- Updated 7 tests across 3 files to reflect drain model
This commit is contained in:
2026-06-10 10:50:40 +02:00
parent 432378fa86
commit bdf2b0050f
6 changed files with 145 additions and 26 deletions
@@ -120,23 +120,23 @@ describe('Cross-Module: Prestige & Discipline', () => {
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
});
it('should NOT drain raw mana for raw-type disciplines (no drain model)', () => {
it('should drain raw mana for raw-type disciplines', () => {
useDisciplineStore.getState().activate('raw-mastery');
useManaStore.setState({ rawMana: 99999 });
const rawManaBefore = useManaStore.getState().rawMana;
// Call processTick directly (not game tick) to isolate discipline drain
useDisciplineStore.getState().processTick({ rawMana: rawManaBefore, elements: {} });
const result = 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)
// raw-mastery: drainBase=1, xp=0 → drain=1; rawMana should be reduced by 1
expect(result.rawMana).toBe(rawManaBefore - 1);
});
it('should NOT pause discipline when insufficient mana (no drain model)', () => {
it('should auto-pause discipline when insufficient mana and not accrue XP', () => {
useDisciplineStore.getState().activate('raw-mastery');
useManaStore.setState({ rawMana: 0 });
@@ -144,8 +144,9 @@ describe('Cross-Module: Prestige & Discipline', () => {
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
expect(disc).toBeDefined();
expect(disc!.paused).toBe(false);
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
expect(disc!.autoPaused).toBe(true);
// XP should NOT accrue while auto-paused due to insufficient mana
expect(useDisciplineStore.getState().totalXP).toBe(0);
});
it('should respect concurrent limit', () => {
@@ -35,7 +35,7 @@ describe('DisciplineStore — reactivate after deactivate', () => {
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
});
it('should reactivate even with zero mana (no drain model)', () => {
it('should reactivate even with zero mana (activation is optimistic, drain applies on tick)', () => {
// Activate with sufficient mana
useManaStore.setState({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
@@ -51,7 +51,8 @@ describe('DisciplineStore — reactivate after deactivate', () => {
useDisciplineStore.getState().deactivate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
// Set rawMana to 0 — reactivation should still succeed (no drain model)
// Set rawMana to 0 — reactivation should still succeed (activation is optimistic)
// but the discipline will auto-pause on the next tick due to insufficient mana
useManaStore.setState({ rawMana: 0, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
@@ -75,18 +76,18 @@ describe('DisciplineStore — reactivate after deactivate', () => {
expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false);
});
it('should not auto-pause on zero mana (no drain model)', () => {
it('should auto-pause on zero mana and not accrue XP', () => {
useManaStore.setState({ rawMana: 100, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
// Tick with no mana — discipline should NOT auto-pause (no drain model)
// Tick with no mana — discipline should auto-pause (drain model)
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
expect(useDisciplineStore.getState().disciplines['raw-mastery'].autoPaused).toBe(true);
// XP should still accrue
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1);
// XP should NOT accrue when auto-paused
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(0);
});
it('should preserve XP when deactivating and reactivating', () => {
@@ -79,22 +79,97 @@ describe('DisciplineStore', () => {
expect(useDisciplineStore.getState().totalXP).toBe(1);
});
it('should not drain raw mana (no drain model)', () => {
it('should drain raw mana when practicing with sufficient mana', () => {
useDisciplineStore.getState().activate('raw-mastery');
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
// raw-mastery: drainBase=1, xp=0, difficultyFactor=100
// drain = 1 * (1 + (0/100)^0.4) = 1
expect(result.rawMana).toBe(999);
});
it('should auto-pause discipline when raw mana is insufficient', () => {
useDisciplineStore.getState().activate('raw-mastery');
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
expect(useDisciplineStore.getState().disciplines['raw-mastery'].autoPaused).toBe(true);
});
it('should not accrue XP when auto-paused due to insufficient mana', () => {
useDisciplineStore.getState().activate('raw-mastery');
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(0);
});
it('should auto-pause discipline and report name when element mana is insufficient', () => {
useDisciplineStore.setState({
disciplines: {},
activeIds: [],
concurrentLimit: 1,
totalXP: 0,
});
// attune-fire has drainBase=2, manaType=fire
useDisciplineStore.getState().activate('attune-fire', {
elements: { fire: { unlocked: true, current: 1, max: 100, baseMax: 100 } },
});
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 1, max: 100, baseMax: 100 } } });
expect(useDisciplineStore.getState().disciplines['attune-fire'].autoPaused).toBe(true);
expect(result.autoPausedNames).toContain('Fire Mana Capacity');
});
it('should drain element mana when practicing element discipline with sufficient mana', () => {
useDisciplineStore.setState({
disciplines: {},
activeIds: [],
concurrentLimit: 1,
totalXP: 0,
});
useDisciplineStore.getState().activate('attune-fire', {
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
});
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } } });
// attune-fire: drainBase=2, xp=0, difficultyFactor=150
// drain = 2 * (1 + (0/150)^0.4) = 2
expect(result.elements.fire.current).toBe(98);
});
it('should not drain mana for conversion disciplines (sourceManaTypes)', () => {
useDisciplineStore.setState({
disciplines: {},
activeIds: [],
concurrentLimit: 1,
totalXP: 0,
});
// regen-fire is a conversion discipline with sourceManaTypes: ['raw']
useDisciplineStore.getState().activate('regen-fire', {
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
});
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } } });
// Conversion disciplines should NOT drain from pools
expect(result.rawMana).toBe(1000);
expect(result.elements.fire.current).toBe(100);
});
it('should not auto-pause discipline (no drain model)', () => {
it('should re-activate auto-paused discipline when mana is restored', () => {
useDisciplineStore.getState().activate('raw-mastery');
// Auto-pause with 0 mana
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
expect(useDisciplineStore.getState().disciplines['raw-mastery'].autoPaused).toBe(true);
// Re-activate (simulates mana being restored)
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].autoPaused).toBe(false);
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
});
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 scale drain with XP', () => {
useDisciplineStore.setState({
disciplines: { 'raw-mastery': { id: 'raw-mastery', xp: 100, paused: false } },
activeIds: ['raw-mastery'],
concurrentLimit: 1,
totalXP: 100,
});
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
// raw-mastery: drainBase=1, xp=100, difficultyFactor=100
// drain = 1 * (1 + (100/100)^0.4) = 1 * (1 + 1) = 2
expect(result.rawMana).toBe(998);
});
it('should increase concurrent limit at 500 total XP', () => {
+45 -3
View File
@@ -6,6 +6,7 @@ import type { DisciplineState } from '../types/disciplines';
import type { ElementState } from '../types';
import {
calculateStatBonus,
calculateManaDrain,
canProceedDiscipline,
checkDisciplinePrerequisites,
getUnlockedPerks
@@ -85,12 +86,18 @@ export const useDisciplineStore = create<DisciplineStore>()(
// Allow re-activation if discipline exists but is paused
const existing = s.disciplines[id];
if (activeIds.includes(id)) {
// If already active and paused (manually or auto), un-pause it
// If already active and paused (manually or auto-paused due to insufficient mana), un-pause it
if (existing?.paused) {
return {
disciplines: { ...s.disciplines, [id]: { ...existing, paused: false, autoPaused: false } },
};
}
// If auto-paused due to insufficient mana, allow re-activation (un-pause when mana is restored)
if (existing?.autoPaused) {
return {
disciplines: { ...s.disciplines, [id]: { ...existing, autoPaused: false } },
};
}
return s;
}
@@ -170,13 +177,14 @@ export const useDisciplineStore = create<DisciplineStore>()(
processTick(mana) {
const s = get();
const rawMana = mana.rawMana;
let rawMana = mana.rawMana;
const elements = { ...mana.elements };
let newXP = s.totalXP;
const newDisciplines = { ...s.disciplines };
const newUnlockedEffects: string[] = [];
const newUnlockedRecipes: string[] = [];
const newProcessedPerks = [...(s.processedPerks ?? [])];
const autoPausedNames: string[] = [];
for (const id of s.activeIds ?? []) {
const disc = newDisciplines[id];
@@ -187,6 +195,40 @@ export const useDisciplineStore = create<DisciplineStore>()(
const def = DISCIPLINE_MAP[id];
if (!def) continue;
// ── Mana drain (skip for conversion disciplines) ──────────────
// Conversion disciplines (with sourceManaTypes) are handled by
// the unified conversion system — they contribute to conversion
// rates, not direct pool drain.
const isConversionDiscipline = !!(def.sourceManaTypes && def.sourceManaTypes.length > 0);
if (!isConversionDiscipline) {
const drain = calculateManaDrain(def.drainBase, disc.xp, def.difficultyFactor);
// Determine which mana pool to drain from
if (def.manaType === 'raw') {
if (rawMana < drain) {
// Insufficient raw mana — auto-pause
newDisciplines[id] = { ...disc, autoPaused: true };
autoPausedNames.push(def.name);
continue; // skip XP accrual
}
rawMana = Math.max(0, rawMana - drain);
} else {
// Element-specific drain
const elemState = elements[def.manaType];
if (!elemState || elemState.current < drain) {
// Insufficient element mana — auto-pause
newDisciplines[id] = { ...disc, autoPaused: true };
autoPausedNames.push(def.name);
continue; // skip XP accrual
}
elements[def.manaType] = {
...elemState,
current: Math.max(0, elemState.current - drain),
};
}
}
const oldXP = disc.xp;
// Compute discipline XP bonus directly to avoid circular import
let xpBonus = 0;
@@ -255,7 +297,7 @@ export const useDisciplineStore = create<DisciplineStore>()(
processedPerks: newProcessedPerks,
});
return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes, autoPausedNames: [] };
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 }) }