/** * Milestone M7: Paladin Combat Skills Tree (10 Skills) Comprehensive Verification Suite * * Verifies exact 1.13c formulas, synergies, mechanics, and runtime behaviors for: * 1. Architecture Invariants: BATCH1_SKILLS length === 38, ISO_GROUND_ASPECT_RATIO === 0.5. * 2. Sacrifice (96): %ED, AR, Redemption/Fanaticism synergies, 8% net physical self-damage clamped >= 1 HP. * 3. Smite (97): autoHit=true, unblockable=true, shield base + Holy Shield flat damage, stun duration scaling. * 4. Holy Bolt (101): Undead magic damage + synergies, friendly ally heal + Prayer synergy, living non-undead passthrough. * 5. Zeal (106): hit count min(slvl + 1, 5), %ED scaling, sequential retargeting on target death. * 6. Charge (107): 300% movement velocity, 2:1 isometric vector, %ED, knockback on hit. * 7. Vengeance (111): Physical base + Fire%/Cold%/Ltng% multi-element packet, cold chill duration, independent resistances. * 8. Blessed Hammer (112): Archimedean spiral in 2:1 iso, Concentration +50% synergy, 1.13c magic resistance invariance. * 9. Conversion (116): dm34 conversion roll, boss/champion immunity, 16s allegiance shift. * 10. Holy Shield (117): Duration, defense% + Defiance synergy, dm56 block%, flat Smite damage addition. * 11. Fist of the Heavens (121): 25-frame cast delay, celestial lightning + Holy Shock synergy, radial holy bolts damaging only undead. */ import { describe, expect, it } from 'vitest' import { BATCH1_SKILLS, ISO_GROUND_ASPECT_RATIO, calculateSacrificeStats, calculateSmiteStats, calculateHolyBoltDamage, calculateHolyBoltHeal, calculateZealStats, calculateChargeStats, calculateVengeanceStats, calculateBlessedHammerDamage, calculateConversionStats, calculateHolyShieldStats, calculateFistOfTheHeavensDamage, calculateFistOfTheHeavensHolyBoltDamage, } from '../../../src/game/skills.ts' import { executeSUnitDmg, computeEffectiveResistance, type CombatUnitContext, } from '../../../src/game/engine/combat-pipeline.ts' import { validatePaladinSkillEquipment } from '../../../src/game/skills/paladin-combat.ts' import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts' import { StateBus } from '../../../src/game/engine/state-bus.ts' import { MissileEngine } from '../../../src/game/engine/missile-engine.ts' import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' describe('Milestone M7 — Paladin Combat Skills Tree Unit Suite', () => { it('1. Architecture Invariants: BATCH1_SKILLS length === 38, ISO_GROUND_ASPECT_RATIO === 0.5', () => { expect(Object.keys(BATCH1_SKILLS).length).toBe(38) expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5) }) describe('2. Sacrifice (96)', () => { it('calculates %ED, AR, and synergies accurately across levels', () => { // slvl 1 const s1 = calculateSacrificeStats(1) expect(s1.toHitBonusPct).toBe(20) expect(s1.damagePct).toBe(180) expect(s1.selfDamagePct).toBe(8) // slvl 10 with synergies (Redemption lvl 5 = +75%, Fanaticism lvl 10 = +50%) const s10 = calculateSacrificeStats(10, { redemption: 5, fanaticism: 10 }) expect(s10.toHitBonusPct).toBe(20 + 9 * 7) // 83% expect(s10.damagePct).toBe(180 + 9 * 15 + 75 + 50) // 440% expect(s10.selfDamagePct).toBe(8) // slvl 20 const s20 = calculateSacrificeStats(20) expect(s20.toHitBonusPct).toBe(20 + 19 * 7) // 153% expect(s20.damagePct).toBe(180 + 19 * 15) // 465% }) it('inflicts strictly 8% self-damage on Paladin HP clamped to minimum 1 HP', async () => { const registry = await getSharedDataRegistry() const attackerStats = new UnitStatList(registry, { hitpoints: 50 * FIXED_ONE, maxhp: 1000 * FIXED_ONE, }) const attackerBus = new StateBus(attackerStats, registry) const attacker: CombatUnitContext = { id: 'paladin-sacrifice', name: 'Paladin', statList: attackerStats, stateBus: attackerBus, weaponMinPhys: 500, weaponMaxPhys: 500, } const defenderStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, maxhp: 10000 * FIXED_ONE, damageresist: 0, }) const defenderBus = new StateBus(defenderStats, registry) const defender: CombatUnitContext = { id: 'monster-sacrifice', name: 'Monster', statList: defenderStats, stateBus: defenderBus, } // Deal 500 base physical damage with Sacrifice const out = executeSUnitDmg(attacker, defender, { skillId: 96, attackKind: 'melee', srcDam: 0, flatPhysMin256: 500 * FIXED_ONE, flatPhysMax256: 500 * FIXED_ONE, selfDamagePct: 8, autoHit: true, }) expect(out.hit).toBe(true) expect(out.physDamage256).toBe(500 * FIXED_ONE) // 8% of 500 = 40 damage. Attacker had 50 HP, should have 10 HP left. expect(out.selfDamageTaken256).toBe(40 * FIXED_ONE) expect(attackerStats.getHp256()).toBe(10 * FIXED_ONE) // Now deal another 500 damage when attacker has only 10 HP. // 40 self-damage would reduce HP to -30, but it MUST be clamped to 1 HP (FIXED_ONE)! executeSUnitDmg(attacker, defender, { skillId: 96, attackKind: 'melee', srcDam: 0, flatPhysMin256: 500 * FIXED_ONE, flatPhysMax256: 500 * FIXED_ONE, selfDamagePct: 8, autoHit: true, }) expect(attackerStats.getHp256()).toBe(FIXED_ONE) // Exactly 1 HP, cannot commit suicide! }) }) describe('3. Smite (97)', () => { it('calculates %ED, stun duration, and shield + Holy Shield damage', () => { // slvl 1: shield min 2, max 5, holy shield bonus min 3, max 6 const s1 = calculateSmiteStats(1, 2, 5, { min: 3, max: 6 }) expect(s1.damagePct).toBe(15) expect(s1.stunDurationFrames).toBe(15) expect(s1.minDamage).toBe(5) expect(s1.maxDamage).toBe(11) // slvl 20: stun duration = min(250, 15 + 19 * 5) = 110 frames const s20 = calculateSmiteStats(20, 10, 20, { min: 57, max: 60 }) expect(s20.damagePct).toBe(15 + 19 * 15) // 300% expect(s20.stunDurationFrames).toBe(110) expect(s20.minDamage).toBe(67) expect(s20.maxDamage).toBe(80) }) it('enforces autoHit: true and unblockable: true against 95% defense and 75% block target', async () => { const registry = await getSharedDataRegistry() const attackerStats = new UnitStatList(registry, { level: 20, tohit: 100 }) const attackerBus = new StateBus(attackerStats, registry) const attacker: CombatUnitContext = { id: 'smiter', name: 'Paladin', statList: attackerStats, stateBus: attackerBus, hasShield: true, } // Defender with 99,999 defense, 75% block rate, higher level const defenderStats = new UnitStatList(registry, { level: 99, armorclass: 99999, toblock: 75, hitpoints: 5000 * FIXED_ONE, }) const defenderBus = new StateBus(defenderStats, registry) const defender: CombatUnitContext = { id: 'high-def-target', name: 'Iron Maiden', statList: defenderStats, stateBus: defenderBus, hasShield: true, } // Smite attack with autoHit and unblockable const out = executeSUnitDmg(attacker, defender, { skillId: 97, attackKind: 'melee', srcDam: 0, flatPhysMin256: 50 * FIXED_ONE, flatPhysMax256: 60 * FIXED_ONE, autoHit: true, unblockable: true, stunDurationFrames: 50, roll100: 10, // Normally would be blocked by 75% block }) expect(out.hit).toBe(true) expect(out.avoidedReason).toBe('none') expect(out.totalDamage).toBeGreaterThan(0) expect(defenderBus.hasState('stun')).toBe(true) expect(defenderBus.getState('stun')?.durationFrames).toBe(50) }) it('suppresses equipment life and mana leech, but preserves Life Tap curse healing (1.13c parity)', async () => { const registry = await getSharedDataRegistry() const initialHp = 200 * FIXED_ONE const initialMana = 100 * FIXED_ONE const attackerStats = new UnitStatList(registry, { hitpoints: initialHp, maxhp: 1000 * FIXED_ONE, mana: initialMana, maxmana: 500 * FIXED_ONE, lifesteal: 25, // 25% Life Leech from gear manasteal: 20, // 20% Mana Leech from gear }) const attackerBus = new StateBus(attackerStats, registry) const attacker: CombatUnitContext = { id: 'smiter_leech_test', name: 'Paladin', statList: attackerStats, stateBus: attackerBus, hasShield: true, } const defenderStats = new UnitStatList(registry, { hitpoints: 5000 * FIXED_ONE, maxhp: 5000 * FIXED_ONE, damageresist: 0, }) const defenderBus = new StateBus(defenderStats, registry) const defender: CombatUnitContext = { id: 'monster_dummy', name: 'Dummy', statList: defenderStats, stateBus: defenderBus, } // 1. Smite attack WITHOUT Life Tap: gear leech must be completely suppressed const outNoLifeTap = executeSUnitDmg(attacker, defender, { skillId: 97, attackKind: 'melee', srcDam: 0, flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, unblockable: true, }) expect(outNoLifeTap.hit).toBe(true) expect(outNoLifeTap.physDamage256).toBe(100 * FIXED_ONE) expect(outNoLifeTap.attackerHealed256 ?? 0).toBe(0) expect(outNoLifeTap.attackerManaLeeched256 ?? 0).toBe(0) expect(attackerStats.getHp256()).toBe(initialHp) expect(attackerStats.getMana256()).toBe(initialMana) // 2. Smite attack WITH Life Tap curse: 50% physical damage restored as HP defenderBus.applyState({ stateNameOrId: 'lifetap', slvl: 1, durationFrames: 500, }) const outWithLifeTap = executeSUnitDmg(attacker, defender, { skillId: 97, attackKind: 'melee', srcDam: 0, flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, unblockable: true, }) expect(outWithLifeTap.hit).toBe(true) expect(outWithLifeTap.physDamage256).toBe(100 * FIXED_ONE) expect(outWithLifeTap.attackerHealed256).toBe(50 * FIXED_ONE) expect(outWithLifeTap.attackerManaLeeched256 ?? 0).toBe(0) expect(attackerStats.getHp256()).toBe(initialHp + 50 * FIXED_ONE) expect(attackerStats.getMana256()).toBe(initialMana) }) it('enforces SrvSt shield requirement with tri-state semantics (hasShield=false fails; undefined succeeds)', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry, {}) const unshielded: CombatUnitContext = { id: 'pal_no_shield', name: 'Paladin', statList: stats, stateBus: new StateBus(stats, registry), hasShield: false, } const dummyDef: CombatUnitContext = { id: 'dummy', name: 'Dummy', statList: stats, stateBus: new StateBus(stats, registry), } const out = executeSUnitDmg(unshielded, dummyDef, { skillId: 97, attackKind: 'melee', flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, }) expect(out.hit).toBe(false) expect(out.avoidedReason).toBe('miss') expect(validatePaladinSkillEquipment(97, { hasShield: false }).valid).toBe(false) expect(validatePaladinSkillEquipment(97, { hasShield: true }).valid).toBe(true) expect(validatePaladinSkillEquipment(97, undefined).valid).toBe(true) expect(validatePaladinSkillEquipment(117, { hasShield: false }).valid).toBe(false) expect(validatePaladinSkillEquipment(117, { hasShield: true }).valid).toBe(true) expect(validatePaladinSkillEquipment(96, { weaponItemType: 'bow' }).valid).toBe(false) expect(validatePaladinSkillEquipment(106, { weaponItemType: 'bow' }).valid).toBe(false) expect(validatePaladinSkillEquipment(96, undefined).valid).toBe(true) }) }) describe('4. Holy Bolt (101)', () => { it('calculates 5-band magic damage vs Undead with Blessed Hammer and FoH synergies', () => { // slvl 1 const dmg1 = calculateHolyBoltDamage(1) expect(dmg1.min).toBe(8) expect(dmg1.max).toBe(16) // slvl 10 (base min: 8 + 7*8 + 2*10 = 84, base max: 16 + 7*8 + 2*11 = 94) const dmg10 = calculateHolyBoltDamage(10) expect(dmg10.min).toBe(84) expect(dmg10.max).toBe(94) // slvl 20 with Blessed Hammer (10 pts) and FoH (10 pts) -> 20 * 50% = +1000% synergy const dmg20Syn = calculateHolyBoltDamage(20, { blessedHammer: 10, fistOfTheHeavens: 10 }) // Base at lvl 20: min = 8 + 7*8 + 8*10 + 4*13 = 196, max = 16 + 7*8 + 8*11 + 4*15 = 220 // With +1000% syn: 196 * 11 = 2156, 220 * 11 = 2420 expect(dmg20Syn.min).toBe(196 * 11) expect(dmg20Syn.max).toBe(220 * 11) }) it('calculates friendly heal scaling and Prayer synergy', () => { const h1 = calculateHolyBoltHeal(1) expect(h1.min).toBe(1) expect(h1.max).toBe(6) const h10Prayer5 = calculateHolyBoltHeal(10, 5) // Prayer lvl 5 = +75% // Raw min: 1 + 9*2 = 19 -> 19 * 1.75 = 33 // Raw max: 6 + 9*4 = 42 -> 42 * 1.75 = 73 expect(h10Prayer5.min).toBe(Math.trunc(19 * 1.75)) expect(h10Prayer5.max).toBe(Math.trunc(42 * 1.75)) }) it('discriminator: damages Undead, heals friendly ally, and passes through Living Demon', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const casterStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) casterStats.setBaseSkillLevel(99, 10) // Prayer synergy const caster: CombatUnitContext = { id: 'paladin-holybolt', name: 'Paladin', statList: casterStats, stateBus: new StateBus(casterStats, registry), } // Target 1: Demon (living) const demonStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) const demon: CombatUnitContext = { id: 'demon-target', name: 'Demon', statList: demonStats, stateBus: new StateBus(demonStats, registry), isUndead: false, isDemon: true, unitType: 'monster', } // Target 2: Friendly Ally injured const allyStats = new UnitStatList(registry, { hitpoints: 200 * FIXED_ONE, maxhp: 1000 * FIXED_ONE }) const ally: CombatUnitContext = { id: 'ally-target', name: 'Mercenary', statList: allyStats, stateBus: new StateBus(allyStats, registry), unitType: 'mercenary', } // Target 3: Undead Monster const undeadStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, magicresist: 0 }) const undead: CombatUnitContext = { id: 'undead-target', name: 'Skeleton', statList: undeadStats, stateBus: new StateBus(undeadStats, registry), isUndead: true, unitType: 'monster', } // 1. Holy Bolt fired at Demon -> Demon must be ignored and bolt continues const m1 = engine.spawnMissile({ missileNameOrId: 'holybolt', sourceSkillId: 101, slvl: 10, owner: caster, startX: 0, startY: 0, targetX: 50, targetY: 0, dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 100 * FIXED_ONE, elemMax256: 100 * FIXED_ONE }, })! const step1 = engine.tick(1, [demon], new Map([['demon-target', { x: 10, y: 0 }]])) expect(demonStats.getHp256()).toBe(1000 * FIXED_ONE) // No damage! expect(m1.expired).toBe(false) // Missile passed through! // 2. Holy Bolt fired at Ally -> heals ally and expires const m2 = engine.spawnMissile({ missileNameOrId: 'holybolt', sourceSkillId: 101, slvl: 10, owner: caster, startX: 0, startY: 0, targetX: 50, targetY: 0, dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 100 * FIXED_ONE, elemMax256: 100 * FIXED_ONE }, })! const step2 = engine.tick(2, [ally], new Map([['ally-target', { x: 10, y: 0 }]])) expect(allyStats.getHp256()).toBeGreaterThan(200 * FIXED_ONE) // Healed! expect(m2.expired).toBe(true) // Consumed on heal! // 3. Holy Bolt fired at Undead -> damages undead and expires const m3 = engine.spawnMissile({ missileNameOrId: 'holybolt', sourceSkillId: 101, slvl: 10, owner: caster, startX: 0, startY: 0, targetX: 50, targetY: 0, dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 100 * FIXED_ONE, elemMax256: 100 * FIXED_ONE }, })! const step3 = engine.tick(3, [undead], new Map([['undead-target', { x: 10, y: 0 }]])) expect(undeadStats.getHp256()).toBeLessThan(1000 * FIXED_ONE) // Damaged! expect(m3.expired).toBe(true) // Consumed on impact! }) }) describe('5. Zeal (106)', () => { it('calculates swing count formula min(slvl + 1, 5) and %ED scaling', () => { expect(calculateZealStats(1).hits).toBe(2) expect(calculateZealStats(2).hits).toBe(3) expect(calculateZealStats(3).hits).toBe(4) expect(calculateZealStats(4).hits).toBe(5) expect(calculateZealStats(20).hits).toBe(5) // %ED starts at slvl 5: (slvl - 4) * 6% expect(calculateZealStats(1).damagePct).toBe(0) expect(calculateZealStats(4).damagePct).toBe(0) expect(calculateZealStats(5).damagePct).toBe(6) expect(calculateZealStats(10, { sacrifice: 10 }).damagePct).toBe((10 - 4) * 6 + 10 * 12) // 36 + 120 = 156% }) }) describe('6. Charge (107)', () => { it('calculates 300% movement velocity and Vigor/Might synergy', () => { const c1 = calculateChargeStats(1) expect(c1.speedMultiplier).toBe(3.0) expect(c1.damagePct).toBe(100) expect(c1.toHitBonusPct).toBe(50) const c10 = calculateChargeStats(10, { vigor: 5, might: 5 }) expect(c10.damagePct).toBe(100 + 9 * 25 + (5 + 5) * 20) // 325 + 200 = 525% expect(c10.toHitBonusPct).toBe(50 + 9 * 15) // 185% }) }) describe('7. Vengeance (111)', () => { it('calculates 3-way elemental percentages and cold chill duration', () => { const v1 = calculateVengeanceStats(1) expect(v1.firePct).toBe(70) expect(v1.coldPct).toBe(70) expect(v1.ltngPct).toBe(70) expect(v1.chillDurationFrames).toBe(30) // slvl 10 with Resist Fire 10, Salvation 5 const v10 = calculateVengeanceStats(10, { resistFire: 10, salvation: 5 }) // Base elem: 70 + 9*6 = 124% // Fire: 124 + 10*10 + 5*2 = 234% expect(v10.firePct).toBe(234) expect(v10.coldPct).toBe(124 + 10) // 134% (salvation only) expect(v10.chillDurationFrames).toBe(30 + 9 * 15) // 165 frames (6.6s) }) it('evaluates multi-elemental damage packet with independent resistances', async () => { const registry = await getSharedDataRegistry() const attackerStats = new UnitStatList(registry, {}) const attacker: CombatUnitContext = { id: 'avenger', name: 'Paladin', statList: attackerStats, stateBus: new StateBus(attackerStats, registry), } // Defender: Immune to Fire (110%), 50% Cold, 0% Lightning, 0% Physical const defenderStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, damageresist: 0, fireresist: 110, coldresist: 50, lightresist: 0, }) const defender: CombatUnitContext = { id: 'tri-res-target', name: 'Monster', statList: defenderStats, stateBus: new StateBus(defenderStats, registry), } const out = executeSUnitDmg(attacker, defender, { skillId: 111, attackKind: 'melee', srcDam: 0, flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, multiElemPacket: { firePct: 100, // 100 fire raw -> resisted 110% -> 0 coldPct: 100, // 100 cold raw -> resisted 50% -> 50 ltngPct: 100, // 100 ltng raw -> resisted 0% -> 100 chillDurationFrames: 50, }, autoHit: true, }) expect(out.hit).toBe(true) expect(out.physDamage256).toBe(100 * FIXED_ONE) // Fire (0) + Cold (50) + Lightning (100) = 150 elemental damage expect(out.elemDamage256).toBe(150 * FIXED_ONE) expect(out.totalDamage).toBe(250) expect(defender.stateBus.hasState('freeze')).toBe(true) }) }) describe('8. Blessed Hammer (112)', () => { it('calculates 5-band magic damage, synergies, and Concentration +50% synergy', () => { const h1 = calculateBlessedHammerDamage(1) expect(h1.min).toBe(12) expect(h1.max).toBe(16) // Concentration aura +300% ED -> gives +150% ED to Blessed Hammer const h1Conc = calculateBlessedHammerDamage(1, undefined, 300) expect(h1Conc.concentrationBonusPct).toBe(150) expect(h1Conc.min).toBe(Math.trunc(12 * 2.5)) expect(h1Conc.max).toBe(Math.trunc(16 * 2.5)) }) it('CRITICAL 1.13c Rule: Magic Immune monsters take 0 damage from Blessed Hammer', async () => { const registry = await getSharedDataRegistry() const attackerStats = new UnitStatList(registry, {}) const attacker: CombatUnitContext = { id: 'hammerdin', name: 'Paladin', statList: attackerStats, stateBus: new StateBus(attackerStats, registry), } // 1.13c Invariant: Undead with magicresist >= 100 is IMMUNE to Blessed Hammer! const immuneUndeadStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, magicresist: 100, }) const immuneUndead: CombatUnitContext = { id: 'baal-wave-undead', name: 'Achmel the Cursed Minion', statList: immuneUndeadStats, stateBus: new StateBus(immuneUndeadStats, registry), isUndead: true, } const out = executeSUnitDmg(attacker, immuneUndead, { skillId: 112, attackKind: 'spell', elemType: 'mag', elemMin256: 500 * FIXED_ONE, elemMax256: 500 * FIXED_ONE, autoHit: true, }) expect(out.hit).toBe(true) expect(out.immuneToElem).toBe(true) expect(out.totalDamage).toBe(0) expect(immuneUndeadStats.getHp256()).toBe(5000 * FIXED_ONE) // Zero damage taken! }) }) describe('9. Conversion (116)', () => { it('calculates dm34 conversion roll chance and duration', () => { // dm34 formula: Math.floor((slvl * 50) / (slvl + 10)) expect(calculateConversionStats(1).chancePct).toBe(Math.floor(50 / 11)) // 4% expect(calculateConversionStats(10).chancePct).toBe(Math.floor(500 / 20)) // 25% expect(calculateConversionStats(20).chancePct).toBe(Math.floor(1000 / 30)) // 33% expect(calculateConversionStats(1).durationFrames).toBe(400) // 16s }) }) describe('10. Holy Shield (117)', () => { it('calculates duration, defense bonus, dm56 block chance, and flat Smite damage', () => { const hs1 = calculateHolyShieldStats(1) expect(hs1.durationFrames).toBe(750) // 30s expect(hs1.defensePct).toBe(25) expect(hs1.blockChancePct).toBe(14) // 1.13c dm56: 10 + Math.floor(3300 / 700) = 14% expect(hs1.smiteMinFlat).toBe(3) expect(hs1.smiteMaxFlat).toBe(6) const hs20Defiance10 = calculateHolyShieldStats(20, 10) expect(hs20Defiance10.durationFrames).toBe(750 + 19 * 625) // 12,625 frames (~505s) expect(hs20Defiance10.defensePct).toBe(25 + 19 * 15 + 10 * 15) // 310 + 150 = 460% expect(hs20Defiance10.blockChancePct).toBe(35) // 1.13c dm56: 10 + Math.floor(66000 / 2600) = 35% expect(hs20Defiance10.smiteMinFlat).toBe(57) expect(hs20Defiance10.smiteMaxFlat).toBe(60) }) }) describe('11. Fist of the Heavens (121)', () => { it('calculates celestial lightning damage, Holy Shock synergy, and radial Holy Bolt damage', () => { const foh1 = calculateFistOfTheHeavensDamage(1) expect(foh1.lightningMin).toBe(150) expect(foh1.lightningMax).toBe(200) // slvl 10 with Holy Shock 10 -> +70% synergy const foh10Shock10 = calculateFistOfTheHeavensDamage(10, { holyShock: 10 }) // Base at lvl 10: 150 + 7*15 + 2*30 = 315 min, 200 + 7*15 + 2*30 = 365 max // With +70% syn: 315 * 1.7 = 535, 365 * 1.7 = 620 expect(foh10Shock10.lightningMin).toBe(Math.trunc(315 * 1.7)) expect(foh10Shock10.lightningMax).toBe(Math.trunc(365 * 1.7)) // Radial Holy Bolt at lvl 10 with Holy Bolt synergy 10 -> +150% synergy const hb10 = calculateFistOfTheHeavensHolyBoltDamage(10, 10) // Base HB min: 84, max: 94 // With +150% syn: 84 * 2.5 = 210, 94 * 2.5 = 235 expect(hb10.holyBoltMin).toBe(Math.trunc(84 * 2.5)) expect(hb10.holyBoltMax).toBe(Math.trunc(94 * 2.5)) }) }) })