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') {