fix: add curse amplification to applyEnemyDefenses (spec §6.3)
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:
2026-06-06 17:46:39 +02:00
parent 325949cc5f
commit bd15df85ff
6 changed files with 158 additions and 7 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-06-06T14:50:40.350Z Generated: 2026-06-06T15:33:46.661Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-06-06T14:50:38.455Z", "generated": "2026-06-06T15:33:44.741Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
}, },
+1
View File
@@ -215,6 +215,7 @@ Mana-Loop/
│ │ │ │ ├── cross-module-helpers.ts │ │ │ │ ├── cross-module-helpers.ts
│ │ │ │ ├── cross-module-lifecycle-consistency.test.ts │ │ │ │ ├── cross-module-lifecycle-consistency.test.ts
│ │ │ │ ├── cross-module-prestige-discipline.test.ts │ │ │ │ ├── cross-module-prestige-discipline.test.ts
│ │ │ │ ├── curse-amplification.test.ts
│ │ │ │ ├── discipline-math.test.ts │ │ │ │ ├── discipline-math.test.ts
│ │ │ │ ├── discipline-prerequisites.test.ts │ │ │ │ ├── discipline-prerequisites.test.ts
│ │ │ │ ├── discipline-reactivate-bug.test.ts │ │ │ │ ├── discipline-reactivate-bug.test.ts
+5 -5
View File
@@ -539,14 +539,14 @@ They are **in scope for the implementation this spec describes**:
| Feature | Where Defined | Status | This Spec's Requirement | | Feature | Where Defined | Status | This Spec's Requirement |
|---|---|---|---| |---|---|---|---|
| Enemy armor reduction | `EnemyState.armor`, `MODIFIER_CONFIG.armored` | **Implemented** (spells/DoTs only; melee bypasses — see issue #285) | Implement in `onDamageDealt` §5.2 | | Enemy armor reduction | `EnemyState.armor`, `MODIFIER_CONFIG.armored` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 |
| Enemy barrier absorption | `EnemyState.barrier`, `MODIFIER_CONFIG.mage/shield` | **Implemented** (spells/DoTs only; melee bypasses — see issue #285) | Implement in `onDamageDealt` §5.2 | | Enemy barrier absorption | `EnemyState.barrier`, `MODIFIER_CONFIG.mage/shield` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 |
| Enemy dodge roll | `EnemyState.dodgeChance`, `MODIFIER_CONFIG.agile` | **Implemented** (spells/DoTs only; melee bypasses — see issue #285) | Implement in `onDamageDealt` §5.2 | | Enemy dodge roll | `EnemyState.dodgeChance`, `MODIFIER_CONFIG.agile` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 |
| Mage barrier recharge | `MODIFIER_CONFIG.mage.barrierRechargeRate` | Data-only | Tick in `onDamageDealt` §5.2 | | Mage barrier recharge | `MODIFIER_CONFIG.mage.barrierRechargeRate` | Data-only | Tick in `onDamageDealt` §5.2 |
| Guardian armor | `GuardianDef.armor` | Data-only | Add check to guardian pipeline §5.3 | | Guardian armor | `GuardianDef.armor` | Data-only | Add check to guardian pipeline §5.3 |
| DoT / debuff system | Spell/enchantment type defs | **Implemented**`dot-runtime.ts` complete and wired into combat tick | Verified working; curse amplification needs investigation (see issue #286) | | DoT / debuff system | Spell/enchantment type defs | **Implemented**`dot-runtime.ts` complete and wired into combat tick; curse amplification added (issue #286) | Verified working |
| Golemancy combat | Full golem data exists | Disconnected | Implement per §9 | | Golemancy combat | Full golem data exists | Disconnected | Implement per §9 |
| Sword melee attacks | Weapon type exists | **Partial** — meleeProgress exists but bypasses enemy defenses (see issue #285) | Add `meleeProgress` per §3.1 | | Sword melee attacks | Weapon type exists | **Implemented** — meleeProgress with enemy defense application (issue #285) | Add `meleeProgress` per §3.1 |
| AoE target distribution | `SpellDefinition.aoe` flag | Partial | Implement per §3.2 | | AoE target distribution | `SpellDefinition.aoe` flag | Partial | Implement per §3.2 |
| `elemMasteryBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now | | `elemMasteryBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now |
| `guardianBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now | | `guardianBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now |
@@ -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 { ): number {
if (!enemy) return dmg; 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) // 1. Dodge check (spec §5.2, §4.5)
let effectiveDodge = enemy.dodgeChance; let effectiveDodge = enemy.dodgeChance;
if (roomType === 'speed') { if (roomType === 'speed') {