fix: deactivate all disciplines when entering/exiting the Spire
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s

- 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
This commit is contained in:
2026-06-10 11:41:25 +02:00
parent 076282caf3
commit 48eee17d43
10 changed files with 295 additions and 30 deletions
@@ -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);
});
});
@@ -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}`);
};
+3
View File
@@ -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<CombatStore>()(
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');
},
+17
View File
@@ -52,6 +52,7 @@ export interface DisciplineStoreState {
export interface DisciplineStoreActions {
activate: (id: string, gameStateOverrides?: { elements?: Record<string, ElementState>; rawMana?: number; signedPacts?: number[] }) => void;
deactivate: (id: string) => void;
deactivateAll: () => void;
processTick: (mana: { rawMana: number; elements: Record<string, ElementState> }) => {
rawMana: number;
elements: Record<string, ElementState>;
@@ -161,6 +162,22 @@ export const useDisciplineStore = create<DisciplineStore>()(
});
},
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 });
},
+6 -26
View File
@@ -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<GameCoordinatorStore>()(
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<GameCoordinatorStore>()(
)
);
/** Build pact element map and gross regen for the unified conversion system */
function buildConversionParams(
signedPacts: number[],
attunements: Record<string, { active: boolean; level: number }>,
): { pactElementMap: Record<number, string>; grossRegen: Record<string, number> } {
const pactElementMap: Record<number, string> = {};
for (const floor of signedPacts) {
const guardian = getGuardianForFloor(floor);
if (guardian?.element?.length) {
pactElementMap[floor] = guardian.element[0];
}
}
const grossRegen: Record<string, number> = {};
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 };
}
+27
View File
@@ -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<string, { active: boolean; level: number }>,
): { pactElementMap: Record<number, string>; grossRegen: Record<string, number> } {
const pactElementMap: Record<number, string> = {};
for (const floor of signedPacts) {
const guardian = getGuardianForFloor(floor);
if (guardian?.element?.length) {
pactElementMap[floor] = guardian.element[0];
}
}
const grossRegen: Record<string, number> = {};
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 };
}
+1
View File
@@ -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,