From a45d38a9c9bb5b1520b3d2f195a8f955c724bcec Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Mon, 15 Jun 2026 10:58:44 +0200 Subject: [PATCH] fix: discipline stat bonuses not applied to max mana and other stats in reactive UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/circular-deps.txt | 13 +- docs/dependency-graph.json | 19 ++- docs/project-structure.txt | 1 + .../discipline-effects-reactivity.test.ts | 150 ++++++++++++++++++ src/lib/game/effects/discipline-effects.ts | 2 +- src/lib/game/hooks/useGameDerived.ts | 9 +- 6 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 src/lib/game/__tests__/discipline-effects-reactivity.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index b82164e..5517506 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,11 +1,14 @@ # Circular Dependencies -Generated: 2026-06-14T19:56:38.228Z -Found: 4 circular chain(s) — these MUST be fixed before modifying involved files. +Generated: 2026-06-14T21:49:16.203Z +Found: 7 circular chain(s) — these MUST be fixed before modifying involved files. 1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts -2. 2) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts -3. 3) stores/attunementStore.ts > stores/combatStore.ts > stores/combat-descent-actions.ts -4. 4) stores/attunementStore.ts > stores/combatStore.ts > stores/combat-descent-actions.ts > stores/non-combat-room-actions.ts +2. 2) stores/combatStore.ts > stores/combat-actions.ts > stores/combat-room-enchantments.ts > stores/craftingStore.ts +3. 3) stores/combatStore.ts > stores/combat-actions.ts > stores/combat-room-enchantments.ts > stores/craftingStore.ts > stores/crafting-equipment-tick.ts +4. 4) stores/combatStore.ts > stores/combat-actions.ts > stores/combat-room-enchantments.ts > stores/craftingStore.ts > stores/pipelines/equipment-crafting.ts +5. 5) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts +6. 6) stores/attunementStore.ts > stores/combatStore.ts > stores/combat-descent-actions.ts +7. 7) stores/attunementStore.ts > stores/combatStore.ts > stores/combat-descent-actions.ts > stores/non-combat-room-actions.ts ## How to fix 1. Identify which import in the chain can be extracted to a shared types/utils file. diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 4731cab..6706809 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-14T19:56:35.928Z", + "generated": "2026-06-14T21:49:13.932Z", "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." }, @@ -245,6 +245,9 @@ "data/disciplines/elemental.ts": [ "types/disciplines.ts" ], + "data/disciplines/enchanter-combat.ts": [ + "types/disciplines.ts" + ], "data/disciplines/enchanter-special.ts": [ "types/disciplines.ts" ], @@ -265,6 +268,7 @@ "data/disciplines/elemental-regen-advanced.ts", "data/disciplines/elemental-regen.ts", "data/disciplines/elemental.ts", + "data/disciplines/enchanter-combat.ts", "data/disciplines/enchanter-special.ts", "data/disciplines/enchanter-spells.ts", "data/disciplines/enchanter-utility.ts", @@ -558,6 +562,7 @@ "stores/combat-damage.ts", "stores/combat-invocation.ts", "stores/combat-melee.ts", + "stores/combat-room-enchantments.ts", "stores/combat-state.types.ts", "stores/dot-runtime.ts", "stores/golem-combat-actions.ts", @@ -618,6 +623,14 @@ "utils/index.ts", "utils/spire-utils.ts" ], + "stores/combat-room-enchantments.ts": [ + "data/guardian-encounters.ts", + "effects/discipline-effects.ts", + "stores/combat-state.types.ts", + "stores/craftingStore.ts", + "types.ts", + "utils/room-enchantments-utils.ts" + ], "stores/combat-state.types.ts": [ "types.ts", "utils/invocation-utils.ts" @@ -983,6 +996,10 @@ "data/guardian-encounters.ts" ], "utils/result.ts": [], + "utils/room-enchantments-utils.ts": [ + "data/enchantments/special-effects.ts", + "types.ts" + ], "utils/room-utils.ts": [ "constants.ts", "data/guardian-encounters.ts", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 82e8c8d..05a1818 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -224,6 +224,7 @@ Mana-Loop/ │ │ │ │ ├── day30-blank-page.test.ts │ │ │ │ ├── design-validation-perk-gating.test.ts │ │ │ │ ├── discipline-deactivate-on-spire-entry.test.ts +│ │ │ │ ├── discipline-effects-reactivity.test.ts │ │ │ │ ├── discipline-math.test.ts │ │ │ │ ├── discipline-prerequisites.test.ts │ │ │ │ ├── discipline-reactivate-bug.test.ts diff --git a/src/lib/game/__tests__/discipline-effects-reactivity.test.ts b/src/lib/game/__tests__/discipline-effects-reactivity.test.ts new file mode 100644 index 0000000..b3a426f --- /dev/null +++ b/src/lib/game/__tests__/discipline-effects-reactivity.test.ts @@ -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>) { + 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); + }); +}); diff --git a/src/lib/game/effects/discipline-effects.ts b/src/lib/game/effects/discipline-effects.ts index 407131d..f4a47bb 100644 --- a/src/lib/game/effects/discipline-effects.ts +++ b/src/lib/game/effects/discipline-effects.ts @@ -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 } => !!entry.def); diff --git a/src/lib/game/hooks/useGameDerived.ts b/src/lib/game/hooks/useGameDerived.ts index 8e49bdf..5d7fa62 100644 --- a/src/lib/game/hooks/useGameDerived.ts +++ b/src/lib/game/hooks/useGameDerived.ts @@ -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;