refactor: make activate() read mana state from stores directly instead of requiring UI to pass gameState bag
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m28s

This commit is contained in:
2026-06-09 10:14:20 +02:00
parent 42053f41ac
commit c89d8fd2d8
5 changed files with 75 additions and 26 deletions
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useDisciplineStore } from '../stores/discipline-slice';
import { useManaStore } from '../stores/manaStore';
function resetDisciplineStore() {
useDisciplineStore.setState({
@@ -12,11 +13,14 @@ function resetDisciplineStore() {
});
}
describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
describe('DisciplineStore — reactivate after deactivate', () => {
beforeEach(resetDisciplineStore);
it('should reactivate a raw discipline after deactivate when rawMana is sufficient', () => {
// First activation succeeds because disciplineState is undefined (optimistic)
// Set up mana store with sufficient raw mana
useManaStore.setState({ rawMana: 1000, elements: {} });
// First activation succeeds (reads rawMana from mana store)
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
@@ -25,15 +29,19 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true);
// Reactivate WITH sufficient rawMana — should succeed
useDisciplineStore.getState().activate('raw-mastery', { rawMana: 1000, elements: {}, signedPacts: [] });
// Reactivate — reads rawMana=1000 from mana store, should succeed
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
});
it('should NOT reactivate a raw discipline when rawMana is insufficient', () => {
// Activate and build up XP
// Activate with sufficient mana
useManaStore.setState({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
// Build up XP via ticks
useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
const xp = useDisciplineStore.getState().disciplines['raw-mastery'].xp;
@@ -43,29 +51,33 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
useDisciplineStore.getState().deactivate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
// Reactivate with insufficient rawMana (0) — should fail
useDisciplineStore.getState().activate('raw-mastery', { rawMana: 0, elements: {}, signedPacts: [] });
// Set rawMana to 0 — reactivation should fail
useManaStore.setState({ rawMana: 0, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
});
it('should reactivate a discipline with elements after deactivating it', () => {
useDisciplineStore.getState().activate('attune-fire', {
useManaStore.setState({
rawMana: 0,
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
});
useDisciplineStore.getState().activate('attune-fire');
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
useDisciplineStore.getState().deactivate('attune-fire');
expect(useDisciplineStore.getState().activeIds).not.toContain('attune-fire');
useDisciplineStore.getState().activate('attune-fire', {
elements: { fire: { unlocked: true, current: 100, max: 100, baseMax: 100 } },
});
// Reactivate — reads elements from mana store
useDisciplineStore.getState().activate('attune-fire');
expect(useDisciplineStore.getState().activeIds).toContain('attune-fire');
expect(useDisciplineStore.getState().disciplines['attune-fire'].paused).toBe(false);
});
it('should reactivate after processTick auto-pauses due to no mana', () => {
useDisciplineStore.getState().activate('raw-mastery', { rawMana: 100, elements: {} });
useManaStore.setState({ rawMana: 100, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
// Tick with no mana — discipline auto-pauses
@@ -73,14 +85,16 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true);
// Reactivate with sufficient mana
useDisciplineStore.getState().activate('raw-mastery', { rawMana: 1000, elements: {} });
// Restore mana and reactivate
useManaStore.setState({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false);
});
it('should preserve XP when deactivating and reactivating', () => {
useDisciplineStore.getState().activate('raw-mastery', { rawMana: 1000, elements: {} });
useManaStore.setState({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
@@ -90,11 +104,30 @@ describe('DisciplineStore — reactivate after deactivate (bug #163)', () => {
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(3);
expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true);
useDisciplineStore.getState().activate('raw-mastery', { rawMana: 1000, elements: {} });
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(3);
useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} });
expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(4);
});
it('should use gameState overrides when explicitly provided (for test control)', () => {
// Even though mana store has no mana, explicit override should work
useManaStore.setState({ rawMana: 0, elements: {} });
// First activation: no state yet, so canProceed returns true (optimistic)
useDisciplineStore.getState().activate('raw-mastery');
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
useDisciplineStore.getState().deactivate('raw-mastery');
// Reactivate with explicit override — bypasses store reads
useDisciplineStore.getState().activate('raw-mastery', {
rawMana: 1000,
elements: {},
signedPacts: [],
});
expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery');
});
});
+18 -5
View File
@@ -22,6 +22,8 @@ import { enchanterSpecialDisciplines } from '../data/disciplines/enchanter-speci
import { fabricatorDisciplines } from '../data/disciplines/fabricator';
import { invokerDisciplines } from '../data/disciplines/invoker';
import { MAX_CONCURRENT_DISCIPLINES } from '../types/disciplines';
import { useManaStore } from './manaStore';
import { usePrestigeStore } from './prestigeStore';
const ALL_DISCIPLINES = [
@@ -48,7 +50,7 @@ export interface DisciplineStoreState {
}
export interface DisciplineStoreActions {
activate: (id: string, gameState?: { elements?: Record<string, ElementState>; rawMana?: number; signedPacts?: number[] }) => void;
activate: (id: string, gameStateOverrides?: { elements?: Record<string, ElementState>; rawMana?: number; signedPacts?: number[] }) => void;
deactivate: (id: string) => void;
processTick: (mana: { rawMana: number; elements: Record<string, ElementState> }) => {
rawMana: number;
@@ -73,7 +75,7 @@ export const useDisciplineStore = create<DisciplineStore>()(
processedPerks: [],
practicingCallbacks: null,
activate(id, gameState) {
activate(id, gameStateOverrides) {
set((s) => {
const def = DISCIPLINE_MAP[id];
if (!def) return s;
@@ -98,15 +100,26 @@ export const useDisciplineStore = create<DisciplineStore>()(
return d && !d.paused;
}).length;
if (nonPaused >= s.concurrentLimit) return s;
if (!canProceedDiscipline(def, existing, gameState)) return s;
// Resolve game state: use overrides if provided (for tests), otherwise read
// directly from the mana and prestige stores. This prevents the UI from needing
// to manually construct and pass a gameState bag — eliminating the class of bug
// where a missing field (e.g. rawMana) silently prevents reactivation.
const resolvedState = gameStateOverrides ?? {
rawMana: useManaStore.getState().rawMana,
elements: useManaStore.getState().elements,
signedPacts: usePrestigeStore.getState().signedPacts,
};
if (!canProceedDiscipline(def, existing, resolvedState)) return s;
// Check discipline prerequisites (requires field → discipline XP or mana type unlock)
const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES, gameState?.elements, gameState?.signedPacts);
const prereqCheck = checkDisciplinePrerequisites(def, s.disciplines, ALL_DISCIPLINES, resolvedState.elements, resolvedState.signedPacts);
if (!prereqCheck.canProceed) return s;
// For conversion disciplines: gate on having all source mana types unlocked
if (def.sourceManaTypes && def.sourceManaTypes.length > 0) {
const elements = gameState?.elements;
const elements = resolvedState.elements;
if (elements) {
for (const srcType of def.sourceManaTypes) {
if (srcType === 'raw') continue; // raw is always available