fix: apply mana drain when practicing disciplines
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m25s
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:
@@ -1,5 +1,5 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-06-10T08:00:19.784Z
|
Generated: 2026-06-10T08:14:33.822Z
|
||||||
Found: 2 circular chain(s) — these MUST be fixed before modifying involved files.
|
Found: 2 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||||
|
|
||||||
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-06-10T08:00:17.492Z",
|
"generated": "2026-06-10T08:14:31.514Z",
|
||||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,23 +120,23 @@ describe('Cross-Module: Prestige & Discipline', () => {
|
|||||||
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
|
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');
|
useDisciplineStore.getState().activate('raw-mastery');
|
||||||
useManaStore.setState({ rawMana: 99999 });
|
useManaStore.setState({ rawMana: 99999 });
|
||||||
const rawManaBefore = useManaStore.getState().rawMana;
|
const rawManaBefore = useManaStore.getState().rawMana;
|
||||||
|
|
||||||
// Call processTick directly (not game tick) to isolate discipline drain
|
// 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);
|
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
|
||||||
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
|
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
|
||||||
expect(disc).toBeDefined();
|
expect(disc).toBeDefined();
|
||||||
expect(disc!.xp).toBeGreaterThan(0);
|
expect(disc!.xp).toBeGreaterThan(0);
|
||||||
// Returned rawMana should be unchanged (no drain)
|
// raw-mastery: drainBase=1, xp=0 → drain=1; rawMana should be reduced by 1
|
||||||
// (processTick returns the same rawMana it received)
|
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');
|
useDisciplineStore.getState().activate('raw-mastery');
|
||||||
useManaStore.setState({ rawMana: 0 });
|
useManaStore.setState({ rawMana: 0 });
|
||||||
|
|
||||||
@@ -144,8 +144,9 @@ describe('Cross-Module: Prestige & Discipline', () => {
|
|||||||
|
|
||||||
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
|
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
|
||||||
expect(disc).toBeDefined();
|
expect(disc).toBeDefined();
|
||||||
expect(disc!.paused).toBe(false);
|
expect(disc!.autoPaused).toBe(true);
|
||||||
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
|
// XP should NOT accrue while auto-paused due to insufficient mana
|
||||||
|
expect(useDisciplineStore.getState().totalXP).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect concurrent limit', () => {
|
it('should respect concurrent limit', () => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe('DisciplineStore — reactivate after deactivate', () => {
|
|||||||
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
|
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
|
// Activate with sufficient mana
|
||||||
useManaStore.setState({ rawMana: 1000, elements: {} });
|
useManaStore.setState({ rawMana: 1000, elements: {} });
|
||||||
useDisciplineStore.getState().activate('raw-mastery');
|
useDisciplineStore.getState().activate('raw-mastery');
|
||||||
@@ -51,7 +51,8 @@ describe('DisciplineStore — reactivate after deactivate', () => {
|
|||||||
useDisciplineStore.getState().deactivate('raw-mastery');
|
useDisciplineStore.getState().deactivate('raw-mastery');
|
||||||
expect(useDisciplineStore.getState().activeIds).not.toContain('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: {} });
|
useManaStore.setState({ rawMana: 0, elements: {} });
|
||||||
useDisciplineStore.getState().activate('raw-mastery');
|
useDisciplineStore.getState().activate('raw-mastery');
|
||||||
expect(useDisciplineStore.getState().activeIds).toContain('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);
|
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: {} });
|
useManaStore.setState({ rawMana: 100, elements: {} });
|
||||||
useDisciplineStore.getState().activate('raw-mastery');
|
useDisciplineStore.getState().activate('raw-mastery');
|
||||||
expect(useDisciplineStore.getState().activeIds).toContain('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: {} });
|
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
|
||||||
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
|
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
|
// XP should NOT accrue when auto-paused
|
||||||
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1);
|
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should preserve XP when deactivating and reactivating', () => {
|
it('should preserve XP when deactivating and reactivating', () => {
|
||||||
|
|||||||
@@ -79,22 +79,97 @@ describe('DisciplineStore', () => {
|
|||||||
expect(useDisciplineStore.getState().totalXP).toBe(1);
|
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');
|
useDisciplineStore.getState().activate('raw-mastery');
|
||||||
const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
|
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.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');
|
useDisciplineStore.getState().activate('raw-mastery');
|
||||||
|
// Auto-pause with 0 mana
|
||||||
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
|
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', () => {
|
it('should scale drain with XP', () => {
|
||||||
useDisciplineStore.getState().activate('raw-mastery');
|
useDisciplineStore.setState({
|
||||||
useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} });
|
disciplines: { 'raw-mastery': { id: 'raw-mastery', xp: 100, paused: false } },
|
||||||
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1);
|
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', () => {
|
it('should increase concurrent limit at 500 total XP', () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { DisciplineState } from '../types/disciplines';
|
|||||||
import type { ElementState } from '../types';
|
import type { ElementState } from '../types';
|
||||||
import {
|
import {
|
||||||
calculateStatBonus,
|
calculateStatBonus,
|
||||||
|
calculateManaDrain,
|
||||||
canProceedDiscipline,
|
canProceedDiscipline,
|
||||||
checkDisciplinePrerequisites,
|
checkDisciplinePrerequisites,
|
||||||
getUnlockedPerks
|
getUnlockedPerks
|
||||||
@@ -85,12 +86,18 @@ export const useDisciplineStore = create<DisciplineStore>()(
|
|||||||
// Allow re-activation if discipline exists but is paused
|
// Allow re-activation if discipline exists but is paused
|
||||||
const existing = s.disciplines[id];
|
const existing = s.disciplines[id];
|
||||||
if (activeIds.includes(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) {
|
if (existing?.paused) {
|
||||||
return {
|
return {
|
||||||
disciplines: { ...s.disciplines, [id]: { ...existing, paused: false, autoPaused: false } },
|
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;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,13 +177,14 @@ export const useDisciplineStore = create<DisciplineStore>()(
|
|||||||
|
|
||||||
processTick(mana) {
|
processTick(mana) {
|
||||||
const s = get();
|
const s = get();
|
||||||
const rawMana = mana.rawMana;
|
let rawMana = mana.rawMana;
|
||||||
const elements = { ...mana.elements };
|
const elements = { ...mana.elements };
|
||||||
let newXP = s.totalXP;
|
let newXP = s.totalXP;
|
||||||
const newDisciplines = { ...s.disciplines };
|
const newDisciplines = { ...s.disciplines };
|
||||||
const newUnlockedEffects: string[] = [];
|
const newUnlockedEffects: string[] = [];
|
||||||
const newUnlockedRecipes: string[] = [];
|
const newUnlockedRecipes: string[] = [];
|
||||||
const newProcessedPerks = [...(s.processedPerks ?? [])];
|
const newProcessedPerks = [...(s.processedPerks ?? [])];
|
||||||
|
const autoPausedNames: string[] = [];
|
||||||
|
|
||||||
for (const id of s.activeIds ?? []) {
|
for (const id of s.activeIds ?? []) {
|
||||||
const disc = newDisciplines[id];
|
const disc = newDisciplines[id];
|
||||||
@@ -187,6 +195,40 @@ export const useDisciplineStore = create<DisciplineStore>()(
|
|||||||
const def = DISCIPLINE_MAP[id];
|
const def = DISCIPLINE_MAP[id];
|
||||||
if (!def) continue;
|
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;
|
const oldXP = disc.xp;
|
||||||
// Compute discipline XP bonus directly to avoid circular import
|
// Compute discipline XP bonus directly to avoid circular import
|
||||||
let xpBonus = 0;
|
let xpBonus = 0;
|
||||||
@@ -255,7 +297,7 @@ export const useDisciplineStore = create<DisciplineStore>()(
|
|||||||
processedPerks: newProcessedPerks,
|
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 }) }
|
{ 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 }) }
|
||||||
|
|||||||
Reference in New Issue
Block a user