diablo2-web/tests/skills/pal/offensive-auras.test.ts

636 lines
25 KiB
TypeScript

/**
* Comprehensive 1.13c Unit Verification Suite — Paladin Offensive Auras Tree (10 Skills).
*
* Covers:
* - Architecture Invariants: BATCH1_SKILLS length === 38, ISO_GROUND_ASPECT_RATIO === 0.5
* - Might (98): Party physical %ED, radius scaling, combat integration
* - Holy Fire (102): Radial pulse, weapon fire damage, Resist Fire (+18%) & Salvation (+6%) synergies
* - Thorns (103): Melee physical reflection, physical resistance mitigation, PvP 1/4th penalty
* - Blessed Aim (108): Active party AR, +5% passive AR per hard point even when inactive
* - Concentration (113): Party %ED, 20% uninterrupted attack chance, +50% Blessed Hammer synergy
* - Holy Freeze (114): Continuous slow, CBF bypass, cold pulse, weapon cold damage & chill length, synergies
* - Holy Shock (118): Lightning pulse (min=1), weapon lightning damage, Resist Lightning (+12%) & Salvation (+4%) synergies
* - Sanctuary (119): Magic pulse & knockback vs Undead, sets Undead physical resistance to 0%, %ED vs Undead, Cleansing synergy
* - Fanaticism (122): IAS, %AR, caster physical %ED vs ally physical %ED (strictly half)
* - Conviction (123): Defense % reduction, elemental resistance reduction, 1/5th immunity breaking, zero impact on Poison/Magic, caster immunity
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
ISO_GROUND_ASPECT_RATIO,
calculateMightStats,
calculateHolyFireStats,
calculateThornsStats,
calculateBlessedAimStats,
calculateConcentrationStats,
calculateHolyFreezeStats,
calculateHolyShockStats,
calculateSanctuaryStats,
calculateFanaticismStats,
calculateConvictionStats,
calculateBlessedHammerDamage,
} from '../../../src/game/skills.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
import {
computeEffectiveResistance,
executeSUnitDmg,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
describe('Paladin Offensive Auras — Milestone M8 Architecture & Formula Invariants', () => {
it('enforces architecture invariants: BATCH1_SKILLS length === 38 and ISO_GROUND_ASPECT_RATIO === 0.5', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
})
// --------------------------------------------------------------------------
// 1. Might (98)
// --------------------------------------------------------------------------
describe('Skill #098: Might', () => {
it('scales party physical %ED and radius at slvl 1, 10, and 20', () => {
const s1 = calculateMightStats(1)
expect(s1.damagePercent).toBe(40)
expect(s1.radiusSubtiles).toBe(16)
expect(s1.radiusYards).toBe(10.67)
const s10 = calculateMightStats(10)
expect(s10.damagePercent).toBe(130)
expect(s10.radiusSubtiles).toBe(34)
expect(s10.radiusYards).toBe(22.67)
const s20 = calculateMightStats(20)
expect(s20.damagePercent).toBe(230)
expect(s20.radiusSubtiles).toBe(54)
expect(s20.radiusYards).toBe(36.0)
})
it('boosts weapon physical damage in executeSUnitDmg when active', async () => {
const registry = await getSharedDataRegistry()
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busP = new StateBus(statsP, registry)
busP.applyState({ stateNameOrId: 'might', slvl: 10 })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: busP,
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const statsM = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, damageresist: 0 })
const monster: CombatUnitContext = {
id: 'monster',
name: 'Monster',
statList: statsM,
stateBus: new StateBus(statsM, registry),
unitType: 'monster',
}
const out = executeSUnitDmg(paladin, monster, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// 100 base damage with slvl 10 Might (+130% ED) = 230 damage
expect(out.physDamage256).toBe(230 * FIXED_ONE)
})
})
// --------------------------------------------------------------------------
// 2. Holy Fire (102)
// --------------------------------------------------------------------------
describe('Skill #102: Holy Fire', () => {
it('calculates pulse damage, weapon fire damage, and synergies at slvl 1, 10, and 20', () => {
const s1 = calculateHolyFireStats(1)
expect(s1.pulseMin).toBe(1)
expect(s1.pulseMax).toBe(3)
expect(s1.pulseMin256).toBe(256)
expect(s1.pulseMax256).toBe(768)
expect(s1.weaponFireMin).toBe(6) // 6 * pulseMin
expect(s1.weaponFireMax).toBe(18) // 6 * pulseMax
expect(s1.radiusSubtiles).toBe(6)
const s10 = calculateHolyFireStats(10)
expect(s10.pulseMin256).toBe(1664)
expect(s10.pulseMax256).toBe(2176)
expect(s10.weaponFireMin).toBe(39) // Math.trunc(1664 * 6 / 256)
expect(s10.weaponFireMax).toBe(51) // Math.trunc(2176 * 6 / 256)
const s20 = calculateHolyFireStats(20)
expect(s20.pulseMin256).toBe(4736)
expect(s20.pulseMax256).toBe(5248)
expect(s20.weaponFireMin).toBe(111)
expect(s20.weaponFireMax).toBe(123)
// Synergies: Resist Fire (+18%/blvl), Salvation (+6%/blvl)
const s20Syn = calculateHolyFireStats(20, { resistFire: 20, salvation: 20 })
expect(s20Syn.synergyBonusPct).toBe(20 * 18 + 20 * 6) // +480%
expect(s20Syn.pulseMin256).toBe(Math.trunc((4736 * (100 + 480)) / 100))
expect(s20Syn.pulseMax256).toBe(Math.trunc((5248 * (100 + 480)) / 100))
})
it('adds weapon fire damage to physical attacks in executeSUnitDmg', async () => {
const registry = await getSharedDataRegistry()
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
statsP.setBaseSkillLevel(100, 20) // Resist Fire 20 (+360%)
const busP = new StateBus(statsP, registry)
busP.applyState({ stateNameOrId: 'holyfire', slvl: 20 })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: busP,
unitType: 'player',
weaponMinPhys: 50,
weaponMaxPhys: 50,
}
const statsM = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, fireresist: 0 })
const monster: CombatUnitContext = {
id: 'monster',
name: 'Monster',
statList: statsM,
stateBus: new StateBus(statsM, registry),
unitType: 'monster',
}
const out = executeSUnitDmg(paladin, monster, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
expect(out.physDamage256).toBe(50 * FIXED_ONE)
expect(out.elemDamage256).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// 3. Thorns (103)
// --------------------------------------------------------------------------
describe('Skill #103: Thorns', () => {
it('scales damage reflection percentage at slvl 1, 10, and 20', () => {
expect(calculateThornsStats(1).reflectPct).toBe(250)
expect(calculateThornsStats(10).reflectPct).toBe(610)
expect(calculateThornsStats(20).reflectPct).toBe(1010)
})
it('reflects melee physical damage, mitigated by attacker physical resistance, with PvP 1/4th penalty', async () => {
const registry = await getSharedDataRegistry()
// 1. PvM Reflection: Attacker has 0% physical resistance
const statsDef = new UnitStatList(registry, { maxhp: 2000 * FIXED_ONE, hitpoints: 2000 * FIXED_ONE, damageresist: 0 })
const busDef = new StateBus(statsDef, registry)
busDef.applyState({ stateNameOrId: 'thorns', slvl: 1 }) // 250% reflection
const paladinDef: CombatUnitContext = {
id: 'pal_def',
name: 'Paladin Defender',
statList: statsDef,
stateBus: busDef,
unitType: 'player',
}
const statsAtk = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 0 })
const busAtk = new StateBus(statsAtk, registry)
const monsterAtk: CombatUnitContext = {
id: 'monster_atk',
name: 'Monster Attacker',
statList: statsAtk,
stateBus: busAtk,
unitType: 'monster',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const outPvM = executeSUnitDmg(monsterAtk, paladinDef, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// 100 phys damage dealt -> reflects 250% = 250 damage back to monster
expect(outPvM.reflectedPhys256).toBe(250 * FIXED_ONE)
expect(monsterAtk.statList.getHp256()).toBe(750 * FIXED_ONE)
// 2. Physical resistance mitigation: Attacker has 50% physical resistance
const statsAtk50 = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 50 })
const monsterAtk50: CombatUnitContext = {
id: 'monster_50',
name: 'Monster 50% Res',
statList: statsAtk50,
stateBus: new StateBus(statsAtk50, registry),
unitType: 'monster',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const out50 = executeSUnitDmg(monsterAtk50, paladinDef, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// 250 reflected * (100 - 50)% = 125 reflected
expect(out50.reflectedPhys256).toBe(125 * FIXED_ONE)
// 3. PvP 1/4th penalty: Player attacker vs Player defender
const statsPlayerAtk = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 0 })
const playerAtk: CombatUnitContext = {
id: 'player_atk',
name: 'Player Attacker',
statList: statsPlayerAtk,
stateBus: new StateBus(statsPlayerAtk, registry),
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const outPvP = executeSUnitDmg(playerAtk, paladinDef, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// In PvP, 1/4th penalty applies: 250 * 256 / 4 = 16000 (62.5 damage in 256-fixed-point)
expect(outPvP.reflectedPhys256).toBe(Math.trunc((250 * FIXED_ONE) / 4))
})
})
// --------------------------------------------------------------------------
// 4. Blessed Aim (108)
// --------------------------------------------------------------------------
describe('Skill #108: Blessed Aim', () => {
it('scales active party %AR at slvl 1, 10, and 20', () => {
expect(calculateBlessedAimStats(1).attackRatingPct).toBe(75)
expect(calculateBlessedAimStats(10).attackRatingPct).toBe(210)
expect(calculateBlessedAimStats(20).attackRatingPct).toBe(360)
})
it('grants +5% passive Attack Rating per base hard point even when inactive', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, { tohit: 1000 })
expect(stats.getAccruedStat('tohit')).toBe(1000)
// Invest 5 hard points in Blessed Aim
stats.setBaseSkillLevel(108, 5)
expect(stats.getAccruedStat('passive_ar_bonus_pct')).toBe(25)
// +25% on 1000 base AR = 1250 AR
expect(stats.getAccruedStat('tohit')).toBe(1250)
// Adding +skills items (+10) must NOT increase passive bonus (hard points only!)
stats.setBonusSkillLevel(108, 10)
stats.setAllSkillsBonus(5)
expect(stats.getAccruedStat('tohit')).toBe(1250)
})
})
// --------------------------------------------------------------------------
// 5. Concentration (113)
// --------------------------------------------------------------------------
describe('Skill #113: Concentration', () => {
it('scales party physical %ED, uninterrupted chance, and Blessed Hammer synergy', () => {
const s1 = calculateConcentrationStats(1)
expect(s1.damagePercent).toBe(60)
expect(s1.uninterruptedChancePct).toBe(20)
expect(s1.blessedHammerBonusPct).toBe(30)
const s10 = calculateConcentrationStats(10)
expect(s10.damagePercent).toBe(195)
expect(s10.blessedHammerBonusPct).toBe(97) // Math.trunc(195 / 2)
const s20 = calculateConcentrationStats(20)
expect(s20.damagePercent).toBe(345)
expect(s20.blessedHammerBonusPct).toBe(172) // Math.trunc(345 / 2)
})
it('provides +50% effective synergy bonus to Blessed Hammer damage', () => {
const hammerBase = calculateBlessedHammerDamage(20, { blessedAim: 0, vigor: 0 }, 0)
const hammerConc = calculateBlessedHammerDamage(20, { blessedAim: 0, vigor: 0 }, 345) // slvl 20 Concentration (345% ED)
expect(hammerConc.concentrationBonusPct).toBe(172)
expect(hammerConc.min).toBe(Math.trunc((hammerBase.min * (100 + 172)) / 100))
})
it('reports uninterrupted attack in executeSUnitDmg when Concentration is active', async () => {
const registry = await getSharedDataRegistry()
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busP = new StateBus(statsP, registry)
busP.applyState({ stateNameOrId: 'concentration', slvl: 1 })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: busP,
unitType: 'player',
weaponMinPhys: 50,
weaponMaxPhys: 50,
}
const statsM = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const monster: CombatUnitContext = {
id: 'monster',
name: 'Monster',
statList: statsM,
stateBus: new StateBus(statsM, registry),
unitType: 'monster',
}
const out = executeSUnitDmg(paladin, monster, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
rollUninterrupted: 10, // < 20%
})
expect(out.uninterrupted).toBe(true)
})
})
// --------------------------------------------------------------------------
// 6. Holy Freeze (114)
// --------------------------------------------------------------------------
describe('Skill #114: Holy Freeze', () => {
it('scales enemy slow % and cold pulse damage across slvl 1, 10, and 20', () => {
const s1 = calculateHolyFreezeStats(1)
expect(s1.slowPct).toBe(30) // 25 + floor(35/7) = 30%
expect(s1.pulseMin256).toBe(512)
expect(s1.pulseMax256).toBe(768)
expect(s1.weaponColdMin).toBe(10)
expect(s1.weaponColdMax).toBe(15)
const s10 = calculateHolyFreezeStats(10)
expect(s10.slowPct).toBe(46)
expect(s10.pulseMin256).toBe(3328)
expect(s10.pulseMax256).toBe(3584)
const s20 = calculateHolyFreezeStats(20)
expect(s20.slowPct).toBe(51)
expect(s20.pulseMin256).toBe(9472)
expect(s20.pulseMax256).toBe(9728)
// Synergies: Resist Cold (+15%/lvl) and Salvation (+7%/lvl)
const s20Syn = calculateHolyFreezeStats(20, { resistCold: 20, salvation: 20 })
expect(s20Syn.synergyBonusPct).toBe(20 * 15 + 20 * 7) // +440%
})
it('slows enemies via AuraScanner bypassing Cannot Be Frozen (CBF)', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: new StateBus(statsP, registry),
unitType: 'player',
}
// Enemy has cannot_be_frozen = 1
const statsE = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, cannot_be_frozen: 1 })
const enemy: CombatUnitContext = {
id: 'enemy',
name: 'CBF Enemy',
statList: statsE,
stateBus: new StateBus(statsE, registry),
unitType: 'monster',
}
const auraSource = scanner.setActiveAura(paladin, 114, 10)!
expect(auraSource).toBeDefined()
scanner.pulseAura(auraSource, 0, [paladin], [enemy])
expect(enemy.stateBus.hasState('holywindcold')).toBe(true)
expect(enemy.statList.getModifierBonus('item_slow')).toBe(46) // Slow is active!
})
})
// --------------------------------------------------------------------------
// 7. Holy Shock (118)
// --------------------------------------------------------------------------
describe('Skill #118: Holy Shock', () => {
it('calculates lightning pulse (min=1), weapon added lightning damage, and synergies', () => {
const s1 = calculateHolyShockStats(1)
expect(s1.pulseMin).toBe(1)
expect(s1.pulseMax).toBe(10)
expect(s1.pulseMin256).toBe(256)
expect(s1.pulseMax256).toBe(2560)
expect(s1.weaponLightningMin).toBe(1)
expect(s1.weaponLightningMax).toBe(60) // 6 * pulseMax
const s10 = calculateHolyShockStats(10)
expect(s10.pulseMin).toBe(1)
expect(s10.pulseMax).toBe(68)
expect(s10.weaponLightningMax).toBe(408)
const s20 = calculateHolyShockStats(20)
expect(s20.pulseMin).toBe(1)
expect(s20.pulseMax).toBe(156)
expect(s20.weaponLightningMax).toBe(936)
// Synergies: Resist Lightning (+12%/lvl) and Salvation (+4%/lvl)
const s20Syn = calculateHolyShockStats(20, { resistLightning: 20, salvation: 20 })
expect(s20Syn.synergyBonusPct).toBe(20 * 12 + 20 * 4) // +320%
})
})
// --------------------------------------------------------------------------
// 8. Sanctuary (119)
// --------------------------------------------------------------------------
describe('Skill #119: Sanctuary', () => {
it('scales pulse damage, %ED vs Undead, and AR vs Undead', () => {
const s1 = calculateSanctuaryStats(1)
expect(s1.pulseMin256).toBe(2048)
expect(s1.pulseMax256).toBe(4096)
expect(s1.undeadDamagePercent).toBe(150)
expect(s1.undeadAttackRating).toBe(100)
const s10 = calculateSanctuaryStats(10)
expect(s10.undeadDamagePercent).toBe(420)
expect(s10.undeadAttackRating).toBe(550)
const s20 = calculateSanctuaryStats(20)
expect(s20.undeadDamagePercent).toBe(720)
expect(s20.undeadAttackRating).toBe(1050)
})
it('zeros Undead physical resistance (piercing immunity) and pulses only vs Undead', async () => {
const registry = await getSharedDataRegistry()
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busP = new StateBus(statsP, registry)
busP.applyState({ stateNameOrId: 'sanctuary', slvl: 1 })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: busP,
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
// Physical Immune Undead (damageresist = 100)
const statsUndead = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 100 })
const undead: CombatUnitContext = {
id: 'ghost',
name: 'Ghost',
statList: statsUndead,
stateBus: new StateBus(statsUndead, registry),
unitType: 'monster',
isUndead: true,
}
const outUndead = executeSUnitDmg(paladin, undead, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// Physical immunity pierced and set to 0%, +150% ED vs Undead applied = 250 damage dealt!
expect(outUndead.immuneToPhys).toBe(false)
expect(outUndead.physDamage256).toBe(250 * FIXED_ONE)
// Physical Immune Non-Undead (Demon, damageresist = 100) -> Still immune!
const statsDemon = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 100 })
const demon: CombatUnitContext = {
id: 'demon',
name: 'Demon',
statList: statsDemon,
stateBus: new StateBus(statsDemon, registry),
unitType: 'monster',
isDemon: true,
isUndead: false,
}
const outDemon = executeSUnitDmg(paladin, demon, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
expect(outDemon.immuneToPhys).toBe(true)
expect(outDemon.physDamage256).toBe(0)
})
})
// --------------------------------------------------------------------------
// 9. Fanaticism (122)
// --------------------------------------------------------------------------
describe('Skill #122: Fanaticism', () => {
it('calculates caster IAS, %AR, and asymmetric caster vs party %ED', () => {
const s1 = calculateFanaticismStats(1)
expect(s1.casterIas).toBe(17)
expect(s1.attackRatingPct).toBe(40)
expect(s1.casterDamagePercent).toBe(50)
expect(s1.partyDamagePercent).toBe(25) // Strictly half
const s10 = calculateFanaticismStats(10)
expect(s10.casterIas).toBe(28)
expect(s10.attackRatingPct).toBe(85)
expect(s10.casterDamagePercent).toBe(203)
expect(s10.partyDamagePercent).toBe(101) // Math.trunc(203 / 2)
const s20 = calculateFanaticismStats(20)
expect(s20.casterIas).toBe(32)
expect(s20.attackRatingPct).toBe(135)
expect(s20.casterDamagePercent).toBe(373)
expect(s20.partyDamagePercent).toBe(186) // Math.trunc(373 / 2)
})
it('applies caster %ED to Paladin and half %ED to party ally in executeSUnitDmg', async () => {
const registry = await getSharedDataRegistry()
// Paladin Caster (slvl 1 Fanaticism: 50% ED)
const statsCaster = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busCaster = new StateBus(statsCaster, registry)
busCaster.applyState({ stateNameOrId: 'fanaticism', slvl: 1 })
const caster: CombatUnitContext = {
id: 'paladin_caster',
name: 'Paladin Caster',
statList: statsCaster,
stateBus: busCaster,
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
// Party Ally (slvl 1 Fanaticism: 25% ED)
const statsAlly = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busAlly = new StateBus(statsAlly, registry)
busAlly.applyState({ stateNameOrId: 'fanaticism', slvl: 1, stats: { is_ally: 1 } })
const ally: CombatUnitContext = {
id: 'party_ally',
name: 'Party Ally',
statList: statsAlly,
stateBus: busAlly,
unitType: 'party',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const statsM = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 0 })
const monster: CombatUnitContext = {
id: 'monster',
name: 'Target Monster',
statList: statsM,
stateBus: new StateBus(statsM, registry),
unitType: 'monster',
}
const outCaster = executeSUnitDmg(caster, monster, { skillId: 0, attackKind: 'melee', autoHit: true })
expect(outCaster.physDamage256).toBe(150 * FIXED_ONE) // 100 + 50% = 150
const outAlly = executeSUnitDmg(ally, monster, { skillId: 0, attackKind: 'melee', autoHit: true })
expect(outAlly.physDamage256).toBe(125 * FIXED_ONE) // 100 + 25% = 125
})
})
// --------------------------------------------------------------------------
// 10. Conviction (123)
// --------------------------------------------------------------------------
describe('Skill #123: Conviction', () => {
it('calculates defense and elemental resistance reduction with immunity breaking scaling', () => {
const s1 = calculateConvictionStats(1)
expect(s1.defenseReductionPct).toBe(30)
expect(s1.resistanceReductionPct).toBe(30)
expect(s1.immunityBreakingReductionPct).toBe(6) // 30 / 5 = 6
const s10 = calculateConvictionStats(10)
expect(s10.defenseReductionPct).toBe(75)
expect(s10.resistanceReductionPct).toBe(75)
expect(s10.immunityBreakingReductionPct).toBe(15)
const s20 = calculateConvictionStats(20)
expect(s20.defenseReductionPct).toBe(90) // Capped at 90%
expect(s20.resistanceReductionPct).toBe(125)
expect(s20.immunityBreakingReductionPct).toBe(25)
const s25 = calculateConvictionStats(25)
expect(s25.resistanceReductionPct).toBe(150) // Capped at 150%
expect(s25.immunityBreakingReductionPct).toBe(30)
})
it('breaks elemental immunities at 1/5th effectiveness and never impacts Poison or Magic', () => {
// 105% base fire resistance (Fire Immune)
// Slvl 25 Conviction has 150% pierce -> 1/5th = 30% against immunity
// Effective res = 105 - 30 = 75% (Immunity Broken!)
const resFire = computeEffectiveResistance({
baseRes: 105,
convictionPierce: 150,
})
expect(resFire.isImmune).toBe(false)
expect(resFire.effectiveRes).toBe(75)
// 140% base fire resistance -> 140 - 30 = 110% (Still immune!)
const resUnbreakable = computeEffectiveResistance({
baseRes: 140,
convictionPierce: 150,
})
expect(resUnbreakable.isImmune).toBe(true)
expect(resUnbreakable.effectiveRes).toBe(100)
})
})
})