diablo2-web/tests/skills/pal/adv-pal-defensive-auras-str...

346 lines
14 KiB
TypeScript

/**
* Adversarial Stress & Edge-Case Suite — Paladin Defensive Auras (Milestone M9).
*
* 5 Critical Scenarios:
* 1. Passive Max Resistance Stacking & Absolute 95% Hard Cap:
* - 20 hard points in Resist Fire (+10%), plus item modifier (+15% max fire res)
* - Total = 75 + 10 + 15 = 100%, strictly clamped to 95% hard ceiling
* - Verifies combat damage taken is mitigated by exactly 95%.
* 2. Redemption Multi-Corpse Race & Spatial Bounds:
* - 10 corpses: 5 within 16 subtiles, 5 outside 16 subtiles
* - Verifies only corpses within 2:1 isometric radius are eligible
* - Consumed corpses cannot be re-redeemed; HP/Mana replenishment is clamped to max
* 3. Cleansing Curse Eviction & Multi-Curse Pressure:
* - Paladin inflicted with Amplify Damage or Decrepify under Cleansing aura
* - Verifies curse expiration frames are curtailed proportionally
* 4. Prayer Mana Exhaustion & Meditation Synergy Boundary:
* - Paladin with low mana starts Prayer; aura auto-deactivates at 0 mana
* - Paladin switches to Meditation with 20 base points in Prayer; receives continuous heal pulses indefinitely with 0 mana drain
* 5. Salvation Elemental Isolation vs Conviction:
* - Party under Salvation attacked by Fire, Cold, Lightning, Poison, and Magic missiles
* - Verifies elemental damage is strongly mitigated while Poison and Magic take unmitigated damage
*/
import { describe, expect, it } from 'vitest'
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'
import { calculateSalvationStats } from '../../../src/game/skills.ts'
describe('Paladin Defensive Auras — Adversarial Stress & Edge-Case Verification', () => {
// --------------------------------------------------------------------------
// Scenario 1: Passive Max Resistance Stacking & Absolute 95% Hard Cap
// --------------------------------------------------------------------------
describe('Scenario 1: Passive Max Resistance Stacking & Absolute 95% Hard Cap', () => {
it('stacks passive hard-point max res with item bonuses and strictly caps at 95%', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
fireresist: 150, // Accrued fire resistance well above 95%
})
// Paladin with 20 base points in Resist Fire -> +10% max fire res
stats.setBaseSkillLevel(100, 20)
expect(stats.getEffectiveMaxResistance('fire')).toBe(85)
// Add item bonus: Guardian Angel (+15% to all max resistances)
stats.addStat('maxfireresist', 15)
// 75 + 10 + 15 = 100% -> Strictly clamped to 95%!
expect(stats.getEffectiveMaxResistance('fire')).toBe(95)
// Add even more max fire res (+10% from Waterwalk / Hotspur)
stats.addStat('maxfireresist', 10)
// 75 + 35 = 110% -> STILL clamped to 95%!
expect(stats.getEffectiveMaxResistance('fire')).toBe(95)
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: stats,
stateBus: new StateBus(stats, registry),
unitType: 'player',
}
const statsAttacker = new UnitStatList(registry)
const attacker: CombatUnitContext = {
id: 'attacker',
name: 'Attacker',
statList: statsAttacker,
stateBus: new StateBus(statsAttacker, registry),
unitType: 'monster',
}
// Attacker hits with 1000 fire damage (in 256-fixed-point)
const out = executeSUnitDmg(attacker, paladin, {
skillId: 0,
attackKind: 'spell',
elemType: 'fire',
elemMin256: 1000 * FIXED_ONE,
elemMax256: 1000 * FIXED_ONE,
autoHit: true,
})
// 1000 fire damage mitigated by 95% = 50 damage taken (50 * 256 = 12800)
expect(out.elemDamage256).toBe(50 * FIXED_ONE)
expect(paladin.statList.getHp256()).toBe(950 * FIXED_ONE)
})
})
// --------------------------------------------------------------------------
// Scenario 2: Redemption Multi-Corpse Race & Spatial Bounds
// --------------------------------------------------------------------------
describe('Scenario 2: Redemption Multi-Corpse Race & Spatial Bounds', () => {
it('only redeems corpses within 2:1 isometric radius, locks them immediately, and clamps HP/Mana to max', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
// Paladin with 100 missing HP and 50 missing Mana
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 900 * FIXED_ONE,
maxmana: 500 * FIXED_ONE,
mana: 450 * FIXED_ONE,
})
const bus = new StateBus(stats, registry)
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: stats,
stateBus: bus,
unitType: 'player',
x: 100,
y: 100,
}
// 10 corpses: 5 within 16 subtiles (~213px in 2:1 space), 5 outside
// Note: in 2:1 isometric space dist = Math.hypot(dx, dy * 2)
const corpses = [
// Within:
{ id: 'c1', consumed: false, x: 120, y: 110 }, // dx=20, dy=10 -> hypot(20, 20) = 28.3px < 213px
{ id: 'c2', consumed: false, x: 80, y: 90 }, // dx=-20, dy=-10 -> hypot(-20, -20) = 28.3px < 213px
{ id: 'c3', consumed: false, x: 100, y: 150 }, // dx=0, dy=50 -> hypot(0, 100) = 100px < 213px
{ id: 'c4', consumed: false, x: 250, y: 100 }, // dx=150, dy=0 -> hypot(150, 0) = 150px < 213px
{ id: 'c5', consumed: false, x: 100, y: 190 }, // dx=0, dy=90 -> hypot(0, 180) = 180px < 213px
// Outside (>213px):
{ id: 'c6', consumed: false, x: 400, y: 100 }, // dx=300 -> 300px > 213px
{ id: 'c7', consumed: false, x: 100, y: 250 }, // dy=150 -> 300px > 213px
{ id: 'c8', consumed: false, x: 300, y: 300 }, // far
{ id: 'c9', consumed: false, x: 0, y: 0 }, // far
{ id: 'c10', consumed: false, x: 500, y: 500 },// far
]
const aura = scanner.setActiveAura(paladin, 124, 20, 0)! // slvl 20: 120 Life & Mana
const outcome = scanner.pulseAura(aura, 0, [paladin], [], corpses, {
rollChanceFn: () => true, // 100% deterministic roll
})
expect(outcome.corpsesRedeemed).toBe(5)
for (let i = 0; i < 5; i++) {
expect(corpses[i]!.consumed).toBe(true)
}
for (let i = 5; i < 10; i++) {
expect(corpses[i]!.consumed).toBe(false)
}
// Clamped strictly to maxhp (1000) and maxmana (500)
expect(paladin.statList.getHp256()).toBe(1000 * FIXED_ONE)
expect(paladin.statList.getMana256()).toBe(500 * FIXED_ONE)
// Subsequent pulse on same corpses must redeem 0
const outcome2 = scanner.pulseAura(aura, 50, [paladin], [], corpses, {
rollChanceFn: () => true,
})
expect(outcome2.corpsesRedeemed).toBe(0)
})
})
// --------------------------------------------------------------------------
// Scenario 3: Cleansing Curse Eviction & Multi-Curse Pressure
// --------------------------------------------------------------------------
describe('Scenario 3: Cleansing Curse Eviction & Multi-Curse Pressure', () => {
it('curtails remaining curse and poison frames under Cleansing aura pulse', 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',
}
// Apply Decrepify curse at frame 0 for 500 frames (expireFrame = 500)
bus.applyState({
stateNameOrId: 'decrepify',
curseTypeOverride: 1,
durationFrames: 500,
currentFrame: 0,
})
// Apply Poison state for 500 frames
bus.applyState({
stateNameOrId: 'poison',
durationFrames: 500,
currentFrame: 0,
})
expect(bus.getState('decrepify')?.expireFrame).toBe(500)
expect(bus.getState('poison')?.expireFrame).toBe(500)
// Slvl 20 Cleansing: 80% reduction
const aura = scanner.setActiveAura(paladin, 109, 20, 0)!
// Pulse at currentFrame = 100
scanner.pulseAura(aura, 100, [paladin], [])
// Remaining frames before pulse: 500 - 100 = 400 frames
// Reduced by 80% -> 400 * 0.20 = 80 frames remaining
// New expireFrame = 100 + 80 = 180
expect(bus.getState('decrepify')?.expireFrame).toBe(180)
expect(bus.getState('poison')?.expireFrame).toBe(180)
})
})
// --------------------------------------------------------------------------
// Scenario 4: Prayer Mana Exhaustion & Meditation Synergy Boundary
// --------------------------------------------------------------------------
describe('Scenario 4: Prayer Mana Exhaustion & Meditation Synergy Boundary', () => {
it('Prayer terminates on mana depletion; switching to Meditation provides infinite heal with zero drain', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
// Paladin with low mana: exactly 20 mana units (20 * 256 = 5120)
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 500 * FIXED_ONE,
mana: 32, // 32 units of mana256 = 2 frames of Prayer
})
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',
}
// 1. Activate Prayer
scanner.setActiveAura(paladin, 99, 20, 0)
scanner.tick(1, [paladin], [])
expect(paladin.statList.getMana256()).toBe(16)
scanner.tick(2, [paladin], [])
expect(paladin.statList.getMana256()).toBe(0)
// Tick 3: drops below 16 -> aura deactivated!
scanner.tick(3, [paladin], [])
expect(paladin.stateBus.hasState('prayer')).toBe(false)
// 2. Paladin now has 0 mana, switches to Meditation (120)
paladin.statList.setMana256(0)
const medAura = scanner.setActiveAura(paladin, 120, 20, 0)!
expect(medAura).toBeDefined()
// Pulse Meditation 5 times
for (let tick = 0; tick < 250; tick += 50) {
scanner.pulseAura(medAura, tick, [paladin], [])
}
// Paladin healed 5 pulses * 25 HP = 125 HP (from 500 to 625 HP)
expect(paladin.statList.getHp256()).toBe(625 * FIXED_ONE)
// Mana remains strictly 0 (no drain occurred!)
expect(paladin.statList.getMana256()).toBe(0)
})
})
// --------------------------------------------------------------------------
// Scenario 5: Salvation Elemental Isolation vs Conviction
// --------------------------------------------------------------------------
describe('Scenario 5: Salvation Elemental Isolation vs Conviction', () => {
it('Salvation strongly mitigates Fire, Cold, Lightning, but provides ZERO protection against Poison and Magic', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 5000 * FIXED_ONE,
hitpoints: 5000 * FIXED_ONE,
})
const bus = new StateBus(stats, registry)
const salv = calculateSalvationStats(20) // +109% Fire, Cold, Lightning
bus.applyState({
stateNameOrId: 'salvation',
slvl: 20,
stats: {
fireresist: salv.fireResistPct,
coldresist: salv.coldResistPct,
lightresist: salv.lightningResistPct,
},
})
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: stats,
stateBus: bus,
unitType: 'player',
}
const statsAtk = new UnitStatList(registry)
const attacker: CombatUnitContext = {
id: 'attacker',
name: 'Attacker',
statList: statsAtk,
stateBus: new StateBus(statsAtk, registry),
unitType: 'monster',
}
// Enemy casts Conviction (-100% resistance)
const convictionPierce = 100
// 1. Fire Attack: 109% (Salvation) - 100% (Conviction) = 9% effective fire res
const outFire = executeSUnitDmg(attacker, paladin, {
skillId: 0,
attackKind: 'spell',
elemType: 'fire',
elemMin256: 1000 * FIXED_ONE,
elemMax256: 1000 * FIXED_ONE,
autoHit: true,
convictionPierce,
})
// 1000 * (100 - 9)% = 910 damage
expect(outFire.elemDamage256).toBe(910 * FIXED_ONE)
// 2. Poison Attack: Salvation gives 0 poison res -> 0% base poison res
const outPois = executeSUnitDmg(attacker, paladin, {
skillId: 0,
attackKind: 'spell',
elemType: 'pois',
elemMin256: 1000 * FIXED_ONE,
elemMax256: 1000 * FIXED_ONE,
autoHit: true,
})
// 1000 * (100 - 0)% = 1000 unmitigated poison damage!
expect(outPois.elemDamage256).toBe(1000 * FIXED_ONE)
// 3. Magic Attack: Salvation gives 0 magic res -> 0% base magic res
const outMag = executeSUnitDmg(attacker, paladin, {
skillId: 0,
attackKind: 'spell',
elemType: 'mag',
elemMin256: 1000 * FIXED_ONE,
elemMax256: 1000 * FIXED_ONE,
autoHit: true,
})
// 1000 * (100 - 0)% = 1000 unmitigated magic damage!
expect(outMag.elemDamage256).toBe(1000 * FIXED_ONE)
})
})
})