// ─── Combat Tick Callback Builder ───────────────────────────────────────────── // Extracts the large combat callback lambdas from gameStore.ts tick() // to keep the coordinator under the 400-line file limit. import { HOURS_PER_TICK } from '../../constants'; import { getGuardianForFloor } from '../../data/guardian-encounters'; import { hasSpecial, SPECIAL_EFFECTS } from '../../effects/special-effects'; import type { ComputedEffects } from '../../effects/upgrade-effects.types'; import type { EnemyState } from '../../types'; import type { CombatStore } from '../combat-state.types'; import { countdownGolemRoomDuration } from '../golem-combat-actions'; import { useAttunementStore } from '../attunementStore'; // ─── Enemy Defense Context ──────────────────────────────────────────────────── // Snapshot of the current tick's enemy defense state, captured once per tick // when makeOnDamageDealt is invoked. This avoids changing the onDamageDealt // callback signature across the entire call chain. export interface EnemyDefenseCtx { roomType: string; enemy: EnemyState | null; } // ─── Params ─────────────────────────────────────────────────────────────────── interface BuildCombatCallbacksParams { ctx: { combat: { floorHP: number; floorMaxHP: number; currentFloor: number; guardianShield: number; guardianShieldMax: number; guardianBarrier: number; guardianBarrierMax: number; currentRoom: { roomType: string; enemies: EnemyState[] }; }; }; effects: ComputedEffects; maxMana: number; addLog: (msg: string) => void; useCombatStore: { setState: (s: Partial) => void; getState: () => CombatStore }; usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void; defeatedGuardians: number[] } }; } /** Speed-room bonus added to agile dodge chance (spec §4.5) */ const SPEED_ROOM_DODGE_BONUS = 0.20; // ─── Standalone Enemy Defenses (for DoT/debuff pipeline) ───────────────────── /** * Apply regular enemy defenses: dodge → barrier → armor (spec §5.2). * Returns modified damage, or 0 on dodge. * Exported for use by the DoT/debuff tick processing system (spec §6.3). * * @param bypassArmor — if true, skip armor reduction entirely (spec §6.4, AC-13) * @param bypassBarrier — if true, skip barrier absorption (spec §6.4) */ export function applyEnemyDefenses( dmg: number, enemy: EnemyState | null, roomType: string, addLog: (msg: string) => void, bypassArmor?: boolean, bypassBarrier?: boolean, ): number { if (!enemy) return dmg; // 0. Curse amplification (spec §6.3) — amplifies all incoming damage let curseMult = 1; for (const effect of enemy.activeEffects) { if (effect.type === 'curse') { curseMult *= (1 + effect.magnitude); } } if (curseMult > 1) { dmg *= curseMult; } // 1. Dodge check (spec §5.2, §4.5) let effectiveDodge = enemy.dodgeChance; if (roomType === 'speed') { const hasAgile = enemy.name.toLowerCase().includes('agile'); if (hasAgile) { effectiveDodge = Math.min(0.75, enemy.dodgeChance + SPEED_ROOM_DODGE_BONUS); } } if (effectiveDodge > 0 && Math.random() < effectiveDodge) { addLog('Attack dodged!'); return 0; } // 2a. Shield pool absorption (flat HP one-time pool, spec §5.1) — skipped if bypassBarrier if (!bypassBarrier && enemy.shieldPool && enemy.shieldPool > 0) { const absorb = Math.min(enemy.shieldPool, dmg); enemy.shieldPool -= absorb; dmg -= absorb; } // 2b. Barrier absorption (percentage, spec §5.2) — skipped if bypassBarrier if (!bypassBarrier && enemy.barrier && enemy.barrier > 0) { dmg *= (1 - enemy.barrier); } // 3. Armor reduction — skipped if bypassArmor (spec §6.4, AC-13) if (!bypassArmor) { const armorValue = (enemy as EnemyState & { effectiveArmor?: number }).effectiveArmor ?? enemy.armor; if (armorValue && armorValue > 0) { dmg *= (1 - armorValue); } } return dmg; } export function buildCombatCallbacks(params: BuildCombatCallbacksParams) { const { ctx, effects, maxMana, useCombatStore, usePrestigeStore } = params; const onFloorCleared = (floor: number, wasGuardian: boolean) => { if (wasGuardian) { const defeatedGuardian = getGuardianForFloor(floor); params.addLog((defeatedGuardian?.name || 'Unknown') + ' defeated! Visit the Grimoire to sign a pact.'); usePrestigeStore.getState().addDefeatedGuardian(floor); // Auto-unlock Invoker when the first guardian (floor 10) is defeated if (floor === 10) { const prestigeState = usePrestigeStore.getState(); const unlocked = useAttunementStore.getState().unlockAttunement('invoker', prestigeState.defeatedGuardians); if (unlocked) { params.addLog('💜 The path of the Invoker is now available!'); } } } else if (floor % 5 === 0) { params.addLog('Floor ' + floor + ' cleared!'); } useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 }); // ── Golem room-duration countdown (spec §14) ────────────────────── const cs = useCombatStore.getState(); const activeGolems = cs.golemancy.activeGolems; const golemDesigns = cs.golemancy.golemDesigns; if (activeGolems.length > 0) { const result = countdownGolemRoomDuration(activeGolems, golemDesigns); if (result.logMessages.length > 0) { result.logMessages.forEach((msg) => params.addLog(msg)); } useCombatStore.setState({ golemancy: { ...cs.golemancy, activeGolems: result.remainingGolems }, }); } }; /** Mage barrier recharge rate (spec §5.2): 5% per tick */ const MAGE_BARRIER_RECHARGE_RATE = 0.05; const MAGE_BARRIER_MAX = 0.4; // maxBarrier from MODIFIER_CONFIG.mage /** * Apply mage barrier recharge (spec §5.2). * Returns recharged enemy copy, or null if not a mage enemy. */ const applyMageBarrierRecharge = (enemy: EnemyState | null): EnemyState | null => { if (!enemy || !enemy.barrier || enemy.barrier <= 0) return null; if (!enemy.name.startsWith('Mage')) return null; const recharged = enemy.barrier + MAGE_BARRIER_RECHARGE_RATE * HOURS_PER_TICK; return { ...enemy, barrier: recharged }; }; // Local reference to the module-level applyEnemyDefenses const defApply = applyEnemyDefenses; /** * Create the onDamageDealt callback for this tick. * Closes over the enemy defense context (captured once per tick from currentRoom). */ const makeOnDamageDealt = ( rawManaRef: () => number, elementsRef: () => Record, defCtx: EnemyDefenseCtx, addLog: (msg: string) => void, ) => { return (damage: number, skipSpecials?: boolean) => { const rawMana = rawManaRef(); const elements = elementsRef(); let dmg = damage; // Discipline specials (Executioner, Berserker) — before enemy defenses // Skipped for golem attacks (spec §9.4, D-04 fix) if (!skipSpecials) { // Executioner: per-enemy HP check (spec §4.4, D-15 fix) const executionerTarget = defCtx.enemy; if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && executionerTarget && executionerTarget.hp < executionerTarget.maxHP * 0.25) { dmg *= 2; } if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { dmg *= 1.5; } } // Apply regular enemy defenses for ALL enemies (spec §5.2) dmg = defApply(dmg, defCtx.enemy, defCtx.roomType, addLog); // Mage barrier recharge (spec §5.2) — after damage, recharge mage barrier on the enemy if (defCtx.enemy && defCtx.enemy.barrier && defCtx.enemy.barrier > 0 && defCtx.enemy.name.startsWith('Mage')) { const rechargedBarrier = Math.min( MAGE_BARRIER_MAX, defCtx.enemy.barrier + MAGE_BARRIER_RECHARGE_RATE * HOURS_PER_TICK, ); // Update enemy barrier on the store (mutates via reference) defCtx.enemy.barrier = rechargedBarrier; } // Guardian-specific defensive pipeline (spec §5.3, §11) const guardian = getGuardianForFloor(ctx.combat.currentFloor); if (guardian) { let shield = ctx.combat.guardianShield; const shieldMax = ctx.combat.guardianShieldMax; let barrier = ctx.combat.guardianBarrier; const barrierMax = ctx.combat.guardianBarrierMax; // Shield absorption (flat pool, per-hit) if (shield > 0 && dmg > 0) { const absorb = Math.min(shield, dmg); shield -= absorb; dmg -= absorb; } // Barrier reduction (percentage, per-hit) if (barrier > 0 && dmg > 0) { dmg *= (1 - barrier); } // Guardian armor reduction (spec §11, D-26 fix) if (guardian.armor && guardian.armor > 0 && dmg > 0) { dmg *= (1 - guardian.armor); } // Health regen reduces net damage (per-hit, already scaled by HOURS_PER_TICK) if (guardian.healthRegen && guardian.healthRegen > 0) { const healAmount = guardian.healthRegenIsPercent ? Math.floor(ctx.combat.floorMaxHP * guardian.healthRegen / 100 * HOURS_PER_TICK) : Math.floor(guardian.healthRegen * HOURS_PER_TICK); dmg -= healAmount; } useCombatStore.setState({ guardianShield: shield, guardianShieldMax: shieldMax, guardianBarrier: barrier, guardianBarrierMax: barrierMax, }); } return { rawMana, elements, modifiedDamage: dmg }; }; }; // ─── Guardian Regen (once per tick, spec §5.3, D-25 fix) ───────────────── /** * Apply guardian shield/barrier regen once per tick. * Must be called exactly once per combat tick, not per-damage-event. */ const applyGuardianRegen = () => { const guardian = getGuardianForFloor(ctx.combat.currentFloor); if (!guardian) return; let shield = ctx.combat.guardianShield; const shieldMax = ctx.combat.guardianShieldMax; let barrier = ctx.combat.guardianBarrier; const barrierMax = ctx.combat.guardianBarrierMax; let changed = false; if (guardian.shieldRegen && shield < shieldMax) { shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK); changed = true; } if (guardian.barrierRegen && barrier < barrierMax) { barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK); changed = true; } if (changed) { useCombatStore.setState({ guardianShield: shield, guardianShieldMax: shieldMax, guardianBarrier: barrier, guardianBarrierMax: barrierMax, }); } }; return { onFloorCleared, makeOnDamageDealt, applyMageBarrierRecharge, applyGuardianRegen }; }