feat: implement regular enemy defenses — armor, barrier, dodge (spec §5.2)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s

- Add applyEnemyDefenses() pipeline: dodge → barrier → armor for ALL enemies
- Add speed room + agile additive dodge (capped at 0.75, spec §4.5)
- Add mage barrier recharge per tick (spec §5.2)
- Add effectiveArmor support for armor_corrode debuff compatibility
- Pass enemy defense context via closure (no signature changes to onDamageDealt)
- Add 16 regression tests for defense mechanics
- All 921 tests pass (45 test files)
This commit is contained in:
2026-06-03 14:27:14 +02:00
parent 1b4e5cf5ac
commit 7c0e740226
4 changed files with 453 additions and 8 deletions
+92 -7
View File
@@ -6,6 +6,19 @@ 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';
// ─── 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: {
@@ -17,6 +30,7 @@ interface BuildCombatCallbacksParams {
guardianShieldMax: number;
guardianBarrier: number;
guardianBarrierMax: number;
currentRoom: { roomType: string; enemies: EnemyState[] };
};
};
effects: ComputedEffects;
@@ -26,27 +40,94 @@ interface BuildCombatCallbacksParams {
usePrestigeStore: { getState: () => { addDefeatedGuardian: (floor: number) => void } };
}
/** Speed-room bonus added to agile dodge chance (spec §4.5) */
const SPEED_ROOM_DODGE_BONUS = 0.20;
export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
const { ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore } = params;
const { ctx, effects, maxMana, useCombatStore, usePrestigeStore } = params;
const onFloorCleared = (floor: number, wasGuardian: boolean) => {
if (wasGuardian) {
const defeatedGuardian = getGuardianForFloor(floor);
addLog((defeatedGuardian?.name || 'Unknown') + ' defeated! Visit the Grimoire to sign a pact.');
params.addLog((defeatedGuardian?.name || 'Unknown') + ' defeated! Visit the Grimoire to sign a pact.');
usePrestigeStore.getState().addDefeatedGuardian(floor);
} else if (floor % 5 === 0) {
addLog('Floor ' + floor + ' cleared!');
params.addLog('Floor ' + floor + ' cleared!');
}
useCombatStore.setState({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
};
// Returns a function matching the processCombatTick onDamageDealt signature.
// The returned function closes over the current tick's rawMana/elements references.
const makeOnDamageDealt = (rawManaRef: () => number, elementsRef: () => Record<string, { current: number; max: number; unlocked: boolean }>) => {
/** Mage barrier recharge rate (spec §5.2): 5% per tick */
const MAGE_BARRIER_RECHARGE_RATE = 0.05;
/**
* 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 };
};
/**
* Apply regular enemy defenses: dodge → barrier → armor (spec §5.2).
* Returns modified damage, or 0 on dodge.
* This is the single defense pipeline used for ALL enemy hits (not just guardians).
*/
const applyEnemyDefenses = (
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
): number => {
if (!enemy) return dmg;
// 1. Dodge check (spec §5.2, §4.5)
let effectiveDodge = enemy.dodgeChance;
if (roomType === 'speed') {
// Agile + speed room: additive dodge bonus, capped at 0.75
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;
}
// 2. Barrier absorption (percentage, spec §5.2)
if (enemy.barrier && enemy.barrier > 0) {
dmg *= (1 - enemy.barrier);
}
// 3. Armor reduction — use effectiveArmor (after corrode) if available, else base armor (spec §5.2)
const armorValue = (enemy as EnemyState & { effectiveArmor?: number }).effectiveArmor ?? enemy.armor;
if (armorValue && armorValue > 0) {
dmg *= (1 - armorValue);
}
return dmg;
};
/**
* 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) => {
const rawMana = rawManaRef();
const elements = elementsRef();
let dmg = damage;
// Discipline specials (Executioner, Berserker) — before enemy defenses
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
dmg *= 2;
}
@@ -54,6 +135,10 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
dmg *= 1.5;
}
// Apply regular enemy defenses for ALL enemies (spec §5.2)
dmg = applyEnemyDefenses(dmg, defCtx.enemy, defCtx.roomType, addLog);
// Guardian-specific defensive pipeline (shield → barrier → health regen, spec §5.3)
const guardian = getGuardianForFloor(ctx.combat.currentFloor);
if (guardian && (guardian.shield || guardian.barrier || guardian.healthRegen)) {
let shield = ctx.combat.guardianShield;
@@ -95,5 +180,5 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) {
};
};
return { onFloorCleared, makeOnDamageDealt };
return { onFloorCleared, makeOnDamageDealt, applyMageBarrierRecharge };
}