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

524 lines
20 KiB
TypeScript

/**
* Comprehensive 1.13c Unit Verification Suite — Paladin Defensive Auras Tree (10 Skills).
*
* Covers:
* - Architecture Invariants: BATCH1_SKILLS length === 38, ISO_GROUND_ASPECT_RATIO === 0.5
* - Prayer (99): Periodic heal pulse, continuous mana drain (16/256), auto-deactivation at 0 mana
* - Resist Fire (100): Active party fire res, passive max fire res (+1% per 2 hard points, capped at 95%)
* - Defiance (104): Active party %Defense, Holy Shield synergy (+15%/blvl)
* - Resist Cold (105): Active party cold res, passive max cold res (+1% per 2 hard points, capped at 95%)
* - Cleansing (109): Curse & poison duration reduction, free Prayer synergy heal pulse
* - Resist Lightning (110): Active party lightning res, passive max lightning res (+1% per 2 hard points, capped at 95%)
* - Vigor (115): FRW velocitypercent, stamina recovery bonus, max stamina %
* - Meditation (120): Active party mana recovery rate, free Prayer synergy heal pulse
* - Redemption (124): 50-tick cadence, fixed 16 subtiles in 2:1 isometric space, dm34 chance, ln56 life/mana restore, corpse consumption
* - Salvation (125): Active party Fire/Cold/Lightning res, zero effect on Poison or Magic
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
ISO_GROUND_ASPECT_RATIO,
calculatePrayerStats,
calculateResistFireStats,
calculateDefianceStats,
calculateResistColdStats,
calculateCleansingStats,
calculateResistLightningStats,
calculateVigorStats,
calculateMeditationStats,
calculateRedemptionStats,
calculateSalvationStats,
} 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 Defensive Auras — Milestone M9 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. Prayer (99)
// --------------------------------------------------------------------------
describe('Skill #099: Prayer', () => {
it('scales heal amount (edns 5-band) and radius at slvl 1, 10, and 20', () => {
const s1 = calculatePrayerStats(1)
expect(s1.healAmount).toBe(2)
expect(s1.healAmount256).toBe(512)
expect(s1.manaCostPerFrame256).toBe(16)
expect(s1.radiusSubtiles).toBe(16)
expect(s1.radiusYards).toBe(10.67)
const s10 = calculatePrayerStats(10)
expect(s10.healAmount).toBe(11) // 2 + 7*1 + 2*1 = 11
expect(s10.healAmount256).toBe(11 * 256)
expect(s10.radiusSubtiles).toBe(34) // 16 + 9*2 = 34
expect(s10.radiusYards).toBe(22.67)
const s20 = calculatePrayerStats(20)
expect(s20.healAmount).toBe(25) // 2 + 7 + 8 + 4*2 = 25
expect(s20.healAmount256).toBe(25 * 256)
expect(s20.radiusSubtiles).toBe(54) // 16 + 19*2 = 54
})
it('heals self and damaged party allies within range on pulse', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const statsCaster = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 500 * FIXED_ONE,
mana: 500 * FIXED_ONE,
})
const busCaster = new StateBus(statsCaster, registry)
const caster: CombatUnitContext = {
id: 'paladin_caster',
name: 'Paladin Caster',
statList: statsCaster,
stateBus: busCaster,
unitType: 'player',
x: 0,
y: 0,
}
const statsAlly = new UnitStatList(registry, {
maxhp: 800 * FIXED_ONE,
hitpoints: 400 * FIXED_ONE,
})
const busAlly = new StateBus(statsAlly, registry)
const ally: CombatUnitContext = {
id: 'party_ally',
name: 'Party Ally',
statList: statsAlly,
stateBus: busAlly,
unitType: 'party',
x: 50,
y: 25, // within range in 2:1 isometric space
}
const aura = scanner.setActiveAura(caster, 99, 10, 0)!
expect(aura).toBeDefined()
const outcome = scanner.pulseAura(aura, 0, [caster, ally], [])
expect(outcome.healthRestored).toBe(22) // 11 to caster + 11 to ally
expect(caster.statList.getHp256()).toBe((500 + 11) * FIXED_ONE)
expect(ally.statList.getHp256()).toBe((400 + 11) * FIXED_ONE)
})
it('drains mana per tick and auto-deactivates when mana reaches 0', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
// Paladin with exactly 32 units of mana256 (enough for 2 frames)
const stats = new UnitStatList(registry, {
maxmana: 100 * FIXED_ONE,
mana: 32,
})
const bus = new StateBus(stats, registry)
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: stats,
stateBus: bus,
unitType: 'player',
}
scanner.setActiveAura(paladin, 99, 1, 0)
expect(paladin.statList.getMana256()).toBe(32)
// Frame 1: drains 16 units -> 16 remaining
scanner.tick(1, [paladin], [])
expect(paladin.statList.getMana256()).toBe(16)
// Frame 2: drains 16 units -> 0 remaining
scanner.tick(2, [paladin], [])
expect(paladin.statList.getMana256()).toBe(0)
// Frame 3: curMana < 16 -> auto-deactivates aura and removes state
scanner.tick(3, [paladin], [])
expect(paladin.statList.getMana256()).toBe(0)
expect(paladin.stateBus.hasState('prayer')).toBe(false)
})
})
// --------------------------------------------------------------------------
// 2. Resist Fire (100)
// --------------------------------------------------------------------------
describe('Skill #100: Resist Fire', () => {
it('scales active party fire resistance at slvl 1, 10, and 20', () => {
const s1 = calculateResistFireStats(1)
expect(s1.fireResistPct).toBe(53)
expect(s1.radiusSubtiles).toBe(16)
const s10 = calculateResistFireStats(10)
expect(s10.fireResistPct).toBe(114)
const s20 = calculateResistFireStats(20)
expect(s20.fireResistPct).toBe(132)
})
it('grants passive +1% Max Fire Resistance per 2 hard points, active even when aura is inactive', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry)
expect(stats.getEffectiveMaxResistance('fire')).toBe(75)
// 5 hard points in Resist Fire -> +2% max fire res (floor(5/2) = 2)
stats.setBaseSkillLevel(100, 5)
expect(stats.getAccruedStat('maxfireresist')).toBe(2)
expect(stats.getEffectiveMaxResistance('fire')).toBe(77)
// +skills items do NOT increase passive max res (blvl only!)
stats.setBonusSkillLevel(100, 10)
expect(stats.getAccruedStat('maxfireresist')).toBe(2)
expect(stats.getEffectiveMaxResistance('fire')).toBe(77)
// 20 hard points -> +10% max fire res -> 85%
stats.setBaseSkillLevel(100, 20)
expect(stats.getAccruedStat('maxfireresist')).toBe(10)
expect(stats.getEffectiveMaxResistance('fire')).toBe(85)
})
})
// --------------------------------------------------------------------------
// 3. Defiance (104)
// --------------------------------------------------------------------------
describe('Skill #104: Defiance', () => {
it('scales party %Defense at slvl 1, 10, and 20', () => {
const s1 = calculateDefianceStats(1)
expect(s1.defensePercent).toBe(70)
expect(s1.radiusSubtiles).toBe(16)
const s10 = calculateDefianceStats(10)
expect(s10.defensePercent).toBe(160)
const s20 = calculateDefianceStats(20)
expect(s20.defensePercent).toBe(260)
})
it('increases party member defense in executeSUnitDmg reducing monster chance to hit', async () => {
const registry = await getSharedDataRegistry()
const statsPal = new UnitStatList(registry, { armorclass: 1000 })
const busPal = new StateBus(statsPal, registry)
busPal.applyState({ stateNameOrId: 'defiance', slvl: 10, stats: { skill_armor_percent: 160 } })
expect(statsPal.getAccruedStat('armorclass')).toBe(2600) // 1000 * 2.6 = 2600
})
})
// --------------------------------------------------------------------------
// 4. Resist Cold (105)
// --------------------------------------------------------------------------
describe('Skill #105: Resist Cold', () => {
it('scales active party cold resistance at slvl 1, 10, and 20', () => {
const s1 = calculateResistColdStats(1)
expect(s1.coldResistPct).toBe(53)
const s10 = calculateResistColdStats(10)
expect(s10.coldResistPct).toBe(114)
expect(calculateResistColdStats(20).coldResistPct).toBe(132)
})
it('grants passive +1% Max Cold Resistance per 2 hard points', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry)
expect(stats.getEffectiveMaxResistance('cold')).toBe(75)
stats.setBaseSkillLevel(105, 14)
expect(stats.getAccruedStat('maxcoldresist')).toBe(7)
expect(stats.getEffectiveMaxResistance('cold')).toBe(82)
})
})
// --------------------------------------------------------------------------
// 5. Cleansing (109)
// --------------------------------------------------------------------------
describe('Skill #109: Cleansing', () => {
it('scales curse & poison duration reduction at slvl 1, 10, and 20', () => {
const s1 = calculateCleansingStats(1)
expect(s1.curseReductionPct).toBe(39)
expect(s1.poisonLengthReductionPct).toBe(39)
const s10 = calculateCleansingStats(10)
expect(s10.curseReductionPct).toBe(71)
const s20 = calculateCleansingStats(20)
expect(s20.curseReductionPct).toBe(80)
})
it('emits free Prayer heal pulse when caster has Prayer hard points (0 mana cost)', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 100 * FIXED_ONE,
})
stats.setBaseSkillLevel(99, 10) // 10 hard points in Prayer (11 HP heal)
const bus = new StateBus(stats, registry)
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: stats,
stateBus: bus,
unitType: 'player',
}
const aura = scanner.setActiveAura(paladin, 109, 1, 0)!
const outcome = scanner.pulseAura(aura, 0, [paladin], [])
expect(outcome.healthRestored).toBe(11)
expect(paladin.statList.getHp256()).toBe(511 * FIXED_ONE)
// Cleansing does NOT drain mana!
expect(paladin.statList.getMana256()).toBe(100 * FIXED_ONE)
})
it('shortens active curse duration on party members', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const stats = new UnitStatList(registry)
const bus = new StateBus(stats, registry)
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: stats,
stateBus: bus,
unitType: 'player',
}
// Inflict Amplify Damage curse for 100 frames
bus.applyState({
stateNameOrId: 'amplifydamage',
curseTypeOverride: 1,
durationFrames: 100,
currentFrame: 0,
})
expect(bus.getState('amplifydamage')?.expireFrame).toBe(100)
// Pulse Cleansing slvl 10 (71% reduction) at tick 0
const aura = scanner.setActiveAura(paladin, 109, 10, 0)!
scanner.pulseAura(aura, 0, [paladin], [])
// Remaining 100 frames reduced by 71% -> 100 * 0.29 = 29 frames remaining
expect(bus.getState('amplifydamage')?.expireFrame).toBe(29)
})
})
// --------------------------------------------------------------------------
// 6. Resist Lightning (110)
// --------------------------------------------------------------------------
describe('Skill #110: Resist Lightning', () => {
it('scales active party lightning resistance at slvl 1, 10, and 20', () => {
const s1 = calculateResistLightningStats(1)
expect(s1.lightningResistPct).toBe(53)
expect(calculateResistLightningStats(10).lightningResistPct).toBe(114)
expect(calculateResistLightningStats(20).lightningResistPct).toBe(132)
})
it('grants passive +1% Max Lightning Resistance per 2 hard points', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry)
expect(stats.getEffectiveMaxResistance('ltng')).toBe(75)
stats.setBaseSkillLevel(110, 20)
expect(stats.getAccruedStat('maxlightresist')).toBe(10)
expect(stats.getEffectiveMaxResistance('ltng')).toBe(85)
})
})
// --------------------------------------------------------------------------
// 7. Vigor (115)
// --------------------------------------------------------------------------
describe('Skill #115: Vigor', () => {
it('scales FRW %, stamina recovery %, and max stamina % at slvl 1, 10, and 20', () => {
const s1 = calculateVigorStats(1)
expect(s1.velocityPercent).toBe(13)
expect(s1.staminaRecoveryBonusPct).toBe(50)
expect(s1.maxStaminaPercent).toBe(50)
expect(s1.radiusSubtiles).toBe(16)
const s10 = calculateVigorStats(10)
expect(s10.velocityPercent).toBe(36)
expect(s10.staminaRecoveryBonusPct).toBe(275)
expect(s10.maxStaminaPercent).toBe(275)
expect(s10.radiusSubtiles).toBe(43) // 16 + 9*3 = 43
const s20 = calculateVigorStats(20)
expect(s20.velocityPercent).toBe(43)
expect(s20.staminaRecoveryBonusPct).toBe(525)
expect(s20.maxStaminaPercent).toBe(525)
expect(s20.radiusSubtiles).toBe(73) // 16 + 19*3 = 73
})
})
// --------------------------------------------------------------------------
// 8. Meditation (120)
// --------------------------------------------------------------------------
describe('Skill #120: Meditation', () => {
it('scales party mana recovery bonus at slvl 1, 10, and 20', () => {
const s1 = calculateMeditationStats(1)
expect(s1.manaRecoveryBonusPct).toBe(300)
expect(s1.radiusSubtiles).toBe(16)
const s10 = calculateMeditationStats(10)
expect(s10.manaRecoveryBonusPct).toBe(525)
const s20 = calculateMeditationStats(20)
expect(s20.manaRecoveryBonusPct).toBe(775)
})
it('emits free Prayer heal pulse with 0 mana drain', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 100 * FIXED_ONE,
})
stats.setBaseSkillLevel(99, 20) // 20 hard points in Prayer (25 HP heal)
const bus = new StateBus(stats, registry)
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: stats,
stateBus: bus,
unitType: 'player',
}
const aura = scanner.setActiveAura(paladin, 120, 1, 0)!
const outcome = scanner.pulseAura(aura, 0, [paladin], [])
expect(outcome.healthRestored).toBe(25)
expect(paladin.statList.getHp256()).toBe(525 * FIXED_ONE)
expect(paladin.statList.getMana256()).toBe(100 * FIXED_ONE)
})
})
// --------------------------------------------------------------------------
// 9. Redemption (124)
// --------------------------------------------------------------------------
describe('Skill #124: Redemption', () => {
it('scales redemption chance and recovery amounts at slvl 1, 10, and 20', () => {
const s1 = calculateRedemptionStats(1)
expect(s1.redemptionChancePct).toBe(24)
expect(s1.lifeRecoverPts).toBe(25)
expect(s1.manaRecoverPts).toBe(25)
expect(s1.radiusSubtiles).toBe(16)
const s10 = calculateRedemptionStats(10)
expect(s10.redemptionChancePct).toBe(71)
expect(s10.lifeRecoverPts).toBe(70)
expect(s10.manaRecoverPts).toBe(70)
const s20 = calculateRedemptionStats(20)
expect(s20.redemptionChancePct).toBe(86)
expect(s20.lifeRecoverPts).toBe(120)
expect(s20.manaRecoverPts).toBe(120)
})
it('consumes valid corpses within 16 subtiles and restores Life & Mana to caster only', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 500 * FIXED_ONE,
mana: 200 * FIXED_ONE,
})
const bus = new StateBus(stats, registry)
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: stats,
stateBus: bus,
unitType: 'player',
x: 0,
y: 0,
}
const corpses = [
{ id: 'corpse_1', consumed: false, x: 20, y: 10 }, // inside 16 subtiles (~213px)
{ id: 'corpse_2', consumed: false, x: 50, y: 25 }, // inside
{ id: 'corpse_3', consumed: true, x: 10, y: 5 }, // already consumed
{ id: 'corpse_4', consumed: false, x: 300, y: 150 }, // outside range
]
const aura = scanner.setActiveAura(paladin, 124, 10, 0)! // slvl 10: 70 Life/Mana
// Always pass rollChanceFn for deterministic test
const outcome = scanner.pulseAura(aura, 0, [paladin], [], corpses, {
rollChanceFn: () => true,
})
expect(outcome.corpsesRedeemed).toBe(2)
expect(corpses[0]!.consumed).toBe(true)
expect(corpses[1]!.consumed).toBe(true)
expect(corpses[3]!.consumed).toBe(false) // untouched outside
// Restored 2 corpses * 70 pts = 140 HP and 140 Mana
expect(paladin.statList.getHp256()).toBe((500 + 140) * FIXED_ONE)
expect(paladin.statList.getMana256()).toBe((200 + 140) * FIXED_ONE)
})
})
// --------------------------------------------------------------------------
// 10. Salvation (125)
// --------------------------------------------------------------------------
describe('Skill #125: Salvation', () => {
it('scales active Fire, Cold, Lightning resistances at slvl 1, 10, and 20', () => {
const s1 = calculateSalvationStats(1)
expect(s1.fireResistPct).toBe(61)
expect(s1.coldResistPct).toBe(61)
expect(s1.lightningResistPct).toBe(61)
expect(s1.radiusSubtiles).toBe(16)
const s10 = calculateSalvationStats(10)
expect(s10.fireResistPct).toBe(98)
expect(s10.coldResistPct).toBe(98)
expect(s10.lightningResistPct).toBe(98)
const s20 = calculateSalvationStats(20)
expect(s20.fireResistPct).toBe(109)
expect(s20.coldResistPct).toBe(109)
expect(s20.lightningResistPct).toBe(109)
})
it('strictly provides zero resistance bonus to Poison or Magic', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry)
const bus = new StateBus(stats, registry)
const salv = calculateSalvationStats(10)
bus.applyState({
stateNameOrId: 'salvation',
slvl: 10,
stats: {
fireresist: salv.fireResistPct,
coldresist: salv.coldResistPct,
lightresist: salv.lightningResistPct,
},
})
expect(stats.getAccruedStat('fireresist')).toBe(98)
expect(stats.getAccruedStat('coldresist')).toBe(98)
expect(stats.getAccruedStat('lightresist')).toBe(98)
// Strictly zero to poison or magic!
expect(stats.getAccruedStat('poisonresist')).toBe(0)
expect(stats.getAccruedStat('magicresist')).toBe(0)
})
})
})