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
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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user