93ffa0768b
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
- Fix Fabricator golem-2 capped perk interval from 250 to 500 (spec match) - Update golem-1 description to 'Unlock golem summoning' (spec match)
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
// ─── 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<CombatStore>) => 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<string, { current: number; max: number; unlocked: boolean }>,
|
|
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 };
|
|
}
|