fix: discipline stat bonuses not applied to max mana and other stats in reactive UI
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m23s

Bug A: useMemo with empty deps in useGameDerived.ts — disciplineEffects
and clickMana were computed once on mount and never recomputed, so the
display always showed bonuses as 0. Fixed by subscribing to discipline
store state and passing disciplineEffects to computeClickMana().

Bug B: autoPaused disciplines excluded from computeDisciplineEffects —
when a discipline auto-paused due to insufficient mana drain, it lost
all its stat bonuses even though it remained active. Fixed by changing
the filter from !disc.autoPaused to !disc.paused, so auto-paused
disciplines keep their earned bonuses (they just stop gaining XP).

Added regression tests in discipline-effects-reactivity.test.ts.
This commit is contained in:
2026-06-15 10:58:44 +02:00
parent 9b559bb9f9
commit a45d38a9c9
6 changed files with 184 additions and 10 deletions
@@ -0,0 +1,150 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { useDisciplineStore } from '../stores/discipline-slice';
import { baseDisciplines } from '../data/disciplines/base';
// ─── Test Fixtures ────────────────────────────────────────────────────────────
const rawMastery = baseDisciplines.find((d) => d.id === 'raw-mastery')!;
/**
* Helper: set the discipline store to a known state.
* We use the store's setState directly so we can control exactly what
* computeDisciplineEffects sees when it calls getState().
*/
function setDisciplineState(overrides: Partial<ReturnType<typeof useDisciplineStore.getState>>) {
useDisciplineStore.setState(overrides as any);
}
// ─── Bug B: autoPaused disciplines should still contribute stat bonuses ───────
describe('computeDisciplineEffects — autoPaused discipline bonuses (Bug B)', () => {
beforeEach(() => {
// Reset to clean state
setDisciplineState({
disciplines: {},
activeIds: [],
});
});
it('should include stat bonuses from an auto-paused discipline with XP', () => {
setDisciplineState({
disciplines: {
'raw-mastery': { id: 'raw-mastery', xp: 1000, paused: false, autoPaused: true },
},
activeIds: ['raw-mastery'],
});
const effects = computeDisciplineEffects();
expect(effects.bonuses.maxManaBonus).toBeGreaterThan(0);
});
it('should NOT include stat bonuses from a manually paused discipline', () => {
setDisciplineState({
disciplines: {
'raw-mastery': { id: 'raw-mastery', xp: 1000, paused: true, autoPaused: false },
},
activeIds: [],
});
const effects = computeDisciplineEffects();
expect(effects.bonuses.maxManaBonus || 0).toBe(0);
});
it('should include stat bonuses from an active (non-paused) discipline', () => {
setDisciplineState({
disciplines: {
'raw-mastery': { id: 'raw-mastery', xp: 500, paused: false, autoPaused: false },
},
activeIds: ['raw-mastery'],
});
const effects = computeDisciplineEffects();
expect(effects.bonuses.maxManaBonus).toBeGreaterThan(0);
});
it('auto-paused discipline with 0 XP should not contribute bonuses', () => {
setDisciplineState({
disciplines: {
'raw-mastery': { id: 'raw-mastery', xp: 0, paused: false, autoPaused: true },
},
activeIds: ['raw-mastery'],
});
const effects = computeDisciplineEffects();
expect(effects.bonuses.maxManaBonus || 0).toBe(0);
});
it('should distinguish autoPaused from paused: mixed scenario', () => {
setDisciplineState({
disciplines: {
'raw-mastery': { id: 'raw-mastery', xp: 1000, paused: false, autoPaused: true },
},
activeIds: ['raw-mastery'],
});
const effects = computeDisciplineEffects();
// autoPaused=true, paused=false → should be included
expect(effects.bonuses.maxManaBonus).toBeGreaterThan(0);
// Now manually pause it
setDisciplineState({
disciplines: {
'raw-mastery': { id: 'raw-mastery', xp: 1000, paused: true, autoPaused: false },
},
activeIds: [],
});
const effects2 = computeDisciplineEffects();
expect(effects2.bonuses.maxManaBonus || 0).toBe(0);
});
});
// ─── Bug A concept: computeDisciplineEffects reads current state ──────────────
describe('computeDisciplineEffects — reactive state reads (Bug A)', () => {
beforeEach(() => {
setDisciplineState({
disciplines: {},
activeIds: [],
});
});
it('should return updated bonuses when discipline XP changes in the store', () => {
// Start with some XP
setDisciplineState({
disciplines: {
'raw-mastery': { id: 'raw-mastery', xp: 100, paused: false, autoPaused: false },
},
activeIds: ['raw-mastery'],
});
const effects1 = computeDisciplineEffects();
const bonus1 = effects1.bonuses.maxManaBonus;
expect(bonus1).toBeGreaterThan(0);
// Simulate tick: XP increases
setDisciplineState({
disciplines: {
'raw-mastery': { id: 'raw-mastery', xp: 5000, paused: false, autoPaused: false },
},
activeIds: ['raw-mastery'],
});
const effects2 = computeDisciplineEffects();
const bonus2 = effects2.bonuses.maxManaBonus;
// Higher XP → higher bonus (stat bonus is monotonically increasing)
expect(bonus2).toBeGreaterThan(bonus1);
});
it('should return zero bonuses when store is empty (fresh game)', () => {
setDisciplineState({
disciplines: {},
activeIds: [],
});
const effects = computeDisciplineEffects();
expect(effects.bonuses.maxManaBonus || 0).toBe(0);
});
});
+1 -1
View File
@@ -84,7 +84,7 @@ export interface DisciplineEffectsResult {
export function computeDisciplineEffects(_state?: DisciplineStoreState): DisciplineEffectsResult {
const { disciplines } = useDisciplineStore.getState();
const activeDiscs = Object.entries(disciplines)
.filter(([, disc]) => disc && disc.xp > 0 && !disc.autoPaused)
.filter(([, disc]) => disc && disc.xp > 0 && !disc.paused)
.map(([id, disc]) => ({ id, disc, def: ALL_DISCIPLINES.find(d => d.id === id) }))
.filter((entry): entry is { id: string; disc: DisciplineState; def: NonNullable<typeof ALL_DISCIPLINES[0]> } => !!entry.def);
+6 -3
View File
@@ -8,6 +8,7 @@ import { useManaStore } from '../stores/manaStore';
import { useCombatStore } from '../stores/combatStore';
import { usePrestigeStore } from '../stores/prestigeStore';
import { useAttunementStore } from '../stores/attunementStore';
import { useDisciplineStore } from '../stores/discipline-slice';
import { computeEffects } from '../effects/upgrade-effects';
import {
computeMaxMana,
@@ -39,9 +40,11 @@ export function useManaStats() {
const hour = useGameStore((s) => s.hour);
const attunements = useAttunementStore((s) => s.attunements);
const disciplines = useDisciplineStore((s) => s.disciplines);
const disciplineEffects = useMemo(
() => computeDisciplineEffects(),
[]
[disciplines]
);
const upgradeEffects = useMemo(
@@ -60,8 +63,8 @@ export function useManaStats() {
);
const clickMana = useMemo(
() => computeClickMana(),
[]
() => computeClickMana(disciplineEffects),
[disciplineEffects]
);
const meditationCap = 5.0 + disciplineEffects.meditationCapBonus;