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:
@@ -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,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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user