import { describe, it, expect, beforeEach, vi } from 'vitest'; import { processCombatTick, makeInitialSpells } from '../stores/combat-actions'; import { useCombatStore, makeInitialSpells as makeStoreInitialSpells } from '../stores/combatStore'; import { useManaStore, makeInitialElements } from '../stores/manaStore'; import { useGameStore } from '../stores/gameStore'; import { usePrestigeStore } from '../stores/prestigeStore'; import { useUIStore } from '../stores/uiStore'; import { useDisciplineStore } from '../stores/discipline-slice'; import { getFloorMaxHP } from '../utils'; import type { CombatTickResult } from '../stores/combat-actions'; // ─── Helpers ────────────────────────────────────────────────────────────────── function resetStores() { 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: makeInitialElements(500, {}) }); useCombatStore.setState({ currentFloor: 1, floorHP: getFloorMaxHP(1), floorMaxHP: getFloorMaxHP(1), maxFloorReached: 1, activeSpell: 'manaBolt', currentAction: 'climb', castProgress: 0, spireMode: false, currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: getFloorMaxHP(1), maxHP: getFloorMaxHP(1), armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] }, clearedFloors: {}, climbDirection: 'up', isDescending: false, startFloor: 1, exitFloor: 1, currentRoomIndex: 0, roomsPerFloor: 5, descentPeak: null, roomResetState: {}, clearedRooms: {}, isDescentComplete: false, golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 }, equipmentSpellStates: [], comboHitCount: 0, floorHitCount: 0, spells: makeStoreInitialSpells(), 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: [] }); } function golemApplyDamageToRoom(dmg: number): { floorHP: number; floorMaxHP: number; roomCleared: boolean } { const cs = useCombatStore.getState(); const newFloorHP = Math.max(0, cs.floorHP - dmg); return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: newFloorHP <= 0 }; } function runCombatTick(rawMana: number, elements: Record): CombatTickResult { return processCombatTick( () => useCombatStore.getState(), (partial) => useCombatStore.setState(partial), rawMana, elements, 1000, // maxMana 1, // attackSpeedMult vi.fn(), // onFloorCleared (dmg) => ({ rawMana, elements, modifiedDamage: dmg }), // onDamageDealt (no modifiers) [], // signedPacts { activeGolems: [] }, // golemancyState golemApplyDamageToRoom, (dmg: number) => dmg, // applyEnemyDefenses (passthrough for tests) ); } function processCombatTickDirect( get: () => ReturnType, set: (partial: Record) => void, rawMana: number, elements: Record, maxMana: number, attackSpeedMult: number, onFloorCleared: (floor: number, wasGuardian: boolean) => void, onDamageDealt: (dmg: number) => { rawMana: number; elements: Record; modifiedDamage?: number }, signedPacts: number[], ): CombatTickResult { return processCombatTick( get, set, rawMana, elements, maxMana, attackSpeedMult, onFloorCleared, onDamageDealt, signedPacts, { activeGolems: [] }, golemApplyDamageToRoom, (dmg: number) => dmg, // applyEnemyDefenses (passthrough for tests) ); } // ═══════════════════════════════════════════════════════════════════════════════ // COMBAT ACTIONS — processCombatTick // ═══════════════════════════════════════════════════════════════════════════════ describe('processCombatTick', () => { beforeEach(resetStores); describe('basic mechanics', () => { it('should return default result when currentAction is not climb', () => { useCombatStore.setState({ currentAction: 'meditate' }); const elements = makeInitialElements(500, {}); const result = runCombatTick(1000, elements); expect(result.currentFloor).toBe(1); expect(result.floorHP).toBe(getFloorMaxHP(1)); }); it('should return default result when spell is not found', () => { useCombatStore.setState({ activeSpell: 'nonexistentSpell' }); const elements = makeInitialElements(500, {}); const result = runCombatTick(1000, elements); expect(result.currentFloor).toBe(1); }); it('should progress cast bar on each tick', () => { const elements = makeInitialElements(500, {}); const result = runCombatTick(1000, elements); expect(result.castProgress).toBeGreaterThan(0); }); it('should reduce floor HP when cast completes', () => { // Set cast progress close to 1 so next cast completes useCombatStore.setState({ castProgress: 0.99 }); const elements = makeInitialElements(500, {}); const initialHP = useCombatStore.getState().floorHP; const result = runCombatTick(1000, elements); // Either HP decreased or floor advanced (if HP reached 0) expect(result.floorHP).toBeLessThanOrEqual(initialHP); }); }); describe('floor advancement', () => { it('should advance room when HP reaches 0 (not last room)', () => { // Set floor HP very low so it's cleared in one cast // currentRoomIndex=0, roomsPerFloor=5 → clears room 0, advances to room 1 useCombatStore.setState({ castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 0, roomsPerFloor: 5, currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: 1, maxHP: 100, armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] } }); const elements = makeInitialElements(500, {}); const result = runCombatTick(1000, elements); // Room advanced, floor stays the same expect(result.currentFloor).toBe(1); // floorHP should be the new room's enemy HP expect(result.floorHP).toBeGreaterThan(0); }); it('should advance floor when last room on floor is cleared', () => { // Set currentRoomIndex to last room so clearing it advances the floor useCombatStore.setState({ castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 4, roomsPerFloor: 5, currentRoom: { roomType: 'combat', enemies: [{ id: 'enemy', name: 'Test Enemy', hp: 1, maxHP: 100, armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0 }] } }); const elements = makeInitialElements(500, {}); const result = runCombatTick(1000, elements); expect(result.currentFloor).toBe(2); expect(result.floorHP).toBeGreaterThan(0); }); it('should update maxFloorReached when advancing', () => { useCombatStore.setState({ floorHP: 1, castProgress: 0.99, maxFloorReached: 5 }); const elements = makeInitialElements(500, {}); const result = runCombatTick(1000, elements); expect(result.maxFloorReached).toBeGreaterThanOrEqual(5); }); it('should not advance beyond floor 100', () => { useCombatStore.setState({ currentFloor: 100, floorHP: 1, castProgress: 0.99 }); const elements = makeInitialElements(500, {}); const result = runCombatTick(1000, elements); expect(result.currentFloor).toBeLessThanOrEqual(100); }); }); describe('mana cost', () => { it('should deduct raw mana when spell costs raw mana', () => { const elements = makeInitialElements(500, {}); const startMana = 1000; const result = runCombatTick(startMana, elements); // manaBolt costs raw mana, so after casting, rawMana should decrease expect(result.rawMana).toBeLessThanOrEqual(startMana); }); it('should not cast when insufficient mana', () => { const elements = makeInitialElements(500, {}); const state = useCombatStore.getState(); // With 0 mana and 0 progress, no cast should complete const result = processCombatTickDirect( () => state, () => {}, 0, // no mana elements, 1000, 1, vi.fn(), (dmg) => ({ rawMana: 0, elements, modifiedDamage: dmg }), [], ); expect(result.rawMana).toBe(0); }); }); describe('equipment spell states', () => { it('should initialize empty equipmentSpellStates', () => { const elements = makeInitialElements(500, {}); const result = runCombatTick(1000, elements); expect(result.equipmentSpellStates).toBeDefined(); }); it('should not error with empty equipment spell states', () => { useCombatStore.setState({ equipmentSpellStates: [] }); const elements = makeInitialElements(500, {}); expect(() => runCombatTick(1000, elements)).not.toThrow(); }); }); describe('error handling', () => { it('should return safe defaults when spell def is missing (early return path)', () => { // When the spell definition is not found, processCombatTick returns // a default result without crashing — this exercises the early return // path that acts as a safety net. useCombatStore.setState({ activeSpell: 'totallyInvalidSpell' }); const elements = makeInitialElements(500, {}); const result = processCombatTickDirect( () => useCombatStore.getState(), () => {}, 1000, elements, 1000, 1, vi.fn(), (dmg) => ({ rawMana: 1000, elements, modifiedDamage: dmg }), [], ); expect(result).toBeDefined(); expect(result.rawMana).toBe(1000); expect(result.logMessages).toBeDefined(); }); it('should not throw when onDamageDealt callback throws', () => { // The try/catch in processCombatTick should catch errors from callbacks // and return safe defaults instead of crashing const elements = makeInitialElements(500, {}); useCombatStore.setState({ castProgress: 0.99 }); expect(() => { processCombatTickDirect( () => useCombatStore.getState(), () => {}, 1000, elements, 1000, 1, vi.fn(), (_dmg) => { throw new Error('damage callback error'); }, [], ); }).not.toThrow(); }); }); describe('helper: makeInitialSpells', () => { it('should create manaBolt as default spell', () => { const spells = makeInitialSpells(); expect(spells.manaBolt).toBeDefined(); expect(spells.manaBolt.learned).toBe(true); expect(spells.manaBolt.level).toBe(1); }); it('should keep additional spells when specified', () => { const spells = makeInitialSpells(['fireball']); expect(spells.manaBolt).toBeDefined(); expect(spells.fireball).toBeDefined(); expect(spells.fireball.learned).toBe(true); }); it('should not duplicate manaBolt when included in keep list', () => { const spells = makeInitialSpells(['manaBolt']); expect(Object.keys(spells).length).toBe(1); }); }); });