// ─── 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, EQUIPMENT_TYPES } from '../constants'; import { getGuardianForFloor } from '../data/guardian-encounters'; import type { CombatStore, CombatState } from './combat-state.types'; import type { SpellState, EnemyState, EquipmentInstance, FloorState } from '../types'; import { applyOnHitEffect, processDoTPhase } from './dot-runtime'; import type { RuntimeActiveGolem } from '../types'; import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcMeleeDamage, canAffordSpellCost, deductSpellCost } from '../utils'; import { computeDisciplineEffects } from '../effects/discipline-effects'; import { processGolemMaintenance, processGolemAttacks, processGolemManaRegen, } from './golem-combat-actions'; import { applyDamageToRoom } from './combat-damage'; // ─── Result Type ─────────────────────────────────────────────────────────────── /** * Create a default CombatTickResult for safe fallback on error. */ function makeDefaultCombatTickResult( rawMana: number, elements: Record, state: CombatState, activeGolems: RuntimeActiveGolem[], ): 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, activeGolems, meleeSwordProgress: state.meleeSwordProgress, currentRoom: state.currentRoom, }; } export interface CombatTickResult { rawMana: number; elements: Record; logMessages: string[]; totalManaGathered: number; currentFloor: number; floorHP: number; floorMaxHP: number; maxFloorReached: number; castProgress: number; equipmentSpellStates: CombatState['equipmentSpellStates']; activeGolems: RuntimeActiveGolem[]; meleeSwordProgress: Record; currentRoom: FloorState; } // ─── Main Combat Tick ────────────────────────────────────────────────────────── 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[], golemancyState: { activeGolems: RuntimeActiveGolem[] }, golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, applyEnemyDefenses: ( dmg: number, enemy: EnemyState | null, roomType: string, addLog: (msg: string) => void, bypassArmor?: boolean, bypassBarrier?: boolean, ) => number, equippedSwords?: Record, ): CombatTickResult { const state = get(); const logMessages: string[] = []; let totalManaGathered = 0; if (state.currentAction !== 'climb') { return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems); } try { // ─── Golem maintenance (spec §13) ────────────────────────────────────── const golemDesigns = state.golemancy.golemDesigns || {}; const maintenanceResult = processGolemMaintenance( golemancyState.activeGolems, golemDesigns, rawMana, elements, ); let activeGolems = maintenanceResult.maintainedGolems; rawMana = maintenanceResult.rawMana; elements = maintenanceResult.elements; logMessages.push(...maintenanceResult.logMessages); // ─── Golem mana regen (spec §12) ─────────────────────────────────────── activeGolems = processGolemManaRegen(activeGolems, golemDesigns); // Write maintained golems back immediately so tick state stays consistent set({ golemancy: { ...state.golemancy, activeGolems } }); const spellId = state.activeSpell; const spellDef = SPELLS_DEF[spellId]; let floorHP = state.floorHP; let currentFloor = state.currentFloor; let floorMaxHP = state.floorMaxHP; let castProgress = state.castProgress; let currentRoom = state.currentRoom; const updatedEquipmentSpellStates = [...state.equipmentSpellStates]; // ─── Spell casting (only when a valid spell is configured) ──────────────── if (spellDef) { const disciplineEffects = computeDisciplineEffects(); const totalAttackSpeed = attackSpeedMult; const spellCastSpeed = spellDef.castSpeed || 1; const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed; const isSpellAoe = !!spellDef.isAoe; castProgress = (castProgress || 0) + progressPerTick; // Process complete casts for active spell let safetyCounter = 0; const MAX_CASTS_PER_TICK = 100; while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) { const afterCost = deductSpellCost(spellDef.cost, rawMana, elements); rawMana = afterCost.rawMana; elements = afterCost.elements; const floorElement = getFloorElement(currentFloor); const baseDamage = calcDamage({ signedPacts }, spellId, undefined, disciplineEffects); const guardian = getGuardianForFloor(currentFloor); const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement]; const multiElemBonus = getMultiElementBonus(spellDef.elem, floorElems); const damage = baseDamage * multiElemBonus; const result = onDamageDealt(damage); rawMana = result.rawMana; elements = result.elements; const finalDamage = result.modifiedDamage || damage; if (!Number.isFinite(finalDamage)) { logMessages.push('⚠️ Combat stopped: invalid damage value'); break; } // Apply damage per-enemy (spec §3.2) const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe); floorHP = roomResult.floorHP; floorMaxHP = roomResult.floorMaxHP; currentRoom = get().currentRoom; castProgress -= 1; safetyCounter++; applyOnHitEffect(get, set, spellId, logMessages); currentRoom = get().currentRoom; if (roomResult.roomCleared) { const guardian = getGuardianForFloor(currentFloor); onFloorCleared(currentFloor, !!guardian); get().advanceRoomOrFloor(); const newState = get(); currentFloor = newState.currentFloor; floorMaxHP = newState.floorMaxHP; floorHP = newState.floorHP; currentRoom = newState.currentRoom; castProgress = 0; if (guardian) { logMessages.push(`⚔️ ${guardian.name} defeated!`); } else if (currentFloor % 5 === 0) { logMessages.push(`🗺️ Floor ${currentFloor - 1} cleared!`); } } } // Process equipment spell states for (let i = 0; i < updatedEquipmentSpellStates.length; i++) { const eSpell = updatedEquipmentSpellStates[i]; const eSpellDef = SPELLS_DEF[eSpell.spellId]; if (!eSpellDef) continue; const isESpellAoe = !!eSpellDef.isAoe; const eSpellCastSpeed = eSpellDef.castSpeed || 1; const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * attackSpeedMult; let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick; let eSafetyCounter = 0; const MAX_E_CASTS_PER_TICK = 100; while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements) && eSafetyCounter < MAX_E_CASTS_PER_TICK) { const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements); rawMana = eAfterCost.rawMana; elements = eAfterCost.elements; const eFloorElement = getFloorElement(currentFloor); const eBaseDamage = calcDamage({ signedPacts }, eSpell.spellId, undefined, disciplineEffects); const eGuardian = getGuardianForFloor(currentFloor); const eFloorElems = eGuardian && eGuardian.element.length > 0 ? eGuardian.element : [eFloorElement]; const eMultiElemBonus = getMultiElementBonus(eSpellDef.elem, eFloorElems); const eDamage = eBaseDamage * eMultiElemBonus; const eResult = onDamageDealt(eDamage); rawMana = eResult.rawMana; elements = eResult.elements; const eFinalDamage = eResult.modifiedDamage || eDamage; if (!Number.isFinite(eFinalDamage)) break; // Apply damage per-enemy (spec §3.2) const eRoomResult = applyDamageToRoom(get, set, eFinalDamage, isESpellAoe); floorHP = eRoomResult.floorHP; floorMaxHP = eRoomResult.floorMaxHP; currentRoom = get().currentRoom; eCastProgress -= 1; eSafetyCounter++; if (eRoomResult.roomCleared) { const eGuardian = getGuardianForFloor(currentFloor); onFloorCleared(currentFloor, !!eGuardian); get().advanceRoomOrFloor(); const newState = get(); currentFloor = newState.currentFloor; floorMaxHP = newState.floorMaxHP; floorHP = newState.floorHP; currentRoom = newState.currentRoom; eCastProgress = 0; if (eGuardian) { logMessages.push(`⚔️ ${eGuardian.name} defeated!`); } else if (currentFloor % 5 === 0) { logMessages.push(`🗺️ Floor ${currentFloor - 1} cleared!`); } break; } } updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 }; } } // ─── Melee sword attacks (spec §3.1, §4.3) ──────────────────────────── const updatedMeleeSwordProgress = { ...state.meleeSwordProgress }; const floorElement = getFloorElement(currentFloor); const guardian = getGuardianForFloor(currentFloor); const floorElems = guardian && guardian.element.length > 0 ? guardian.element : [floorElement]; if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) { for (const [instanceId, swordInstance] of Object.entries(equippedSwords)) { const swordType = EQUIPMENT_TYPES[swordInstance.typeId]; if (!swordType || !swordType.stats?.attackSpeed) continue; const swordAttackSpeed = swordType.stats.attackSpeed; const meleeProgressPerTick = HOURS_PER_TICK * swordAttackSpeed * attackSpeedMult; let meleeProgress = (updatedMeleeSwordProgress[instanceId] || 0) + meleeProgressPerTick; let meleeSafetyCounter = 0; while (meleeProgress >= 1 && meleeSafetyCounter < 100) { const meleeDamage = calcMeleeDamage(swordInstance as EquipmentInstance, swordType, floorElems[0]); // Get the current target enemy (lowest HP, matching focus-fire targeting in applyDamageToRoom) const currentRoomState = get().currentRoom; const livingEnemies = currentRoomState.enemies.filter(e => e.hp > 0); const targetEnemy = livingEnemies.length > 0 ? livingEnemies.reduce((lowest, e) => e.hp < lowest.hp ? e : lowest) : null; const finalMeleeDamage = applyEnemyDefenses(meleeDamage, targetEnemy, currentRoomState.roomType, (msg) => logMessages.push(msg)); if (!Number.isFinite(finalMeleeDamage)) break; // Apply melee damage per-enemy (spec §3.2, focus-fire) const meleeRoomResult = applyDamageToRoom(get, set, finalMeleeDamage, false); floorHP = meleeRoomResult.floorHP; floorMaxHP = meleeRoomResult.floorMaxHP; currentRoom = get().currentRoom; meleeProgress -= 1; meleeSafetyCounter++; if (meleeRoomResult.roomCleared) { const g = getGuardianForFloor(currentFloor); onFloorCleared(currentFloor, !!g); get().advanceRoomOrFloor(); const ns = get(); currentFloor = ns.currentFloor; floorMaxHP = ns.floorMaxHP; floorHP = ns.floorHP; currentRoom = ns.currentRoom; meleeProgress = 0; break; } } updatedMeleeSwordProgress[instanceId] = meleeProgress % 1; } } // ─── Golem attacks (spec §11) ─────────────────────────────────────────── if (activeGolems.length > 0 && floorHP > 0) { const golemResult = processGolemAttacks( activeGolems, golemDesigns, onDamageDealt, golemApplyDamageToRoom, ); rawMana = golemResult.rawMana; elements = golemResult.elements; activeGolems = golemResult.activeGolems; logMessages.push(...golemResult.logMessages); const postGolemState = get(); floorHP = postGolemState.floorHP; floorMaxHP = postGolemState.floorMaxHP; currentFloor = postGolemState.currentFloor; currentRoom = postGolemState.currentRoom; } // ─── DoT/Debuff tick processing (spec §6.3) ───────────────────────────── if (floorHP > 0) { processDoTPhase(get, set, applyEnemyDefenses, logMessages); // processDoTPhase writes per-enemy HP to currentRoom. // Recalculate floorHP from updated room state. const postDoTState = get(); currentRoom = postDoTState.currentRoom; floorHP = currentRoom.enemies.reduce((sum, e) => sum + e.hp, 0); set({ floorHP }); if (floorHP <= 0) { const guardian = getGuardianForFloor(currentFloor); onFloorCleared(currentFloor, !!guardian); get().advanceRoomOrFloor(); const newState = get(); currentFloor = newState.currentFloor; floorMaxHP = newState.floorMaxHP; floorHP = newState.floorHP; currentRoom = newState.currentRoom; } } const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor); return { rawMana, elements, logMessages, totalManaGathered, currentFloor, floorHP, floorMaxHP, maxFloorReached: newMaxFloorReached, castProgress, equipmentSpellStates: updatedEquipmentSpellStates, activeGolems, meleeSwordProgress: updatedMeleeSwordProgress, currentRoom, }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logMessages.push(`⚠️ Combat error: ${errorMsg}`); return makeDefaultCombatTickResult(rawMana, elements, state, golemancyState.activeGolems); } } // Helper function to create initial spells export function makeInitialSpells(spellsToKeep: string[] = []): Record { const startSpells: Record = { manaBolt: { learned: true, level: 1, studyProgress: 0 }, }; for (const spellId of spellsToKeep) { startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 }; } return startSpells; }