/** * Diablo II: Lord of Destruction v1.13c — Necromancer Curses Tree Verification Suite * Milestone M3: Skills 66, 71, 72, 76, 77, 81, 82, 86, 87, 91 * * Verifies: * 1. Architecture invariants: * - BATCH1_SKILLS length strictly 38. * - ISO_GROUND_ASPECT_RATIO = 0.5. * - Decoupled standalone curse formulas. * - calculateSkillDamage returns { min: 0, max: 0 } for all 10 curses. * 2. Exact 1.13c radius formulas (yards and pixels) at slvl 1, 10, 20. * 3. Exact 1.13c duration formulas (frames and seconds) at slvl 1, 10, 20 with difficulty scaling. * 4. Physical immunity breaking with Amplify Damage (100% -> 80%, 110% -> 90%, 120% -> 100%). * 5. Physical immunity breaking with Decrepify (100% -> 90%, 105% -> 95%, 110% -> 100%). * 6. Elemental immunity breaking with Lower Resist (-dm56% with 1/5th penalty on immune). * 7. Attract immunity to overwrite: Amplify Damage, Decrepify, Weaken cannot replace Attract. * 8. Standard curse mutual exclusivity: Amplify Damage overwrites Weaken/Decrepify. * 9. Life Tap 50% physical life leech on hit. * 10. Iron Maiden physical melee damage reflection scaling (200% to 675%). * 11. Weaken -33% damage reduction. * 12. Decrepify tri-debuff (-50% speed, -50% attack rate, -50% damage, -50% phys res). * 13. Dim Vision sight reduction and boss immunity. * 14. Terror monster fleeing distance and boss immunity. * 15. Confuse target chaos and boss immunity. */ import { describe, expect, it } from 'vitest' import { BATCH1_SKILLS, calculateSkillDamage, ISO_GROUND_ASPECT_RATIO, calculateCurseRadiusYards, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateAmplifyDamageDebuff, calculateWeakenDebuff, calculateIronMaidenReturnPct, calculateTerrorDistance, calculateDecrepifyDebuff, calculateLowerResistReduction, computeImmunityBreak, calculateCurseEffect, } from '../../../src/game/skills.ts' import { computeEffectiveResistance, executeSUnitDmg, type CombatUnitContext, } from '../../../src/game/engine/combat-pipeline.ts' import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts' import { StateBus } from '../../../src/game/engine/state-bus.ts' import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' describe('Milestone M3: Necromancer Curses Tree (1.13c Ground Truth)', () => { // ========================================================================= // 1. Architecture Invariants // ========================================================================= describe('1. Architecture Invariants', () => { it('preserves BATCH1_SKILLS length strictly at 38 skills', () => { expect(Object.keys(BATCH1_SKILLS).length).toBe(38) }) it('enforces ISO_GROUND_ASPECT_RATIO === 0.5', () => { expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5) }) it('returns { min: 0, max: 0 } from calculateSkillDamage for all 10 curses', () => { const curseIds = [66, 71, 72, 76, 77, 81, 82, 86, 87, 91] for (const id of curseIds) { const dmg = calculateSkillDamage(id, 20) expect(dmg).toEqual({ min: 0, max: 0 }) } }) }) // ========================================================================= // 2. Exact Radius Formulas (Yards & Pixels) // ========================================================================= describe('2. Exact Radius Formulas (Yards & Pixels)', () => { it('calculates Amplify Damage (66) radius: 3 + (slvl - 1) * 1 subtiles * 2/3', () => { expect(calculateCurseRadiusYards(66, 1)).toBe(2.0) expect(calculateCurseRadiusPx(66, 1)).toBe(80) expect(calculateCurseRadiusYards(66, 10)).toBe(8.0) expect(calculateCurseRadiusPx(66, 10)).toBe(320) expect(calculateCurseRadiusYards(66, 20)).toBe(14.67) expect(calculateCurseRadiusPx(66, 20)).toBe(587) }) it('calculates Dim Vision (71) radius: 4 + (slvl - 1) * 1 subtiles * 2/3', () => { expect(calculateCurseRadiusYards(71, 1)).toBe(2.67) expect(calculateCurseRadiusPx(71, 1)).toBe(107) expect(calculateCurseRadiusYards(71, 10)).toBe(8.67) expect(calculateCurseRadiusPx(71, 10)).toBe(347) expect(calculateCurseRadiusYards(71, 20)).toBe(15.33) expect(calculateCurseRadiusPx(71, 20)).toBe(613) }) it('calculates Weaken (72) radius: 9 + (slvl - 1) * 1 subtiles * 2/3', () => { expect(calculateCurseRadiusYards(72, 1)).toBe(6.0) expect(calculateCurseRadiusPx(72, 1)).toBe(240) expect(calculateCurseRadiusYards(72, 10)).toBe(12.0) expect(calculateCurseRadiusPx(72, 10)).toBe(480) expect(calculateCurseRadiusYards(72, 20)).toBe(18.67) expect(calculateCurseRadiusPx(72, 20)).toBe(747) }) it('calculates Iron Maiden (76) fixed radius: 7 * 2/3 = 4.67y (187 px)', () => { expect(calculateCurseRadiusYards(76, 1)).toBe(4.67) expect(calculateCurseRadiusPx(76, 1)).toBe(187) expect(calculateCurseRadiusYards(76, 10)).toBe(4.67) expect(calculateCurseRadiusPx(76, 10)).toBe(187) expect(calculateCurseRadiusYards(76, 20)).toBe(4.67) expect(calculateCurseRadiusPx(76, 20)).toBe(187) }) it('calculates Terror (77) fixed radius: 4 * 2/3 = 2.67y (107 px)', () => { expect(calculateCurseRadiusYards(77, 1)).toBe(2.67) expect(calculateCurseRadiusPx(77, 1)).toBe(107) expect(calculateCurseRadiusYards(77, 10)).toBe(2.67) expect(calculateCurseRadiusPx(77, 10)).toBe(107) expect(calculateCurseRadiusYards(77, 20)).toBe(2.67) expect(calculateCurseRadiusPx(77, 20)).toBe(107) }) it('calculates Confuse (81) radius: 6 + (slvl - 1) * 1 subtiles * 2/3', () => { expect(calculateCurseRadiusYards(81, 1)).toBe(4.0) expect(calculateCurseRadiusPx(81, 1)).toBe(160) expect(calculateCurseRadiusYards(81, 10)).toBe(10.0) expect(calculateCurseRadiusPx(81, 10)).toBe(400) expect(calculateCurseRadiusYards(81, 20)).toBe(16.67) expect(calculateCurseRadiusPx(81, 20)).toBe(667) }) it('calculates Life Tap (82) radius: 4 + (slvl - 1) * 1 subtiles * 2/3', () => { expect(calculateCurseRadiusYards(82, 1)).toBe(2.67) expect(calculateCurseRadiusPx(82, 1)).toBe(107) expect(calculateCurseRadiusYards(82, 10)).toBe(8.67) expect(calculateCurseRadiusPx(82, 10)).toBe(347) expect(calculateCurseRadiusYards(82, 20)).toBe(15.33) expect(calculateCurseRadiusPx(82, 20)).toBe(613) }) it('calculates Attract (86) fixed search radius: 6.0y (240 px)', () => { expect(calculateCurseRadiusYards(86, 1)).toBe(6.0) expect(calculateCurseRadiusPx(86, 1)).toBe(240) expect(calculateCurseRadiusYards(86, 10)).toBe(6.0) expect(calculateCurseRadiusPx(86, 10)).toBe(240) expect(calculateCurseRadiusYards(86, 20)).toBe(6.0) expect(calculateCurseRadiusPx(86, 20)).toBe(240) }) it('calculates Decrepify (87) fixed radius: 6 * 2/3 = 4.0y (160 px)', () => { expect(calculateCurseRadiusYards(87, 1)).toBe(4.0) expect(calculateCurseRadiusPx(87, 1)).toBe(160) expect(calculateCurseRadiusYards(87, 10)).toBe(4.0) expect(calculateCurseRadiusPx(87, 10)).toBe(160) expect(calculateCurseRadiusYards(87, 20)).toBe(4.0) expect(calculateCurseRadiusPx(87, 20)).toBe(160) }) it('calculates Lower Resist (91) radius: 7 + (slvl - 1) * 1 subtiles * 2/3', () => { expect(calculateCurseRadiusYards(91, 1)).toBe(4.67) expect(calculateCurseRadiusPx(91, 1)).toBe(187) expect(calculateCurseRadiusYards(91, 10)).toBe(10.67) expect(calculateCurseRadiusPx(91, 10)).toBe(427) expect(calculateCurseRadiusYards(91, 20)).toBe(17.33) expect(calculateCurseRadiusPx(91, 20)).toBe(693) }) }) // ========================================================================= // 3. Exact Duration Formulas (Frames & Seconds) // ========================================================================= describe('3. Exact Duration Formulas (Frames & Seconds)', () => { it('calculates exact curse durations across all 10 curses at slvl 1, 10, 20', () => { // 66 Amplify Damage: 200 + (slvl-1)*75 expect(calculateCurseDurationFrames(66, 1)).toBe(200) // 8.0s expect(calculateCurseDurationFrames(66, 10)).toBe(875) // 35.0s expect(calculateCurseDurationFrames(66, 20)).toBe(1625) // 65.0s // 71 Dim Vision: 175 + (slvl-1)*50 expect(calculateCurseDurationFrames(71, 1)).toBe(175) // 7.0s expect(calculateCurseDurationFrames(71, 10)).toBe(625) // 25.0s expect(calculateCurseDurationFrames(71, 20)).toBe(1125) // 45.0s // 72 Weaken: 350 + (slvl-1)*60 expect(calculateCurseDurationFrames(72, 1)).toBe(350) // 14.0s expect(calculateCurseDurationFrames(72, 10)).toBe(890) // 35.6s expect(calculateCurseDurationFrames(72, 20)).toBe(1490) // 59.6s // 76 Iron Maiden: 300 + (slvl-1)*60 expect(calculateCurseDurationFrames(76, 1)).toBe(300) // 12.0s expect(calculateCurseDurationFrames(76, 10)).toBe(840) // 33.6s expect(calculateCurseDurationFrames(76, 20)).toBe(1440) // 57.6s // 77 Terror: 200 + (slvl-1)*25 expect(calculateCurseDurationFrames(77, 1)).toBe(200) // 8.0s expect(calculateCurseDurationFrames(77, 10)).toBe(425) // 17.0s expect(calculateCurseDurationFrames(77, 20)).toBe(675) // 27.0s // 81 Confuse: 250 + (slvl-1)*50 expect(calculateCurseDurationFrames(81, 1)).toBe(250) // 10.0s expect(calculateCurseDurationFrames(81, 10)).toBe(700) // 28.0s expect(calculateCurseDurationFrames(81, 20)).toBe(1200) // 48.0s // 82 Life Tap: 400 + (slvl-1)*60 expect(calculateCurseDurationFrames(82, 1)).toBe(400) // 16.0s expect(calculateCurseDurationFrames(82, 10)).toBe(940) // 37.6s expect(calculateCurseDurationFrames(82, 20)).toBe(1540) // 61.6s // 86 Attract: 300 + (slvl-1)*90 expect(calculateCurseDurationFrames(86, 1)).toBe(300) // 12.0s expect(calculateCurseDurationFrames(86, 10)).toBe(1110) // 44.4s expect(calculateCurseDurationFrames(86, 20)).toBe(2010) // 80.4s // 87 Decrepify: 100 + (slvl-1)*15 expect(calculateCurseDurationFrames(87, 1)).toBe(100) // 4.0s expect(calculateCurseDurationFrames(87, 10)).toBe(235) // 9.4s expect(calculateCurseDurationFrames(87, 20)).toBe(385) // 15.4s // 91 Lower Resist: 500 + (slvl-1)*50 expect(calculateCurseDurationFrames(91, 1)).toBe(500) // 20.0s expect(calculateCurseDurationFrames(91, 10)).toBe(950) // 38.0s expect(calculateCurseDurationFrames(91, 20)).toBe(1450) // 58.0s }) it('scales Dim Vision duration by difficulty (50% NM, 25% Hell)', () => { // Normal: 175f / 1125f expect(calculateCurseDurationFrames(71, 1, 'normal')).toBe(175) expect(calculateCurseDurationFrames(71, 20, 'normal')).toBe(1125) // Nightmare: 50% expect(calculateCurseDurationFrames(71, 1, 'nightmare')).toBe(87) expect(calculateCurseDurationFrames(71, 20, 'nightmare')).toBe(562) // Hell: 25% expect(calculateCurseDurationFrames(71, 1, 'hell')).toBe(43) expect(calculateCurseDurationFrames(71, 20, 'hell')).toBe(281) }) }) // ========================================================================= // 4. Physical Immunity Breaking with Amplify Damage // ========================================================================= describe('4. Physical Immunity Breaking with Amplify Damage', () => { it('applies 1/5th penalty on immune monster (100% -> 80%, 110% -> 90%, 120% -> 100%)', () => { // 100% phys res: 100 - floor(100/5) = 100 - 20 = 80% (broken!) const b100 = computeImmunityBreak(100, 100) expect(b100.wasImmune).toBe(true) expect(b100.effectiveRes).toBe(80) expect(b100.broken).toBe(true) // 110% phys res: 110 - 20 = 90% (broken!) const b110 = computeImmunityBreak(110, 100) expect(b110.wasImmune).toBe(true) expect(b110.effectiveRes).toBe(90) expect(b110.broken).toBe(true) // 120% phys res: 120 - 20 = 100% (remains immune!) const b120 = computeImmunityBreak(120, 100) expect(b120.wasImmune).toBe(true) expect(b120.effectiveRes).toBe(100) expect(b120.broken).toBe(false) }) it('applies full -100% physical resistance to non-immune monster (50% -> -50%, 0% -> -100%)', () => { const b50 = computeImmunityBreak(50, 100) expect(b50.wasImmune).toBe(false) expect(b50.effectiveRes).toBe(-50) expect(b50.broken).toBe(false) const b0 = computeImmunityBreak(0, 100) expect(b0.wasImmune).toBe(false) expect(b0.effectiveRes).toBe(-100) expect(b0.broken).toBe(false) }) it('verifies computeEffectiveResistance under Amplify Damage physical pierce', () => { const res = computeEffectiveResistance({ baseRes: 110, convictionPierce: 100, // 1/5th penalty applies }) expect(res.isImmune).toBe(false) expect(res.effectiveRes).toBe(90) expect(res.convictionAndLRApplied).toBe(20) }) }) // ========================================================================= // 5. Physical Immunity Breaking with Decrepify // ========================================================================= describe('5. Physical Immunity Breaking with Decrepify', () => { it('applies 1/5th penalty on immune monster (100% -> 90%, 105% -> 95%, 110% -> 100%)', () => { // 100% phys res: 100 - floor(50/5) = 100 - 10 = 90% (broken!) const b100 = computeImmunityBreak(100, 50) expect(b100.wasImmune).toBe(true) expect(b100.effectiveRes).toBe(90) expect(b100.broken).toBe(true) // 105% phys res: 105 - 10 = 95% (broken!) const b105 = computeImmunityBreak(105, 50) expect(b105.wasImmune).toBe(true) expect(b105.effectiveRes).toBe(95) expect(b105.broken).toBe(true) // 110% phys res: 110 - 10 = 100% (remains immune!) const b110 = computeImmunityBreak(110, 50) expect(b110.wasImmune).toBe(true) expect(b110.effectiveRes).toBe(100) expect(b110.broken).toBe(false) }) it('applies full -50% physical resistance to non-immune monster (50% -> 0%, 0% -> -50%)', () => { const b50 = computeImmunityBreak(50, 50) expect(b50.wasImmune).toBe(false) expect(b50.effectiveRes).toBe(0) const b0 = computeImmunityBreak(0, 50) expect(b0.wasImmune).toBe(false) expect(b0.effectiveRes).toBe(-50) }) }) // ========================================================================= // 6. Elemental Immunity Breaking with Lower Resist // ========================================================================= describe('6. Elemental Immunity Breaking with Lower Resist', () => { it('evaluates exact dm56 curve: slvl 1: -32%, slvl 10: -55%, slvl 20: -63%', () => { expect(calculateLowerResistReduction(1)).toBe(32) expect(calculateLowerResistReduction(10)).toBe(55) expect(calculateLowerResistReduction(20)).toBe(63) }) it('breaks elemental immunities at 1/5th penalty', () => { // slvl 1: 32% pierce -> floor(32/5) = 6% on 100% res -> 94% (broken!) const b1 = computeImmunityBreak(100, 32) expect(b1.wasImmune).toBe(true) expect(b1.effectiveRes).toBe(94) expect(b1.broken).toBe(true) // slvl 20: 63% pierce -> floor(63/5) = 12% on 100% res -> 88% (broken!) const b20 = computeImmunityBreak(100, 63) expect(b20.wasImmune).toBe(true) expect(b20.effectiveRes).toBe(88) expect(b20.broken).toBe(true) // 110% Lightning Res with slvl 20 LR: 110 - 12 = 98% (broken!) const b110 = computeImmunityBreak(110, 63) expect(b110.wasImmune).toBe(true) expect(b110.effectiveRes).toBe(98) expect(b110.broken).toBe(true) // 115% Fire Res with slvl 20 LR: 115 - 12 = 103% (remains immune!) const b115 = computeImmunityBreak(115, 63) expect(b115.wasImmune).toBe(true) expect(b115.effectiveRes).toBe(103) expect(b115.broken).toBe(false) }) }) // ========================================================================= // 7. Attract Overwrite Immunity // ========================================================================= describe('7. Attract Overwrite Immunity (cursetype = 2)', () => { it('rejects Amplify Damage, Decrepify, Weaken, and duplicate Attract when Attract is active', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList() const bus = new StateBus(stats, registry) // 1. Apply Attract (cursetype = 2) const resAttract = bus.applyState({ stateNameOrId: 'attract', slvl: 10, curseTypeOverride: 2, durationFrames: 1110, }) expect(resAttract.applied).toBe(true) expect(bus.hasState('attract')).toBe(true) // 2. Attempt to cast Amplify Damage (cursetype = 1) -> must be blocked! const resAmp = bus.applyState({ stateNameOrId: 'amplifydamage', slvl: 20, curseTypeOverride: 1, }) expect(resAmp.applied).toBe(false) expect(resAmp.blockedByAttract).toBe(true) expect(bus.hasState('amplifydamage')).toBe(false) expect(bus.hasState('attract')).toBe(true) // 3. Attempt to cast Decrepify (cursetype = 1) -> must be blocked! const resDec = bus.applyState({ stateNameOrId: 'decrepify', slvl: 20, curseTypeOverride: 1, }) expect(resDec.applied).toBe(false) expect(resDec.blockedByAttract).toBe(true) // 4. Attempt to recast Attract on already attracted monster -> must be blocked / no reset! const resAttract2 = bus.applyState({ stateNameOrId: 'attract', slvl: 20, curseTypeOverride: 2, }) expect(resAttract2.applied).toBe(false) expect(resAttract2.blockedByAttract).toBe(true) }) }) // ========================================================================= // 8. Standard Curse Mutual Exclusivity // ========================================================================= describe('8. Standard Curse Mutual Exclusivity (cursetype = 1)', () => { it('overwrites previous cursetype=1 curses cleanly without stat leakage', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList() const bus = new StateBus(stats, registry) // 1. Apply Weaken const r1 = bus.applyState({ stateNameOrId: 'weaken', slvl: 1, stats: { damagepercent: -33 }, curseTypeOverride: 1, }) expect(r1.applied).toBe(true) expect(bus.hasState('weaken')).toBe(true) expect(stats.getModifierBonus('damagepercent')).toBe(-33) // 2. Overwrite with Amplify Damage const r2 = bus.applyState({ stateNameOrId: 'amplifydamage', slvl: 1, stats: { damageresist: -100, amplify_phys_pierce: 100 }, curseTypeOverride: 1, }) expect(r2.applied).toBe(true) expect(r2.evictedState).toBe('weaken') expect(bus.hasState('weaken')).toBe(false) expect(bus.hasState('amplifydamage')).toBe(true) expect(stats.getModifierBonus('damagepercent')).toBe(0) // Cleanly removed! expect(stats.getModifierBonus('damageresist')).toBe(-100) // 3. Overwrite with Decrepify const r3 = bus.applyState({ stateNameOrId: 'decrepify', slvl: 1, stats: { damageresist: -50, velocitypercent: -50 }, curseTypeOverride: 1, }) expect(r3.applied).toBe(true) expect(r3.evictedState).toBe('amplifydamage') expect(bus.hasState('amplifydamage')).toBe(false) expect(bus.hasState('decrepify')).toBe(true) expect(stats.getModifierBonus('damageresist')).toBe(-50) expect(stats.getModifierBonus('velocitypercent')).toBe(-50) }) }) // ========================================================================= // 9. Life Tap 50% Leech Hook // ========================================================================= describe('9. Life Tap 50% Physical Leech Hook', () => { it('grants 50% physical life leech to attackers on hit', () => { const attackerStats = new UnitStatList() attackerStats.setHp256(50 * FIXED_ONE) attackerStats.addStat('maxhp', 100) const attackerBus = new StateBus(attackerStats) const attacker: CombatUnitContext = { id: 'attacker', name: 'Hero', statList: attackerStats, stateBus: attackerBus, unitType: 'player', } const defenderStats = new UnitStatList() defenderStats.setHp256(1000 * FIXED_ONE) const defenderBus = new StateBus(defenderStats) defenderBus.applyState({ stateNameOrId: 'lifetap', slvl: 10, curseTypeOverride: 1, }) const defender: CombatUnitContext = { id: 'defender', name: 'Skeleton', statList: defenderStats, stateBus: defenderBus, unitType: 'monster', } // Attack dealing 100 physical damage const outcome = executeSUnitDmg(attacker, defender, { skillId: 0, attackKind: 'melee', srcDam: 0, flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, }) expect(outcome.hit).toBe(true) expect(outcome.physDamage256).toBe(100 * FIXED_ONE) expect(outcome.attackerHealed256).toBe(50 * FIXED_ONE) // 50% of 100 = 50 HP expect(attackerStats.getHp256()).toBe(100 * FIXED_ONE) // 50 + 50 = 100 HP! }) }) // ========================================================================= // 10. Iron Maiden Damage Reflection // ========================================================================= describe('10. Iron Maiden Damage Reflection Scaling', () => { it('scales reflection percentage: 200 + (slvl - 1) * 25 % (slvl 1: 200%, 10: 425%, 20: 675%)', () => { expect(calculateIronMaidenReturnPct(1)).toBe(200) expect(calculateIronMaidenReturnPct(10)).toBe(425) expect(calculateIronMaidenReturnPct(20)).toBe(675) // PvP division by 4 expect(calculateIronMaidenReturnPct(20, true)).toBe(168) }) it('reflects physical melee damage back to cursed attacker mitigated by attacker phys res', () => { const cursedAttackerStats = new UnitStatList() cursedAttackerStats.setHp256(1000 * FIXED_ONE) cursedAttackerStats.addStat('damageresist', 50) // 50% physical resistance const attackerBus = new StateBus(cursedAttackerStats) attackerBus.applyState({ stateNameOrId: 'ironmaiden', slvl: 1, // 200% return curseTypeOverride: 1, }) const attacker: CombatUnitContext = { id: 'cursed_monster', name: 'Urdar', statList: cursedAttackerStats, stateBus: attackerBus, unitType: 'monster', } const defenderStats = new UnitStatList() defenderStats.setHp256(1000 * FIXED_ONE) const defenderBus = new StateBus(defenderStats) const defender: CombatUnitContext = { id: 'player', name: 'Hero', statList: defenderStats, stateBus: defenderBus, unitType: 'player', } // Monster strikes player for 100 physical damage const outcome = executeSUnitDmg(attacker, defender, { skillId: 0, attackKind: 'melee', srcDam: 0, flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, }) // Raw reflected: 100 * 200% = 200. Mitigated by attacker's 50% phys res: 200 * (100 - 50)% = 100! expect(outcome.reflectedPhys256).toBe(100 * FIXED_ONE) expect(cursedAttackerStats.getHp256()).toBe(900 * FIXED_ONE) }) }) // ========================================================================= // 11. Weaken & 12. Decrepify Debuffs // ========================================================================= describe('11. Weaken & 12. Decrepify Debuffs', () => { it('evaluates Weaken debuff: -33% damage dealt', () => { const w = calculateWeakenDebuff() expect(w.damagepercent).toBe(-33) }) it('evaluates Decrepify tri-debuff: -50% velocity, -50% attack rate, -50% damage, -50% phys res', () => { const d = calculateDecrepifyDebuff() expect(d.velocitypercent).toBe(-50) expect(d.attackrate).toBe(-50) expect(d.damagepercent).toBe(-50) expect(d.damageresist).toBe(-50) expect(d.amplify_phys_pierce).toBe(50) expect(d.item_slow).toBe(50) }) }) // ========================================================================= // 13. Dim Vision, 14. Terror, 15. Confuse Specifications // ========================================================================= describe('13..15 Mental Curses Specs', () => { it('evaluates Terror run distance: 24 + (slvl - 1) * 2 subtiles', () => { expect(calculateTerrorDistance(1)).toBe(24) expect(calculateTerrorDistance(10)).toBe(42) expect(calculateTerrorDistance(20)).toBe(62) }) it('returns complete CurseSpec from calculateCurseEffect across all skills', () => { const specAmp = calculateCurseEffect(66, 1) expect(specAmp.skillName).toBe('Amplify Damage') expect(specAmp.stateName).toBe('amplifydamage') expect(specAmp.curseType).toBe(1) expect(specAmp.radiusYards).toBe(2.0) expect(specAmp.durationFrames).toBe(200) const specAttract = calculateCurseEffect(86, 10) expect(specAttract.skillName).toBe('Attract') expect(specAttract.stateName).toBe('attract') expect(specAttract.curseType).toBe(2) expect(specAttract.radiusYards).toBe(6.0) expect(specAttract.durationFrames).toBe(1110) const specLR = calculateCurseEffect(91, 20) expect(specLR.skillName).toBe('Lower Resist') expect(specLR.stateName).toBe('lowerresist') expect(specLR.debuffStats.lowerresist_pierce).toBe(63) }) }) })