fix: deactivate all disciplines when entering/exiting the Spire
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
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:
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user