From 48eee17d43c1c2ce83b56ceb4e9b0bd066153b88 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 10 Jun 2026 11:41:25 +0200 Subject: [PATCH] fix: deactivate all disciplines when entering/exiting the Spire - Add deactivateAll() action to discipline-slice.ts - Call deactivateAll() in createEnterSpireMode() (combat-descent-actions.ts) - Add spireMode guard in gameStore.ts tick() to skip discipline processTick - Call deactivateAll() in exitSpireMode() (combatStore.ts) as safety measure - Extract buildConversionParams to utils/conversion-params.ts to keep gameStore.ts under 400 lines - Add regression tests (5 tests, all 1136 passing) Fixes #347 --- docs/circular-deps.txt | 7 +- docs/dependency-graph.json | 3 +- docs/project-structure.txt | 2 + ...scipline-deactivate-on-spire-entry.test.ts | 229 ++++++++++++++++++ src/lib/game/stores/combat-descent-actions.ts | 4 + src/lib/game/stores/combatStore.ts | 3 + src/lib/game/stores/discipline-slice.ts | 17 ++ src/lib/game/stores/gameStore.ts | 32 +-- src/lib/game/utils/conversion-params.ts | 27 +++ src/lib/game/utils/index.ts | 1 + 10 files changed, 295 insertions(+), 30 deletions(-) create mode 100644 src/lib/game/__tests__/discipline-deactivate-on-spire-entry.test.ts create mode 100644 src/lib/game/utils/conversion-params.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index ec39c43..474e67f 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,9 +1,10 @@ # Circular Dependencies -Generated: 2026-06-10T08:50:57.213Z -Found: 2 circular chain(s) — these MUST be fixed before modifying involved files. +Generated: 2026-06-10T09:19:26.381Z +Found: 3 circular chain(s) — these MUST be fixed before modifying involved files. 1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts -2. 2) stores/combatStore.ts > stores/combat-descent-actions.ts > stores/attunementStore.ts +2. 2) stores/attunementStore.ts > stores/combatStore.ts > stores/combat-descent-actions.ts +3. 3) 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 ea34726..07dd8c3 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-10T08:50:54.740Z", + "generated": "2026-06-10T09:19:24.358Z", "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." }, @@ -529,6 +529,7 @@ "effects/discipline-effects.ts", "effects/special-effects.ts", "effects/upgrade-effects.ts", + "stores/attunementStore.ts", "stores/combatStore.ts", "stores/gameStore.ts", "stores/manaStore.ts", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index c8d2467..52b9d03 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -213,6 +213,7 @@ Mana-Loop/ │ │ │ │ ├── cross-module-prestige-discipline.test.ts │ │ │ │ ├── curse-amplification.test.ts │ │ │ │ ├── design-validation-perk-gating.test.ts +│ │ │ │ ├── discipline-deactivate-on-spire-entry.test.ts │ │ │ │ ├── discipline-math.test.ts │ │ │ │ ├── discipline-prerequisites.test.ts │ │ │ │ ├── discipline-reactivate-bug.test.ts @@ -410,6 +411,7 @@ Mana-Loop/ │ │ │ ├── utils/ │ │ │ │ ├── activity-log.ts │ │ │ │ ├── combat-utils.ts +│ │ │ │ ├── conversion-params.ts │ │ │ │ ├── conversion-rates.ts │ │ │ │ ├── discipline-math.ts │ │ │ │ ├── element-cap-bonus.ts diff --git a/src/lib/game/__tests__/discipline-deactivate-on-spire-entry.test.ts b/src/lib/game/__tests__/discipline-deactivate-on-spire-entry.test.ts new file mode 100644 index 0000000..e82e43b --- /dev/null +++ b/src/lib/game/__tests__/discipline-deactivate-on-spire-entry.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useDisciplineStore } from '../stores/discipline-slice'; +import { useCombatStore } from '../stores/combatStore'; +import { useGameStore } from '../stores/gameStore'; +import { useManaStore } from '../stores/manaStore'; +import { usePrestigeStore } from '../stores/prestigeStore'; +import { useUIStore } from '../stores/uiStore'; +import { useAttunementStore } from '../stores/attunementStore'; +import { useCraftingStore } from '../stores/craftingStore'; + +function resetAllStores() { + useUIStore.setState({ + paused: false, + gameOver: false, + victory: false, + logs: [], + }); + + useGameStore.setState({ + day: 1, + hour: 0, + incursionStrength: 0, + containmentWards: 0, + initialized: true, + }); + + useManaStore.setState({ + rawMana: 1000, + meditateTicks: 0, + totalManaGathered: 0, + elements: { + transference: { unlocked: true, current: 50, max: 50, baseMax: 50 }, + }, + }); + + useCombatStore.setState({ + currentFloor: 1, + floorHP: 0, + floorMaxHP: 0, + maxFloorReached: 1, + activeSpell: 'manaBolt', + currentAction: 'meditate', + castProgress: 0, + spireMode: false, + currentRoom: { roomType: 'combat', enemies: [] }, + clearedFloors: {}, + climbDirection: null, + isDescending: false, + startFloor: 1, + exitFloor: 1, + currentRoomIndex: 0, + roomsPerFloor: 5, + runId: 0, + descentPeak: null, + roomResetState: {}, + clearedRooms: {}, + isDescentComplete: false, + golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] }, + equipmentSpellStates: [], + weaponCastProgress: {}, + comboHitCount: 0, + floorHitCount: 0, + spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } }, + activityLog: [], + achievements: { unlocked: [], progress: {} }, + totalSpellsCast: 0, + totalDamageDealt: 0, + totalCraftsCompleted: 0, + }); + + usePrestigeStore.setState({ + loopCount: 0, + insight: 0, + totalInsight: 0, + loopInsight: 0, + prestigeUpgrades: {}, + pactSlots: 1, + defeatedGuardians: [], + signedPacts: [], + signedPactDetails: {}, + pactRitualFloor: null, + pactRitualProgress: 0, + }); + + useDisciplineStore.setState({ + disciplines: {}, + activeIds: [], + concurrentLimit: 1, + totalXP: 0, + processedPerks: [], + practicingCallbacks: null, + }); + + useAttunementStore.setState({ + attunements: { + enchanter: { active: true, level: 1, xp: 0 }, + }, + }); + + useCraftingStore.setState({ + designProgress: null, + designProgress2: null, + preparationProgress: null, + applicationProgress: null, + equipmentCraftingProgress: null, + enchantmentDesigns: [], + unlockedEffects: [], + equippedInstances: {}, + equipmentInstances: {}, + lootInventory: { materials: {}, blueprints: [] }, + enchantmentSelection: { + selectedEquipmentType: null, + selectedEffects: [], + designName: '', + selectedDesign: null, + selectedEquipmentInstance: null, + }, + lastError: null, + }); +} + +describe('Issue #347 — disciplines deactivate on spire entry', () => { + beforeEach(resetAllStores); + + it('should deactivate all active disciplines when entering the Spire', () => { + // Set up sufficient mana and activate a discipline + useManaStore.setState({ rawMana: 1000, elements: {} }); + + // Activate Raw Mana Mastery + useDisciplineStore.getState().activate('raw-mastery'); + expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); + expect(useDisciplineStore.getState().activeIds.length).toBe(1); + + // Verify discipline is draining mana / accumulating XP + useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBeGreaterThan(0); + + // Enter the Spire + useCombatStore.getState().enterSpireMode(); + + // ASSERT: All disciplines should be deactivated + expect(useDisciplineStore.getState().activeIds.length).toBe(0); + }); + + it('should not accrue discipline XP during spire mode', () => { + useManaStore.setState({ rawMana: 1000, elements: {} }); + + // Activate and build up some XP + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); + useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); + const xpBefore = useDisciplineStore.getState().disciplines['raw-mastery'].xp; + expect(xpBefore).toBeGreaterThan(0); + + // Enter the Spire + useCombatStore.getState().enterSpireMode(); + + // Confirm spire mode is active + expect(useCombatStore.getState().spireMode).toBe(true); + + // Tick the game — discipline should NOT accrue XP because spireMode guard skips processTick + useGameStore.getState().tick(); + useGameStore.getState().tick(); + useGameStore.getState().tick(); + + // XP should remain unchanged from before entering the spire + expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(xpBefore); + expect(useDisciplineStore.getState().activeIds.length).toBe(0); + }); + + it('should deactivate all disciplines on spire exit as a safety measure', () => { + useManaStore.setState({ rawMana: 1000, elements: {} }); + + // Activate a discipline before entering + useDisciplineStore.getState().activate('raw-mastery'); + + // Enter spire (deactivates disciplines) + useCombatStore.getState().enterSpireMode(); + expect(useCombatStore.getState().spireMode).toBe(true); + expect(useDisciplineStore.getState().activeIds.length).toBe(0); + + // Simulate a discipline somehow reactivated during spire (edge case) + useDisciplineStore.getState().disciplines['raw-mastery'] = { id: 'raw-mastery', xp: 0, paused: false }; + useDisciplineStore.setState({ activeIds: ['raw-mastery'] }); + expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); + + // Exit spire — should still clean up disciplines + useCombatStore.getState().exitSpireMode(); + expect(useDisciplineStore.getState().activeIds.length).toBe(0); + expect(useCombatStore.getState().spireMode).toBe(false); + }); + + it('deactivateAll() should preserve XP and pause state', () => { + useManaStore.setState({ rawMana: 1000, elements: {} }); + + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); + useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); + const xpBefore = useDisciplineStore.getState().disciplines['raw-mastery'].xp; + + // Call deactivateAll directly + useDisciplineStore.getState().deactivateAll(); + + expect(useDisciplineStore.getState().activeIds.length).toBe(0); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(xpBefore); + expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true); + }); + + it('deactivateAll() should work with multiple active disciplines', () => { + useManaStore.setState({ + rawMana: 1000, + elements: { + transference: { unlocked: true, current: 100, max: 100, baseMax: 100 }, + }, + }); + + // Temporarily raise concurrent limit for this test + useDisciplineStore.setState({ concurrentLimit: 3 }); + + useDisciplineStore.getState().activate('raw-mastery'); + useDisciplineStore.getState().activate('attune-transference'); + + expect(useDisciplineStore.getState().activeIds.length).toBe(2); + + useDisciplineStore.getState().deactivateAll(); + + expect(useDisciplineStore.getState().activeIds.length).toBe(0); + }); +}); diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts index 7b67496..ea747d6 100644 --- a/src/lib/game/stores/combat-descent-actions.ts +++ b/src/lib/game/stores/combat-descent-actions.ts @@ -10,6 +10,7 @@ import { useManaStore } from './manaStore'; import { summonGolemsOnRoomEntry } from './golem-combat-actions'; import { computeDisciplineEffects } from '../effects/discipline-effects'; import { useAttunementStore } from './attunementStore'; +import { useDisciplineStore } from './discipline-slice'; import { onEnterLibraryRoom, onEnterRecoveryRoom, @@ -278,6 +279,9 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) { golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: get().golemancy?.golemDesigns ?? {}, golemLoadout: [] }, }); + // Deactivate all active disciplines when entering the Spire + useDisciplineStore.getState().deactivateAll(); + get().addActivityLog('floor_transition', `Entered the Spire at Floor ${startFloor}`); }; diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index fa5746d..46b9f7a 100644 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -17,6 +17,7 @@ import { import { onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom, } from './non-combat-room-actions'; +import { useDisciplineStore } from './discipline-slice'; import { addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry, } from './golemancy-actions'; @@ -210,6 +211,8 @@ export const useCombatStore = create()( maxFloorReached: Math.max(s.maxFloorReached, 1), golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] }, }); + // Deactivate all disciplines on spire exit for safety + useDisciplineStore.getState().deactivateAll(); get().addActivityLog('floor_transition', 'Exited the Spire'); }, diff --git a/src/lib/game/stores/discipline-slice.ts b/src/lib/game/stores/discipline-slice.ts index 92b1c55..9041f4d 100644 --- a/src/lib/game/stores/discipline-slice.ts +++ b/src/lib/game/stores/discipline-slice.ts @@ -52,6 +52,7 @@ export interface DisciplineStoreState { export interface DisciplineStoreActions { activate: (id: string, gameStateOverrides?: { elements?: Record; rawMana?: number; signedPacts?: number[] }) => void; deactivate: (id: string) => void; + deactivateAll: () => void; processTick: (mana: { rawMana: number; elements: Record }) => { rawMana: number; elements: Record; @@ -161,6 +162,22 @@ export const useDisciplineStore = create()( }); }, + deactivateAll() { + set((s) => { + const newDisciplines = { ...s.disciplines }; + for (const id of s.activeIds) { + if (newDisciplines[id]) { + newDisciplines[id] = { ...newDisciplines[id], paused: true }; + } + } + get().practicingCallbacks?.onStopPracticing?.(); + return { + activeIds: [], + disciplines: newDisciplines, + }; + }); + }, + setPracticingCallbacks(callbacks) { set({ practicingCallbacks: callbacks }); }, diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index b76d8cc..e2dbb2c 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -19,7 +19,6 @@ import { useCombatStore } from './combatStore'; import { useAttunementStore } from './attunementStore'; import { useCraftingStore } from './craftingStore'; import { useDisciplineStore } from './discipline-slice'; -import { ATTUNEMENTS_DEF } from '../data/attunements'; import { createResetGame, createGatherMana } from './gameActions'; import { createSafeStorage } from '../utils/safe-persist'; import { createStartNewLoop } from './gameLoopActions'; @@ -27,6 +26,7 @@ import { buildTickContext, applyTickWrites } from './tick-pipeline'; import { processEnchantingTicks } from './pipelines/enchanting-tick'; import { buildGolemCombatPipeline } from './pipelines/golem-combat'; import { getGuardianForFloor } from '../data/guardian-encounters'; +import { buildConversionParams } from '../utils/conversion-params'; import type { TickContext, TickWrites } from './tick-pipeline'; import type { GameCoordinatorState } from './gameStore.types'; @@ -228,7 +228,10 @@ export const useGameStore = create()( usePrestigeStore.getState().completePactRitual(addLog); } - const dr = useDisciplineStore.getState().processTick({ rawMana, elements }); + // Skip discipline processing during spire runs (defense-in-depth) + const dr = ctx.combat.spireMode + ? { rawMana, elements, unlockedEffects: [], unlockedRecipes: [], autoPausedNames: [] } + : useDisciplineStore.getState().processTick({ rawMana, elements }); rawMana = dr.rawMana; elements = dr.elements; rawMana = Math.min(rawMana, computeMaxMana({ prestigeUpgrades: ctx.prestige.prestigeUpgrades }, undefined, computeDisciplineEffects())); @@ -373,27 +376,4 @@ export const useGameStore = create()( ) ); -/** Build pact element map and gross regen for the unified conversion system */ -function buildConversionParams( - signedPacts: number[], - attunements: Record, -): { pactElementMap: Record; grossRegen: Record } { - const pactElementMap: Record = {}; - for (const floor of signedPacts) { - const guardian = getGuardianForFloor(floor); - if (guardian?.element?.length) { - pactElementMap[floor] = guardian.element[0]; - } - } - const grossRegen: Record = {}; - for (const [id, state] of Object.entries(attunements)) { - if (!state.active) continue; - const def = ATTUNEMENTS_DEF[id]; - if (def?.primaryManaType && def.rawManaRegen) { - const levelMult = Math.pow(1.5, (state.level || 1) - 1); - grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0) - + def.rawManaRegen * levelMult; - } - } - return { pactElementMap, grossRegen }; -} + diff --git a/src/lib/game/utils/conversion-params.ts b/src/lib/game/utils/conversion-params.ts new file mode 100644 index 0000000..5297f68 --- /dev/null +++ b/src/lib/game/utils/conversion-params.ts @@ -0,0 +1,27 @@ +import { ATTUNEMENTS_DEF } from '../data/attunements'; +import { getGuardianForFloor } from '../data/guardian-encounters'; + +/** Build pact element map and gross regen for the unified conversion system */ +export function buildConversionParams( + signedPacts: number[], + attunements: Record, +): { pactElementMap: Record; grossRegen: Record } { + const pactElementMap: Record = {}; + for (const floor of signedPacts) { + const guardian = getGuardianForFloor(floor); + if (guardian?.element?.length) { + pactElementMap[floor] = guardian.element[0]; + } + } + const grossRegen: Record = {}; + for (const [id, state] of Object.entries(attunements)) { + if (!state.active) continue; + const def = ATTUNEMENTS_DEF[id]; + if (def?.primaryManaType && def.rawManaRegen) { + const levelMult = Math.pow(1.5, (state.level || 1) - 1); + grossRegen[def.primaryManaType] = (grossRegen[def.primaryManaType] || 0) + + def.rawManaRegen * levelMult; + } + } + return { pactElementMap, grossRegen }; +} diff --git a/src/lib/game/utils/index.ts b/src/lib/game/utils/index.ts index 91fd4c1..eef29eb 100644 --- a/src/lib/game/utils/index.ts +++ b/src/lib/game/utils/index.ts @@ -6,6 +6,7 @@ export { createSafeStorage } from './safe-persist'; export { ok, okVoid, fail, failTyped, unwrapOr, isErrorCode, ErrorCode } from './result'; export type { Result, ErrorCodeType } from './result'; export { getFloorMaxHP, getFloorElement, getFloorElements } from './floor-utils'; +export { buildConversionParams } from './conversion-params'; export { computeMaxMana, computeRegen,