fix: add curse amplification to applyEnemyDefenses (spec §6.3)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
The curse debuff was stored on enemies via dot-runtime.ts but the amplification was never applied to incoming damage. Added curse magnitude check in applyEnemyDefenses (combat-tick.ts) that multiplies incoming damage by (1 + magnitude) for each active curse effect. - Curse amplification applied BEFORE dodge/barrier/armse defenses - Multiple curse effects stack multiplicatively - Non-curse effects (burn, freeze, etc.) are ignored for amplification Also updated spire-combat-spec.md Known Gaps table to reflect: - Melee defense bypass fixed (issue #285) - Curse amplification now implemented (issue #286) Added 9 regression tests in curse-amplification.test.ts. All 957 tests pass (50 test files).
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { applyEnemyDefenses } from '../stores/pipelines/combat-tick';
|
||||
import type { EnemyState } from '../types';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeEnemy(overrides: Partial<EnemyState> = {}): EnemyState {
|
||||
return {
|
||||
id: 'test_enemy',
|
||||
name: 'Test Enemy',
|
||||
hp: 100,
|
||||
maxHP: 100,
|
||||
armor: 0,
|
||||
dodgeChance: 0,
|
||||
barrier: 0,
|
||||
element: 'fire',
|
||||
activeEffects: [],
|
||||
effectiveArmor: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CURSE AMPLIFICATION TESTS (spec §6.3, issue #286)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Curse amplification (spec §6.3)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset any global state if needed
|
||||
});
|
||||
|
||||
it('should not amplify damage when enemy has no curse effect', () => {
|
||||
const enemy = makeEnemy();
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, enemy, 'combat', (msg) => logs.push(msg));
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
|
||||
it('should amplify damage by curse magnitude (single curse)', () => {
|
||||
const enemy = makeEnemy({
|
||||
activeEffects: [
|
||||
{ type: 'curse', remainingDuration: 3, magnitude: 0.20, source: 'spell' },
|
||||
],
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, enemy, 'combat', (msg) => logs.push(msg));
|
||||
// 100 * (1 + 0.20) = 120
|
||||
expect(result).toBe(120);
|
||||
});
|
||||
|
||||
it('should stack multiple curse effects multiplicatively', () => {
|
||||
const enemy = makeEnemy({
|
||||
activeEffects: [
|
||||
{ type: 'curse', remainingDuration: 3, magnitude: 0.20, source: 'spell' },
|
||||
{ type: 'curse', remainingDuration: 2, magnitude: 0.15, source: 'spell' },
|
||||
],
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, enemy, 'combat', (msg) => logs.push(msg));
|
||||
// 100 * (1 + 0.20) * (1 + 0.15) = 100 * 1.20 * 1.15 = 138
|
||||
expect(result).toBe(138);
|
||||
});
|
||||
|
||||
it('should apply curse amplification before armor reduction', () => {
|
||||
const enemy = makeEnemy({
|
||||
armor: 0.5,
|
||||
effectiveArmor: 0.5,
|
||||
activeEffects: [
|
||||
{ type: 'curse', remainingDuration: 3, magnitude: 0.20, source: 'spell' },
|
||||
],
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, enemy, 'combat', (msg) => logs.push(msg));
|
||||
// Curse first: 100 * 1.20 = 120
|
||||
// Then armor: 120 * (1 - 0.5) = 60
|
||||
expect(result).toBe(60);
|
||||
});
|
||||
|
||||
it('should apply curse amplification before barrier absorption', () => {
|
||||
const enemy = makeEnemy({
|
||||
barrier: 0.5,
|
||||
activeEffects: [
|
||||
{ type: 'curse', remainingDuration: 3, magnitude: 0.20, source: 'spell' },
|
||||
],
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, enemy, 'combat', (msg) => logs.push(msg));
|
||||
// Curse first: 100 * 1.20 = 120
|
||||
// Then barrier: 120 * (1 - 0.5) = 60
|
||||
expect(result).toBe(60);
|
||||
});
|
||||
|
||||
it('should apply curse amplification before dodge check', () => {
|
||||
// Curse amplifies damage before dodge, but dodge still works
|
||||
const enemy = makeEnemy({
|
||||
dodgeChance: 0, // No dodge for deterministic test
|
||||
activeEffects: [
|
||||
{ type: 'curse', remainingDuration: 3, magnitude: 0.30, source: 'spell' },
|
||||
],
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, enemy, 'combat', (msg) => logs.push(msg));
|
||||
// Curse: 100 * 1.30 = 130, no dodge
|
||||
expect(result).toBe(130);
|
||||
});
|
||||
|
||||
it('should ignore non-curse effects when calculating amplification', () => {
|
||||
const enemy = makeEnemy({
|
||||
activeEffects: [
|
||||
{ type: 'burn', remainingDuration: 3, magnitude: 5, source: 'spell' },
|
||||
{ type: 'curse', remainingDuration: 3, magnitude: 0.25, source: 'spell' },
|
||||
{ type: 'freeze', remainingDuration: 2, magnitude: 0, source: 'spell' },
|
||||
{ type: 'armor_corrode', remainingDuration: 4, magnitude: 0.15, source: 'spell' },
|
||||
],
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, enemy, 'combat', (msg) => logs.push(msg));
|
||||
// Only curse applies: 100 * 1.25 = 125
|
||||
expect(result).toBe(125);
|
||||
});
|
||||
|
||||
it('should return damage unchanged for null enemy even with curse', () => {
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, null, 'combat', (msg) => logs.push(msg));
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
|
||||
it('should handle enemy with zero-magnitude curse (no amplification)', () => {
|
||||
const enemy = makeEnemy({
|
||||
activeEffects: [
|
||||
{ type: 'curse', remainingDuration: 3, magnitude: 0, source: 'spell' },
|
||||
],
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const result = applyEnemyDefenses(100, enemy, 'combat', (msg) => logs.push(msg));
|
||||
// 100 * (1 + 0) = 100
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,17 @@ export function applyEnemyDefenses(
|
||||
): 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') {
|
||||
|
||||
Reference in New Issue
Block a user