diablo2-web/tests/skills/pal/adv-pal-challenger-stress.t...

697 lines
28 KiB
TypeScript

/**
* Challenger Adversarial Stress Test Suite — Paladin Defensive Auras & Aura Engine (Cohort 4).
*
* Covers 5 Core Mechanical Pillars:
* 1. Aura Pulse Interval & Lingering Cadence (50-frame strict cadence, 55-frame lingering, highest slvl precedence)
* 2. Holy Shield (Defense %, Duration, Smite flat dmg 5-band ladder, dm56 block chance & FBR analysis)
* 3. Redemption (50-tick cadence, 16 subtiles 2:1 iso radius, dm34 chance, life/mana restore, town suspension)
* 4. Cleansing & Meditation Prayer Synergies (free heal pulse, 0 mana drain, hard point blvl enforcement)
* 5. Vengeance Multi-Elemental Packet (independent Fire/Cold/Ltng damage, independent resistances, chill duration)
*/
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, type CorpseContext } from '../../../src/game/engine/aura-scanner.ts'
import {
executeSUnitDmg,
computeEffectiveResistance,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
import {
calculatePrayerStats,
calculateRedemptionStats,
calculateHolyShieldStats,
calculateVengeanceStats,
calculateCleansingStats,
calculateMeditationStats,
} from '../../../src/game/skills.ts'
import { computeDiminishingReturns, compute5BandScaling } from '../../../src/game/engine/calc-ast.ts'
describe('Challenger Adversarial Stress — Paladin Defensive Auras & Aura Engine', () => {
// ==========================================================================
// Pillar 1: Aura Pulse Interval & Lingering Cadence
// ==========================================================================
describe('Pillar 1: Aura Pulse Interval & Lingering Cadence', () => {
it('1.1 Strict 50-Frame Cadence: verifies periodic pulse triggers exactly every 50 frames across 500 frames', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
})
const pal: CombatUnitContext = {
id: 'paladin_pulse',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
const enemyStats = new UnitStatList(registry, {
maxhp: 100000 * FIXED_ONE,
hitpoints: 100000 * FIXED_ONE,
})
const enemy: CombatUnitContext = {
id: 'pulse_dummy',
name: 'Dummy',
statList: enemyStats,
stateBus: new StateBus(enemyStats, registry),
unitType: 'monster',
x: 10,
y: 10,
}
// Activate Holy Shock (118) with startTick = 0
const source = scanner.setActiveAura(pal, 118, 10, 0)!
expect(source.pulseIntervalTicks).toBe(50)
const pulseFrames: number[] = []
let totalDamage = 0
for (let tick = 0; tick <= 500; tick++) {
const outcomes = scanner.tick(tick, [pal], [enemy])
if (outcomes.length > 0 && outcomes[0]!.pulseDamageDealt > 0) {
pulseFrames.push(tick)
totalDamage += outcomes[0]!.pulseDamageDealt
}
}
// Expected pulse frames: 0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500 (11 total)
expect(pulseFrames.length).toBe(11)
expect(pulseFrames).toEqual([0, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500])
expect(totalDamage).toBeGreaterThan(0)
})
it('1.2 Lingering Duration & Boundary Eviction: buff persists for 55 frames (2.2s) when ally leaves radius', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palStats = new UnitStatList(registry)
const pal: CombatUnitContext = {
id: 'paladin_caster',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
const allyStats = new UnitStatList(registry)
const allyBus = new StateBus(allyStats, registry)
const ally: CombatUnitContext = {
id: 'moving_ally',
name: 'Ally',
statList: allyStats,
stateBus: allyBus,
unitType: 'party',
x: 0,
y: 0,
}
// Might slvl 10 (+130% ED)
scanner.setActiveAura(pal, 98, 10, 0)
scanner.tick(0, [pal, ally], [])
// Initial state at tick 0
expect(allyBus.hasState('might')).toBe(true)
expect(allyStats.getModifierBonus('damagepercent')).toBe(130)
// Ally moves far out of range at tick 1
;(ally as { x?: number; y?: number }).x = 1000
;(ally as { x?: number; y?: number }).y = 1000
// Intermediate ticks (1 through 54) - ally still has lingering buff
for (const t of [1, 10, 25, 49, 50, 54]) {
scanner.tick(t, [pal, ally], [])
allyBus.tick(t)
expect(allyBus.hasState('might')).toBe(true)
expect(allyStats.getModifierBonus('damagepercent')).toBe(130)
}
// At tick 55 (50 + 5 duration expired), state is evicted
scanner.tick(55, [pal, ally], [])
allyBus.tick(55)
expect(allyBus.hasState('might')).toBe(false)
expect(allyStats.getModifierBonus('damagepercent')).toBe(0)
})
it('1.3 Highest Level Precedence: differential testing across slvl pairs guarantees highest level overrides lower', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
// Test 20 distinct slvl pairs (slvlA vs slvlB)
const testPairs = [
[1, 10], [10, 1], [5, 20], [20, 5], [15, 15],
[8, 12], [14, 7], [3, 25], [30, 2], [18, 18],
[2, 4], [9, 11], [16, 16], [22, 19], [7, 28],
[13, 6], [21, 23], [4, 17], [29, 29], [12, 24],
]
for (const [slvlA, slvlB] of testPairs) {
const palAStats = new UnitStatList(registry)
const palA: CombatUnitContext = {
id: `palA_${slvlA}_${slvlB}`,
name: 'Paladin A',
statList: palAStats,
stateBus: new StateBus(palAStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
const palBStats = new UnitStatList(registry)
const palB: CombatUnitContext = {
id: `palB_${slvlA}_${slvlB}`,
name: 'Paladin B',
statList: palBStats,
stateBus: new StateBus(palBStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
const allyStats = new UnitStatList(registry)
const allyBus = new StateBus(allyStats, registry)
const ally: CombatUnitContext = {
id: `ally_${slvlA}_${slvlB}`,
name: 'Ally',
statList: allyStats,
stateBus: allyBus,
unitType: 'party',
x: 5,
y: 5,
}
const auraA = scanner.setActiveAura(palA, 98, slvlA!, 0)!
const auraB = scanner.setActiveAura(palB, 98, slvlB!, 0)!
scanner.pulseAura(auraA, 0, [palA, ally], [])
scanner.pulseAura(auraB, 0, [palB, ally], [])
const expectedSlvl = Math.max(slvlA!, slvlB!)
const expectedEd = 40 + (expectedSlvl - 1) * 10
expect(allyStats.getModifierBonus('damagepercent')).toBe(expectedEd)
}
})
it('1.4 Dynamic Aura Handoff: lower level aura takes over when higher level aura expires', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palAStats = new UnitStatList(registry)
const palA: CombatUnitContext = { id: 'palA', name: 'Pal A', statList: palAStats, stateBus: new StateBus(palAStats, registry), unitType: 'player', x: 0, y: 0 }
const palBStats = new UnitStatList(registry)
const palB: CombatUnitContext = { id: 'palB', name: 'Pal B', statList: palBStats, stateBus: new StateBus(palBStats, registry), unitType: 'player', x: 0, y: 0 }
const allyStats = new UnitStatList(registry)
const allyBus = new StateBus(allyStats, registry)
const ally: CombatUnitContext = { id: 'ally', name: 'Ally', statList: allyStats, stateBus: allyBus, unitType: 'party', x: 0, y: 0 }
const aA = scanner.setActiveAura(palA, 98, 10, 0)! // slvl 10 (+130%)
const aB = scanner.setActiveAura(palB, 98, 20, 0)! // slvl 20 (+230%)
// Both pulse at tick 0
scanner.pulseAura(aA, 0, [palA, ally], [])
scanner.pulseAura(aB, 0, [palB, ally], [])
expect(allyStats.getModifierBonus('damagepercent')).toBe(230)
// Paladin B leaves at tick 1
;(palB as { x?: number; y?: number }).x = 2000
;(palB as { x?: number; y?: number }).y = 2000
// Tick 50: Pal A pulses (slvl 10), Pal B does not reach ally
scanner.pulseAura(aA, 50, [palA, ally], [])
scanner.pulseAura(aB, 50, [palB, ally], [])
// Slvl 20 buff is still active until frame 55
expect(allyStats.getModifierBonus('damagepercent')).toBe(230)
// Tick 55: Pal B slvl 20 buff expires, Pal A slvl 10 (valid until frame 105) seamlessly assumes control
allyBus.tick(55)
expect(allyStats.getModifierBonus('damagepercent')).toBe(130)
})
})
// ==========================================================================
// Pillar 2: Holy Shield Mechanics & Parity Probing
// ==========================================================================
describe('Pillar 2: Holy Shield Mechanics & Parity Probing', () => {
it('2.1 Defense Bonus % & Defiance Synergy: strictly follows 25 + (slvl-1)*15 + Defiance.blvl*15', () => {
// Test matrix of slvl and Defiance blvl
for (const slvl of [1, 5, 10, 20, 30]) {
for (const defBlvl of [0, 5, 10, 20]) {
const stats = calculateHolyShieldStats(slvl, defBlvl)
const expected = 25 + (slvl - 1) * 15 + defBlvl * 15
expect(stats.defensePct).toBe(expected)
}
}
})
it('2.2 Duration Scaling: strictly follows 750 + (slvl - 1) * 625 frames', () => {
expect(calculateHolyShieldStats(1).durationFrames).toBe(750) // 30s
expect(calculateHolyShieldStats(2).durationFrames).toBe(1375) // 55s
expect(calculateHolyShieldStats(10).durationFrames).toBe(6375) // 255s
expect(calculateHolyShieldStats(20).durationFrames).toBe(12625) // 505s
expect(calculateHolyShieldStats(30).durationFrames).toBe(18875) // 755s
})
it('2.3 Smite Flat Damage 5-Band Ladder Parity vs Skills.txt', () => {
// 1.13c Skills.txt: minDam=3, minLev1=2, minLev2=3, minLev3=4, minLev4=4, minLev5=4
// maxDam=6, maxLev1=2, maxLev2=3, maxLev3=4, maxLev4=4, maxLev5=4
for (const slvl of [1, 5, 8, 12, 16, 20, 25, 30]) {
const hs = calculateHolyShieldStats(slvl)
const expectedMin = compute5BandScaling(slvl, 3, 2, 3, 4, 4, 4)
const expectedMax = compute5BandScaling(slvl, 6, 2, 3, 4, 4, 4)
expect(hs.smiteMinFlat).toBe(expectedMin)
expect(hs.smiteMaxFlat).toBe(expectedMax)
}
})
it('2.4 Blocking Chance Formula Parity: validates dm56 D2Common ground truth across levels 1..20', () => {
// D2Common dm56 formula: 10 + Math.trunc(3300 * lvl / (100 * (lvl + 6)))
// calculateHolyShieldStats formula aligned to authentic computeDiminishingReturns(10, 40, lvl)
const discrepancies: { slvl: number; hs: number; d2common: number }[] = []
for (let slvl = 1; slvl <= 20; slvl++) {
const hs = calculateHolyShieldStats(slvl).blockChancePct
const d2common = computeDiminishingReturns(10, 40, slvl)
if (hs !== d2common) {
discrepancies.push({ slvl, hs, d2common })
}
}
// Assert 100% parity with zero discrepancies across levels 1..20
expect(discrepancies.length).toBe(0)
expect(calculateHolyShieldStats(1).blockChancePct).toBe(14)
expect(calculateHolyShieldStats(20).blockChancePct).toBe(35)
})
})
// ==========================================================================
// Pillar 3: Redemption Corpse Scanning & Consumption Engine
// ==========================================================================
describe('Pillar 3: Redemption Corpse Scanning & Consumption Engine', () => {
it('3.1 50-Tick Cadence & 2:1 Isometric Spatial Radius (16 subtiles / ~213px)', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE, mana: 200 * FIXED_ONE })
const pal: CombatUnitContext = {
id: 'paladin_redempt',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
// Corpse positions in 2:1 isometric ground space: Math.hypot(dx, dy * 2)
const corpses: CorpseContext[] = [
{ id: 'c_in_1', consumed: false, x: 100, y: 50 }, // dist = hypot(100, 100) = 141.4 <= 213 (IN)
{ id: 'c_in_2', consumed: false, x: 0, y: 100 }, // dist = hypot(0, 200) = 200 <= 213 (IN)
{ id: 'c_out_1', consumed: false, x: 150, y: 100 }, // dist = hypot(150, 200) = 250 > 213 (OUT)
{ id: 'c_out_2', consumed: false, x: 220, y: 0 }, // dist = hypot(220, 0) = 220 > 213 (OUT)
]
const aura = scanner.setActiveAura(pal, 124, 10, 0)! // pulseIntervalTicks = 50
expect(aura.pulseIntervalTicks).toBe(50)
// Pulse with 100% deterministic success
const outcome = scanner.pulseAura(aura, 0, [pal], [], corpses, { rollChanceFn: () => true })
expect(outcome.corpsesRedeemed).toBe(2)
expect(corpses[0]!.consumed).toBe(true)
expect(corpses[1]!.consumed).toBe(true)
expect(corpses[2]!.consumed).toBe(false)
expect(corpses[3]!.consumed).toBe(false)
})
it('3.2 dm34 Redemption Chance % & Binomial Convergence over 1,000 trials', async () => {
const stats1 = calculateRedemptionStats(1)
expect(stats1.redemptionChancePct).toBe(24) // 24%
const stats10 = calculateRedemptionStats(10)
expect(stats10.redemptionChancePct).toBe(71) // 71%
const stats20 = calculateRedemptionStats(20)
expect(stats20.redemptionChancePct).toBe(86) // 86%
// Pseudo-random binomial trial: 1,000 rolls at slvl 10 (71% theoretical)
let successes = 0
const totalTrials = 1000
for (let i = 0; i < totalTrials; i++) {
if (Math.random() * 100 < stats10.redemptionChancePct) {
successes++
}
}
const empiricalRate = successes / totalTrials
// Assert empirical rate is within [66%, 76%] (within 3 standard deviations: sigma = sqrt(1000*0.71*0.29) ~= 14.3)
expect(empiricalRate).toBeGreaterThan(0.66)
expect(empiricalRate).toBeLessThan(0.76)
})
it('3.3 Corpse Consumption Lifecycle & Clamping at Max HP/Mana', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 950 * FIXED_ONE, // Missing 50 HP
maxmana: 500 * FIXED_ONE,
mana: 450 * FIXED_ONE, // Missing 50 Mana
})
const pal: CombatUnitContext = {
id: 'pal_clamp',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
// Slvl 20 Redemption restores 120 HP & Mana per corpse
const corpses: CorpseContext[] = [
{ id: 'c1', consumed: false, x: 10, y: 10 },
{ id: 'c2', consumed: false, x: 20, y: 20 },
]
const aura = scanner.setActiveAura(pal, 124, 20, 0)!
const outcome = scanner.pulseAura(aura, 0, [pal], [], corpses, { rollChanceFn: () => true })
expect(outcome.corpsesRedeemed).toBe(2)
// Clamped to 1000 max HP and 500 max Mana
expect(palStats.getHp256()).toBe(1000 * FIXED_ONE)
expect(palStats.getMana256()).toBe(500 * FIXED_ONE)
expect(outcome.healthRestored).toBe(50)
expect(outcome.manaRestored).toBe(50)
// Pulsing again on the same corpses must yield 0 redeemed
const secondOutcome = scanner.pulseAura(aura, 50, [pal], [], corpses, { rollChanceFn: () => true })
expect(secondOutcome.corpsesRedeemed).toBe(0)
})
it('3.4 Town Safety Suspension: Redemption is strictly disabled in town', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const pal: CombatUnitContext = {
id: 'pal_town',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
const corpses: CorpseContext[] = [{ id: 'town_corpse', consumed: false, x: 5, y: 5 }]
const aura = scanner.setActiveAura(pal, 124, 20, 0)!
const outcome = scanner.pulseAura(aura, 0, [pal], [], corpses, { isTown: true, rollChanceFn: () => true })
expect(outcome.corpsesRedeemed).toBe(0)
expect(corpses[0]!.consumed).toBe(false)
expect(palStats.getHp256()).toBe(500 * FIXED_ONE)
})
})
// ==========================================================================
// Pillar 4: Cleansing & Meditation Prayer Synergies
// ==========================================================================
describe('Pillar 4: Cleansing & Meditation Prayer Synergies', () => {
it('4.1 Cleansing Free Prayer Heal Pulse: heals at Prayer rate with hard points, consumes 0 mana', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 200 * FIXED_ONE,
mana: 200 * FIXED_ONE,
})
palStats.setBaseSkillLevel(99, 10) // 10 hard points in Prayer -> 11 HP/pulse
const palBus = new StateBus(palStats, registry)
const pal: CombatUnitContext = {
id: 'pal_cleanse',
name: 'Paladin',
statList: palStats,
stateBus: palBus,
unitType: 'player',
x: 0,
y: 0,
}
// Activate Cleansing (109)
scanner.setActiveAura(pal, 109, 1, 0)
// Run 100 frames (2 pulses: frame 0 and frame 50)
for (let t = 0; t <= 100; t++) {
scanner.tick(t, [pal], [])
}
// 3 pulses executed (tick 0, 50, 100): 3 * 11 = 33 HP healed
expect(palStats.getHp256()).toBe((500 + 33) * FIXED_ONE)
// Zero mana consumed!
expect(palStats.getMana256()).toBe(200 * FIXED_ONE)
})
it('4.2 Meditation Free Prayer Heal Pulse: heals at Prayer rate with hard points, consumes 0 mana', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 200 * FIXED_ONE,
mana: 200 * FIXED_ONE,
})
palStats.setBaseSkillLevel(99, 20) // 20 hard points in Prayer -> 25 HP/pulse
const palBus = new StateBus(palStats, registry)
const pal: CombatUnitContext = {
id: 'pal_medit',
name: 'Paladin',
statList: palStats,
stateBus: palBus,
unitType: 'player',
x: 0,
y: 0,
}
// Activate Meditation (120)
scanner.setActiveAura(pal, 120, 1, 0)
// Pulse at tick 0 and tick 50
scanner.tick(0, [pal], [])
scanner.tick(50, [pal], [])
// 2 * 25 = 50 HP healed
expect(palStats.getHp256()).toBe((500 + 50) * FIXED_ONE)
// Zero mana consumed!
expect(palStats.getMana256()).toBe(200 * FIXED_ONE)
})
it('4.3 Prayer Continuous Mana Drain & Depletion Deactivation', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
// Start with exactly 10 Mana (2560 in 256-fixed units).
// Rate is 16 units per frame. 2560 / 16 = 160 frames until depletion!
const palStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 10 * FIXED_ONE,
})
const palBus = new StateBus(palStats, registry)
const pal: CombatUnitContext = {
id: 'pal_prayer_drain',
name: 'Paladin',
statList: palStats,
stateBus: palBus,
unitType: 'player',
x: 0,
y: 0,
}
scanner.setActiveAura(pal, 99, 1, 0)
scanner.tick(0, [pal], [])
expect(palBus.hasState('prayer')).toBe(true)
// Advance clock until mana drops to 0
for (let t = 1; t <= 165; t++) {
scanner.tick(t, [pal], [])
}
// Depleted mana and state removed
expect(palStats.getMana256()).toBe(0)
expect(palBus.hasState('prayer')).toBe(false)
})
it('4.4 Hard Points Only Synergy Invariant: soft points (+skills) without hard points give 0 synergy heal', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const palStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 500 * FIXED_ONE,
})
// +20 all skills and +10 bonus skills, but 0 hard points!
palStats.setAllSkillsBonus(20)
palStats.setBonusSkillLevel(99, 10)
expect(palStats.getBaseSkillLevel(99)).toBe(0)
expect(palStats.getEffectiveSkillLevel(99)).toBe(30)
const pal: CombatUnitContext = {
id: 'pal_soft',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
const aura = scanner.setActiveAura(pal, 109, 1, 0)! // Cleansing slvl 1
const outcome = scanner.pulseAura(aura, 0, [pal], [])
// Zero health restored because blvl is 0!
expect(outcome.healthRestored).toBe(0)
expect(palStats.getHp256()).toBe(500 * FIXED_ONE)
})
})
// ==========================================================================
// Pillar 5: Vengeance Multi-Elemental Packet
// ==========================================================================
describe('Pillar 5: Vengeance Multi-Elemental Packet', () => {
it('5.1 Multi-Elemental Percentage Scaling with Synergies', () => {
// Slvl 1: 70% Fire, Cold, Lightning
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, Resist Cold 5, Salvation 5:
// Base: 70 + 9*6 = 124%
// Fire: 124 + 10*10 + 5*2 = 234%
// Cold: 124 + 5*10 + 5*2 = 184%
// Ltng: 124 + 0*10 + 5*2 = 134%
// Chill: 30 + 9*15 = 165 frames
const v10 = calculateVengeanceStats(10, {
resistFire: 10,
resistCold: 5,
salvation: 5,
})
expect(v10.firePct).toBe(234)
expect(v10.coldPct).toBe(184)
expect(v10.ltngPct).toBe(134)
expect(v10.chillDurationFrames).toBe(165)
})
it('5.2 Independent Resistance Calculations against Distinct Elemental Immunities', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry)
const attacker: CombatUnitContext = {
id: 'avenger',
name: 'Paladin',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const mep = {
firePct: 100,
coldPct: 100,
ltngPct: 100,
chillDurationFrames: 50,
}
// Case A: Fire Immune (120%), 0% Cold, 0% Ltng
const dFireStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, fireresist: 120, coldresist: 0, lightresist: 0, damageresist: 0 })
const dFire: CombatUnitContext = { id: 'dFire', name: 'Fire Immune', statList: dFireStats, stateBus: new StateBus(dFireStats, registry), unitType: 'monster' }
const outA = executeSUnitDmg(attacker, dFire, { skillId: 111, attackKind: 'melee', srcDam: 128, autoHit: true, multiElemPacket: mep })
// Phys: 100, Fire: 0, Cold: 100, Ltng: 100 -> Elem = 200
expect(outA.physDamage256).toBe(100 * FIXED_ONE)
expect(outA.elemDamage256).toBe(200 * FIXED_ONE)
expect(dFire.stateBus.hasState('freeze')).toBe(true)
// Case B: Cold Immune (120%), 0% Fire, 0% Ltng
const dColdStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, fireresist: 0, coldresist: 120, lightresist: 0, damageresist: 0 })
const dCold: CombatUnitContext = { id: 'dCold', name: 'Cold Immune', statList: dColdStats, stateBus: new StateBus(dColdStats, registry), unitType: 'monster' }
const outB = executeSUnitDmg(attacker, dCold, { skillId: 111, attackKind: 'melee', srcDam: 128, autoHit: true, multiElemPacket: mep })
// Phys: 100, Fire: 100, Cold: 0, Ltng: 100 -> Elem = 200
expect(outB.physDamage256).toBe(100 * FIXED_ONE)
expect(outB.elemDamage256).toBe(200 * FIXED_ONE)
// Cold immune monster MUST NOT be chilled!
expect(dCold.stateBus.hasState('freeze')).toBe(false)
// Case C: Lightning Immune (120%), 0% Fire, 0% Cold
const dLtngStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, fireresist: 0, coldresist: 0, lightresist: 120, damageresist: 0 })
const dLtng: CombatUnitContext = { id: 'dLtng', name: 'Ltng Immune', statList: dLtngStats, stateBus: new StateBus(dLtngStats, registry), unitType: 'monster' }
const outC = executeSUnitDmg(attacker, dLtng, { skillId: 111, attackKind: 'melee', srcDam: 128, autoHit: true, multiElemPacket: mep })
// Phys: 100, Fire: 100, Cold: 100, Ltng: 0 -> Elem = 200
expect(outC.physDamage256).toBe(100 * FIXED_ONE)
expect(outC.elemDamage256).toBe(200 * FIXED_ONE)
// Case D: Tri-Resist (Fire 50%, Cold 75%, Ltng 25%)
const dTriStats = new UnitStatList(registry, { hitpoints: 10000 * FIXED_ONE, fireresist: 50, coldresist: 75, lightresist: 25, damageresist: 0 })
const dTri: CombatUnitContext = { id: 'dTri', name: 'Tri Res', statList: dTriStats, stateBus: new StateBus(dTriStats, registry), unitType: 'monster' }
const outD = executeSUnitDmg(attacker, dTri, { skillId: 111, attackKind: 'melee', srcDam: 128, autoHit: true, multiElemPacket: mep })
// Fire: 100 * 0.5 = 50, Cold: 100 * 0.25 = 25, Ltng: 100 * 0.75 = 75 -> Elem = 150
expect(outD.physDamage256).toBe(100 * FIXED_ONE)
expect(outD.elemDamage256).toBe(150 * FIXED_ONE)
})
it('5.3 Cannot Be Frozen Defender Immunity to Vengeance Chill State', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry)
const attacker: CombatUnitContext = {
id: 'avenger',
name: 'Paladin',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const cbfStats = new UnitStatList(registry, {
hitpoints: 10000 * FIXED_ONE,
coldresist: 0,
cannot_be_frozen: 1,
})
const cbfDefender: CombatUnitContext = {
id: 'cbf_target',
name: 'CBF Target',
statList: cbfStats,
stateBus: new StateBus(cbfStats, registry),
unitType: 'monster',
}
const out = executeSUnitDmg(attacker, cbfDefender, {
skillId: 111,
attackKind: 'melee',
srcDam: 128,
autoHit: true,
multiElemPacket: { firePct: 50, coldPct: 50, ltngPct: 50, chillDurationFrames: 100 },
})
// Cold damage is still dealt
expect(out.elemDamage256).toBe(150 * FIXED_ONE)
// Freeze state must NOT be applied due to Cannot Be Frozen!
expect(cbfDefender.stateBus.hasState('freeze')).toBe(false)
})
})
})