636 lines
25 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|