From bd15df85ff773b0017c691d7f6df6b2b8b81d5b1 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Sat, 6 Jun 2026 17:46:39 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20add=20curse=20amplification=20to=20apply?= =?UTF-8?q?EnemyDefenses=20(spec=20=C2=A76.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- docs/project-structure.txt | 1 + docs/specs/spire-combat-spec.md | 10 +- .../__tests__/curse-amplification.test.ts | 139 ++++++++++++++++++ src/lib/game/stores/pipelines/combat-tick.ts | 11 ++ 6 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 src/lib/game/__tests__/curse-amplification.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 4f544c2..3491a5b 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-06-06T14:50:40.350Z +Generated: 2026-06-06T15:33:46.661Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 0c418b0..7ac8e16 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_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.", "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." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 589b4d0..75c3443 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -215,6 +215,7 @@ Mana-Loop/ │ │ │ │ ├── cross-module-helpers.ts │ │ │ │ ├── cross-module-lifecycle-consistency.test.ts │ │ │ │ ├── cross-module-prestige-discipline.test.ts +│ │ │ │ ├── curse-amplification.test.ts │ │ │ │ ├── discipline-math.test.ts │ │ │ │ ├── discipline-prerequisites.test.ts │ │ │ │ ├── discipline-reactivate-bug.test.ts diff --git a/docs/specs/spire-combat-spec.md b/docs/specs/spire-combat-spec.md index f4be9e5..3b04bc6 100644 --- a/docs/specs/spire-combat-spec.md +++ b/docs/specs/spire-combat-spec.md @@ -539,14 +539,14 @@ They are **in scope for the implementation this spec describes**: | 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 barrier absorption | `EnemyState.barrier`, `MODIFIER_CONFIG.mage/shield` | **Implemented** (spells/DoTs only; melee bypasses — see issue #285) | 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 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** — fixed in issue #285 (was bypassed for melee) | 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 | | 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 | -| 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 | | `elemMasteryBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now | | `guardianBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now | diff --git a/src/lib/game/__tests__/curse-amplification.test.ts b/src/lib/game/__tests__/curse-amplification.test.ts new file mode 100644 index 0000000..07e404d --- /dev/null +++ b/src/lib/game/__tests__/curse-amplification.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/lib/game/stores/pipelines/combat-tick.ts b/src/lib/game/stores/pipelines/combat-tick.ts index b5acd4f..519ce01 100644 --- a/src/lib/game/stores/pipelines/combat-tick.ts +++ b/src/lib/game/stores/pipelines/combat-tick.ts @@ -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') {