/** * 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/baker/mpq-archive-source.ts' import { FIXED_ONE, UnitStatList } from '../../../src/server/engine/stat-list.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { AuraScanner, type CorpseContext } from '../../../src/server/engine/aura-scanner.ts' import { executeSUnitDmg, computeEffectiveResistance, type CombatUnitContext, } from '../../../src/server/engine/combat-pipeline.ts' import { calculatePrayerStats, calculateRedemptionStats, calculateHolyShieldStats, calculateVengeanceStats, calculateCleansingStats, calculateMeditationStats, } from '../../../src/server/skills/skills.ts' import { computeDiminishingReturns, compute5BandScaling } from '../../../src/common/data/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) }) }) })