// ─── Combat Actions ───────────────────────────────────────────────────────────── // Pure combat logic — no cross-store getState() calls. // All external data (signedPacts, etc.) is passed in as parameters. import { SPELLS_DEF, HOURS_PER_TICK } from '../constants'; import { getGuardianForFloor } from '../data/guardian-encounters'; import type { CombatStore, CombatState } from './combat-state.types'; import type { SpellState } from '../types'; import { getFloorMaxHP, getFloorElement, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils'; import { computeDisciplineEffects } from '../effects/discipline-effects'; import { ErrorCode } from '../utils/result'; /** * Create a default CombatTickResult for safe fallback on error. */ function makeDefaultCombatTickResult( rawMana: number, elements: Record, state: CombatState, ): CombatTickResult { return { rawMana, elements, logMessages: [], totalManaGathered: 0, currentFloor: state.currentFloor, floorHP: state.floorHP, floorMaxHP: state.floorMaxHP, maxFloorReached: state.maxFloorReached, castProgress: state.castProgress, equipmentSpellStates: state.equipmentSpellStates, }; } export interface CombatTickResult { rawMana: number; elements: Record; logMessages: string[]; totalManaGathered: number; currentFloor: number; floorHP: number; floorMaxHP: number; maxFloorReached: number; castProgress: number; equipmentSpellStates: CombatState['equipmentSpellStates']; } export function processCombatTick( get: () => CombatStore, set: (state: Partial) => void, rawMana: number, elements: Record, maxMana: number, attackSpeedMult: number, onFloorCleared: (floor: number, wasGuardian: boolean) => void, onDamageDealt: (damage: number) => { rawMana: number; elements: Record; modifiedDamage?: number; }, signedPacts: number[], ): CombatTickResult { const state = get(); const logMessages: string[] = []; let totalManaGathered = 0; if (state.currentAction !== 'climb') { return makeDefaultCombatTickResult(rawMana, elements, state); } const spellId = state.activeSpell; const spellDef = SPELLS_DEF[spellId]; if (!spellDef) { return makeDefaultCombatTickResult(rawMana, elements, state); } try { // Compute discipline bonuses once per tick const disciplineEffects = computeDisciplineEffects(); // Calculate cast speed (no skill bonus) const totalAttackSpeed = attackSpeedMult; const spellCastSpeed = spellDef.castSpeed || 1; const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed; let castProgress = (state.castProgress || 0) + progressPerTick; let floorHP = state.floorHP; let currentFloor = state.currentFloor; let floorMaxHP = state.floorMaxHP; // Process complete casts for active spell while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) { // Deduct spell cost const afterCost = deductSpellCost(spellDef.cost, rawMana, elements); rawMana = afterCost.rawMana; elements = afterCost.elements; // Calculate base damage const floorElement = getFloorElement(currentFloor); const damage = calcDamage( { skills: {}, signedPacts }, spellId, floorElement, disciplineEffects, ); // Let gameStore apply damage modifiers (executioner, berserker) const result = onDamageDealt(damage); rawMana = result.rawMana; elements = result.elements; const finalDamage = result.modifiedDamage || damage; // Apply damage floorHP = Math.max(0, floorHP - finalDamage); castProgress -= 1; // Check if floor is cleared if (floorHP <= 0) { const guardian = getGuardianForFloor(currentFloor); onFloorCleared(currentFloor, !!guardian); currentFloor = Math.min(currentFloor + 1, 100); floorMaxHP = getFloorMaxHP(currentFloor); floorHP = floorMaxHP; castProgress = 0; if (guardian) { logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`); } else if (currentFloor % 5 === 0) { logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`); } } } // Process equipment spell states (for progress bars in UI) const updatedEquipmentSpellStates = [...state.equipmentSpellStates]; for (let i = 0; i < updatedEquipmentSpellStates.length; i++) { const eSpell = updatedEquipmentSpellStates[i]; const eSpellDef = SPELLS_DEF[eSpell.spellId]; if (!eSpellDef) continue; // Calculate progress for this equipment spell const eSpellCastSpeed = eSpellDef.castSpeed || 1; const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed; let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick; // Process complete casts for equipment spells while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements)) { // Deduct cost const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements); rawMana = eAfterCost.rawMana; elements = eAfterCost.elements; // Calculate damage const eFloorElement = getFloorElement(currentFloor); const eDamage = calcDamage( { skills: {}, signedPacts }, eSpell.spellId, eFloorElement, disciplineEffects, ); const eResult = onDamageDealt(eDamage); rawMana = eResult.rawMana; elements = eResult.elements; const eFinalDamage = eResult.modifiedDamage || eDamage; floorHP = Math.max(0, floorHP - eFinalDamage); eCastProgress -= 1; if (floorHP <= 0) break; // Floor cleared, stop processing } // Update equipment spell state updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 }; } const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor); set({ currentFloor, floorHP, floorMaxHP: getFloorMaxHP(currentFloor), maxFloorReached: newMaxFloorReached, castProgress, equipmentSpellStates: updatedEquipmentSpellStates, }); return { rawMana, elements, logMessages, totalManaGathered, currentFloor, floorHP, floorMaxHP: getFloorMaxHP(currentFloor), maxFloorReached: newMaxFloorReached, castProgress, equipmentSpellStates: updatedEquipmentSpellStates, }; } catch (error) { // Return safe defaults on error — combat tick should never crash the game const errorMsg = error instanceof Error ? error.message : String(error); logMessages.push(`⚠️ Combat error: ${errorMsg}`); return makeDefaultCombatTickResult(rawMana, elements, state); } } // Helper function to create initial spells export function makeInitialSpells(spellsToKeep: string[] = []): Record { const startSpells: Record = { manaBolt: { learned: true, level: 1, studyProgress: 0 }, }; // Add kept spells for (const spellId of spellsToKeep) { startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 }; } return startSpells; }