/** * Diablo II: Lord of Destruction v1.13c — Adversarial Challenger Verification Suite: Iteration 2 * Focus: Amazon Bow & Passive Skills Remediation Verification * * Empirical challenger verification probing: * 1. Multiple Shot Center Arrow Count: * - Exhaustive sweep across arrow counts 1..24 and up to 50: * Confirm that when arrowCount >= 2, exactly 2 central arrows are flagged * (indices Math.floor(count/2) - 1 and Math.floor(count/2)), for both odd and even counts. * 2. Multiple Shot Proc & Leech Suppression: * - Verify that outer arrows with canTriggerProcs === false have Life Tap, * life leech, mana leech, knockback, and On-Striking procs strictly suppressed in combat execution. * 3. Strafe Ammo Consumption: * - Verify that casting Strafe (skill ID 26) decrements the quiver ammo by exactly 1 arrow per cast, * and does not decrement per individual missile in the volley or drop below 0. * * Ground Truth: * - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt * - D2MOO C++ reference: SkillAma.cpp (515-560, 253), MissMode.cpp (4748), SUnitDmg.cpp (1135) */ import { describe, expect, it } from 'vitest' import { generateMultipleShotAngles, isMultipleShotCenterArrow, calculateMultipleShotStats, calculateStrafeStats, } from '../../../src/common/skills/amazon-bow.ts' import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/server/engine/missile-engine.ts' import { UnitStatList, FIXED_ONE } from '../../../src/server/engine/stat-list.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket, } from '../../../src/server/engine/combat-pipeline.ts' import { AnimDispatcher } from '../../../src/server/engine/anim-dispatcher.ts' function createCombatTarget( id: string, pos: { x: number; y: number }, registry: any, opts?: { hp?: number maxHp?: number mana?: number maxMana?: number isMoving?: boolean def?: number weaponMin?: number weaponMax?: number } ): CombatUnitContext { const statList = new UnitStatList(registry, { level: 80, hitpoints: (opts?.hp ?? 10000) * FIXED_ONE, maxhp: (opts?.maxHp ?? opts?.hp ?? 10000) * FIXED_ONE, mana: (opts?.mana ?? 500) * FIXED_ONE, maxmana: (opts?.maxMana ?? opts?.mana ?? 500) * FIXED_ONE, armorclass: opts?.def ?? 400, fireresist: 0, coldresist: 0, lightresist: 0, poisonresist: 0, damageresist: 0, magicresist: 0, x: pos.x, y: pos.y, }) const stateBus = new StateBus(statList, registry) return { id, name: id, statList, stateBus, isMoving: opts?.isMoving ?? false, ...(opts?.weaponMin !== undefined ? { weaponMinPhys: opts.weaponMin } : {}), ...(opts?.weaponMax !== undefined ? { weaponMaxPhys: opts.weaponMax } : {}), x: pos.x, y: pos.y, } } describe('Adversarial Challenger Verification Suite: Amazon Iteration 2 Remediations', () => { // ========================================================================= // 1. Multiple Shot Center Arrow Count Verification // ========================================================================= describe('1. Multiple Shot Center Arrow Count (1.13c Ground Truth: exactly 2 central arrows for count >= 2)', () => { it('verifies count = 1 edge case flags exactly 1 arrow at index 0', () => { expect(isMultipleShotCenterArrow(0, 1)).toBe(true) expect(isMultipleShotCenterArrow(1, 1)).toBe(false) expect(isMultipleShotCenterArrow(-1, 1)).toBe(false) expect(isMultipleShotCenterArrow(99, 1)).toBe(false) }) it('exhaustively sweeps arrow counts 1..24 and verifies exactly 2 central arrows flagged for count >= 2', () => { // Differential Oracle function oracleCenterIndices(count: number): number[] { if (count <= 0) return [] if (count === 1) return [0] const mid2 = Math.floor(count / 2) const mid1 = mid2 - 1 return [mid1, mid2] } for (let count = 1; count <= 24; count++) { const flaggedIndices: number[] = [] for (let idx = 0; idx < count; idx++) { if (isMultipleShotCenterArrow(idx, count)) { flaggedIndices.push(idx) } } const expected = oracleCenterIndices(count) expect(flaggedIndices).toEqual(expected) if (count >= 2) { // Invariant: Exactly 2 arrows flagged expect(flaggedIndices.length).toBe(2) const mid2 = Math.floor(count / 2) const mid1 = mid2 - 1 expect(flaggedIndices[0]).toBe(mid1) expect(flaggedIndices[1]).toBe(mid2) // Ensure both indices are strictly distinct and within bounds expect(mid1).toBeGreaterThanOrEqual(0) expect(mid2).toBeLessThan(count) expect(mid1).toBeLessThan(mid2) } else { expect(flaggedIndices.length).toBe(1) expect(flaggedIndices[0]).toBe(0) } } }) it('verifies odd vs even counts explicitly across representative slvls', () => { // slvl 1: arrowCount = 2 + 1 = 3 (odd) -> indices [0, 1] const odd3 = [0, 1, 2].filter(i => isMultipleShotCenterArrow(i, 3)) expect(odd3).toEqual([0, 1]) // slvl 2: arrowCount = 2 + 2 = 4 (even) -> indices [1, 2] const even4 = [0, 1, 2, 3].filter(i => isMultipleShotCenterArrow(i, 4)) expect(even4).toEqual([1, 2]) // slvl 3: arrowCount = 2 + 3 = 5 (odd) -> indices [1, 2] const odd5 = [0, 1, 2, 3, 4].filter(i => isMultipleShotCenterArrow(i, 5)) expect(odd5).toEqual([1, 2]) // slvl 5: arrowCount = 7 (odd) -> indices [2, 3] const odd7 = [0, 1, 2, 3, 4, 5, 6].filter(i => isMultipleShotCenterArrow(i, 7)) expect(odd7).toEqual([2, 3]) // slvl 6: arrowCount = 8 (even) -> indices [3, 4] const even8 = [0, 1, 2, 3, 4, 5, 6, 7].filter(i => isMultipleShotCenterArrow(i, 8)) expect(even8).toEqual([3, 4]) // slvl 9: arrowCount = 11 (odd) -> indices [4, 5] const odd11 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].filter(i => isMultipleShotCenterArrow(i, 11)) expect(odd11).toEqual([4, 5]) // slvl 20: arrowCount = 22 (even) -> indices [10, 11] const even22 = Array.from({ length: 22 }, (_, i) => i).filter(i => isMultipleShotCenterArrow(i, 22)) expect(even22).toEqual([10, 11]) // slvl 22: arrowCount = 24 (even, standard max volley) -> indices [11, 12] const even24 = Array.from({ length: 24 }, (_, i) => i).filter(i => isMultipleShotCenterArrow(i, 24)) expect(even24).toEqual([11, 12]) }) it('verifies boundary and degenerate inputs', () => { // Degenerate non-positive counts expect(isMultipleShotCenterArrow(0, 0)).toBe(false) expect(isMultipleShotCenterArrow(0, -5)).toBe(false) expect(isMultipleShotCenterArrow(-1, 5)).toBe(false) expect(isMultipleShotCenterArrow(5, 5)).toBe(false) expect(isMultipleShotCenterArrow(6, 5)).toBe(false) // High arrow counts up to 50 for (let count = 25; count <= 50; count++) { const flagged = Array.from({ length: count }, (_, i) => i).filter(i => isMultipleShotCenterArrow(i, count) ) expect(flagged.length).toBe(2) expect(flagged[0]).toBe(Math.floor(count / 2) - 1) expect(flagged[1]).toBe(Math.floor(count / 2)) } }) }) // ========================================================================= // 2. Multiple Shot Proc & Leech Suppression // ========================================================================= describe('2. Multiple Shot Proc & Leech Suppression (1.13c Ground Truth: canTriggerProcs === false)', () => { it('verifies spawnMultipleShotVolley sets canTriggerProcs appropriately on center and outer missiles', async () => { const registry = await getSharedDataRegistry() const missileEngine = new MissileEngine(registry) const owner = createCombatTarget('player-ama', { x: 0, y: 0 }, registry) // slvl 3 -> 5 arrows (indices 0, 1, 2, 3, 4). Center indices are 1 and 2. const missiles = missileEngine.spawnMultipleShotVolley({ sourceSkillId: 12, slvl: 3, owner, startX: 0, startY: 0, targetX: 200, targetY: 0, dmgPacket: { skillId: 12, attackKind: 'missile', flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, knockback: true, }, }) expect(missiles.length).toBe(5) // Check each missile's proc flag against center calculation for (let i = 0; i < missiles.length; i++) { const expectedCenter = isMultipleShotCenterArrow(i, 5) expect(missiles[i]!.canTriggerProcs).toBe(expectedCenter) expect(missiles[i]!.dmgPacket.canTriggerProcs).toBe(expectedCenter) } }) it('verifies combat pipeline strictly suppresses Life Tap healing when canTriggerProcs === false', async () => { const registry = await getSharedDataRegistry() // Attacker has initial 500 HP out of 2000 Max HP (allowing headroom for healing) const owner = createCombatTarget('attacker', { x: 0, y: 0 }, registry, { hp: 500, maxHp: 2000, weaponMin: 0, weaponMax: 0, }) const target = createCombatTarget('defender', { x: 50, y: 0 }, registry, { hp: 1000 }) // Apply Life Tap to defender (50% physical damage healed to attacker) target.stateBus.applyState({ stateNameOrId: 'lifetap', slvl: 1, durationFrames: 600, }) const baseHp = owner.statList.getHp256() // Outer arrow: canTriggerProcs === false const outerPacket: SUnitDmgPacket = { skillId: 12, attackKind: 'missile', flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, canTriggerProcs: false, } const resOuter = executeSUnitDmg(owner, target, outerPacket) expect(resOuter.hit).toBe(true) expect(resOuter.procsSuppressed).toBe(true) expect(resOuter.attackerHealed256).toBeUndefined() expect(owner.statList.getHp256()).toBe(baseHp) // Zero healing // Center arrow: canTriggerProcs === true const centerPacket: SUnitDmgPacket = { skillId: 12, attackKind: 'missile', flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, canTriggerProcs: true, } const resCenter = executeSUnitDmg(owner, target, centerPacket) expect(resCenter.hit).toBe(true) expect(resCenter.procsSuppressed).toBeUndefined() const expectedHeal = Math.trunc((resCenter.physDamage256 * 50) / 100) expect(resCenter.attackerHealed256).toBe(expectedHeal) expect(owner.statList.getHp256()).toBe(baseHp + expectedHeal) }) it('verifies combat pipeline strictly suppresses Life Leech and Mana Leech when canTriggerProcs === false', async () => { const registry = await getSharedDataRegistry() // Attacker has initial 400 HP / 2000 Max HP, 200 Mana / 1000 Max Mana (headroom for leech) const owner = createCombatTarget('attacker', { x: 0, y: 0 }, registry, { hp: 400, maxHp: 2000, mana: 200, maxMana: 1000, weaponMin: 0, weaponMax: 0, }) const target = createCombatTarget('defender', { x: 50, y: 0 }, registry, { hp: 2000 }) // Grant attacker 15% Life Leech and 10% Mana Leech owner.statList.addStat('item_parasite', 15) // life leech owner.statList.addStat('item_parasitemana', 10) // mana leech const hpBefore = owner.statList.getHp256() const manaBefore = owner.statList.getMana256() // Outer arrow: canTriggerProcs === false const outerPacket: SUnitDmgPacket = { skillId: 12, attackKind: 'missile', flatPhysMin256: 200 * FIXED_ONE, flatPhysMax256: 200 * FIXED_ONE, autoHit: true, canTriggerProcs: false, } const resOuter = executeSUnitDmg(owner, target, outerPacket) expect(resOuter.hit).toBe(true) expect(resOuter.procsSuppressed).toBe(true) expect(resOuter.attackerHealed256).toBeUndefined() expect(resOuter.attackerManaLeeched256).toBeUndefined() expect(owner.statList.getHp256()).toBe(hpBefore) expect(owner.statList.getMana256()).toBe(manaBefore) // Center arrow: canTriggerProcs === true const centerPacket: SUnitDmgPacket = { skillId: 12, attackKind: 'missile', flatPhysMin256: 200 * FIXED_ONE, flatPhysMax256: 200 * FIXED_ONE, autoHit: true, canTriggerProcs: true, } const resCenter = executeSUnitDmg(owner, target, centerPacket) expect(resCenter.hit).toBe(true) expect(resCenter.procsSuppressed).toBeUndefined() const expectedLifeLeech = Math.trunc((resCenter.physDamage256 * 15) / 100) const expectedManaLeech = Math.trunc((resCenter.physDamage256 * 10) / 100) expect(resCenter.attackerHealed256).toBe(expectedLifeLeech) expect(resCenter.attackerManaLeeched256).toBe(expectedManaLeech) expect(owner.statList.getHp256()).toBe(hpBefore + expectedLifeLeech) expect(owner.statList.getMana256()).toBe(manaBefore + expectedManaLeech) }) it('verifies combat pipeline strictly suppresses Knockback when canTriggerProcs === false', async () => { const registry = await getSharedDataRegistry() const owner = createCombatTarget('attacker', { x: 0, y: 0 }, registry) const target = createCombatTarget('defender', { x: 50, y: 0 }, registry) expect(target.stateBus.hasState('knockback')).toBe(false) // Outer arrow with knockback: true but canTriggerProcs: false const outerPacket: SUnitDmgPacket = { skillId: 12, attackKind: 'missile', flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, knockback: true, canTriggerProcs: false, } const resOuter = executeSUnitDmg(owner, target, outerPacket) expect(resOuter.hit).toBe(true) expect(target.stateBus.hasState('knockback')).toBe(false) // Knockback suppressed! // Center arrow with knockback: true and canTriggerProcs: true const centerPacket: SUnitDmgPacket = { skillId: 12, attackKind: 'missile', flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, knockback: true, canTriggerProcs: true, } const resCenter = executeSUnitDmg(owner, target, centerPacket) expect(resCenter.hit).toBe(true) expect(target.stateBus.hasState('knockback')).toBe(true) // Knockback applied! }) it('verifies outer arrows still deal full damage even while procs and leech are suppressed', async () => { const registry = await getSharedDataRegistry() const owner = createCombatTarget('attacker', { x: 0, y: 0 }, registry) const target = createCombatTarget('defender', { x: 50, y: 0 }, registry, { hp: 1000 }) const hpBefore = target.statList.getHp256() const outerPacket: SUnitDmgPacket = { skillId: 12, attackKind: 'missile', flatPhysMin256: 120 * FIXED_ONE, flatPhysMax256: 120 * FIXED_ONE, autoHit: true, canTriggerProcs: false, } const resOuter = executeSUnitDmg(owner, target, outerPacket) expect(resOuter.hit).toBe(true) expect(resOuter.totalDamage256).toBe(resOuter.physDamage256) expect(target.statList.getHp256()).toBe(hpBefore - resOuter.totalDamage256) }) }) // ========================================================================= // 3. Strafe Ammo Consumption // ========================================================================= describe('3. Strafe Ammo Consumption (1.13c Ground Truth: 1 arrow decremented per cast)', () => { it('verifies AnimDispatcher decrements ammo by exactly 1 per Strafe cast', async () => { const registry = await getSharedDataRegistry() const statList = new UnitStatList(registry, { level: 80, hitpoints: 1000 * FIXED_ONE, maxhp: 1000 * FIXED_ONE, mana: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE, }) const stateBus = new StateBus(statList, registry) const dispatcher = new AnimDispatcher({ registry, charToken: 'AM', statList, stateBus, weaponClass: 'BOW', }) // Set initial quiver ammo dispatcher.setAmmoQuantity(250) expect(dispatcher.getAmmoQuantity()).toBe(250) const skillStrafe = registry.getSkillById(26)! // Cast 1 const cast1 = dispatcher.startSkillAnimation({ skill: skillStrafe, slvl: 10, currentTick: 0, }) expect(cast1.allowed).toBe(true) const anim1 = cast1.animState! for (let t = 0; t <= anim1.framesPerDirection + 2; t++) { dispatcher.tick(t, () => {}) } expect(dispatcher.getAmmoQuantity()).toBe(249) // Exactly 1 arrow consumed // Cast 2 const cast2 = dispatcher.startSkillAnimation({ skill: skillStrafe, slvl: 10, currentTick: 20, }) expect(cast2.allowed).toBe(true) const anim2 = cast2.animState! for (let t = 20; t <= 20 + anim2.framesPerDirection + 2; t++) { dispatcher.tick(t, () => {}) } expect(dispatcher.getAmmoQuantity()).toBe(248) // Exactly 1 more arrow consumed // Cast 10 consecutive times for (let castIdx = 0; castIdx < 10; castIdx++) { const tickBase = 50 + castIdx * 30 const c = dispatcher.startSkillAnimation({ skill: skillStrafe, slvl: 20, currentTick: tickBase, }) expect(c.allowed).toBe(true) for (let t = tickBase; t <= tickBase + c.animState!.framesPerDirection + 2; t++) { dispatcher.tick(t, () => {}) } } // 248 - 10 = 238 expect(dispatcher.getAmmoQuantity()).toBe(238) }) it('verifies Strafe ammo consumption does not reduce ammo below 0', async () => { const registry = await getSharedDataRegistry() const statList = new UnitStatList(registry, { level: 80, hitpoints: 1000 * FIXED_ONE, maxhp: 1000 * FIXED_ONE, }) const stateBus = new StateBus(statList, registry) const dispatcher = new AnimDispatcher({ registry, charToken: 'AM', statList, stateBus, weaponClass: 'BOW', }) // Ammo starts at 1 dispatcher.setAmmoQuantity(1) const skillStrafe = registry.getSkillById(26)! // Cast when ammo is 1 -> reaches 0 const c1 = dispatcher.startSkillAnimation({ skill: skillStrafe, slvl: 1, currentTick: 0, }) expect(c1.allowed).toBe(true) for (let t = 0; t <= c1.animState!.framesPerDirection + 2; t++) { dispatcher.tick(t, () => {}) } expect(dispatcher.getAmmoQuantity()).toBe(0) // Cast when ammo is 0 -> should not go negative (-1) const c2 = dispatcher.startSkillAnimation({ skill: skillStrafe, slvl: 1, currentTick: 20, }) expect(c2.allowed).toBe(true) for (let t = 20; t <= 20 + c2.animState!.framesPerDirection + 2; t++) { dispatcher.tick(t, () => {}) } expect(dispatcher.getAmmoQuantity()).toBe(0) }) it('verifies contrast with Multiple Shot (consumes 1) and Magic Arrow (consumes 0)', async () => { const registry = await getSharedDataRegistry() const statList = new UnitStatList(registry, { level: 80, hitpoints: 1000 * FIXED_ONE, maxhp: 1000 * FIXED_ONE, }) const stateBus = new StateBus(statList, registry) const dispatcher = new AnimDispatcher({ registry, charToken: 'AM', statList, stateBus, weaponClass: 'BOW', }) dispatcher.setAmmoQuantity(100) const skillMultipleShot = registry.getSkillById(12)! const skillMagicArrow = registry.getSkillById(6)! const skillStrafe = registry.getSkillById(26)! // Multiple Shot (skill 12, decquant: true) const msCast = dispatcher.startSkillAnimation({ skill: skillMultipleShot, slvl: 1, currentTick: 0, }) for (let t = 0; t <= msCast.animState!.framesPerDirection + 2; t++) { dispatcher.tick(t, () => {}) } expect(dispatcher.getAmmoQuantity()).toBe(99) // -1 // Magic Arrow (skill 6, decquant: false) const maCast = dispatcher.startSkillAnimation({ skill: skillMagicArrow, slvl: 1, currentTick: 20, }) for (let t = 20; t <= 20 + maCast.animState!.framesPerDirection + 2; t++) { dispatcher.tick(t, () => {}) } expect(dispatcher.getAmmoQuantity()).toBe(99) // unchanged! // Strafe (skill 26, decquant: false in Skills.txt, but D2MOO Parity forces -1) const strafeCast = dispatcher.startSkillAnimation({ skill: skillStrafe, slvl: 1, currentTick: 40, }) for (let t = 40; t <= 40 + strafeCast.animState!.framesPerDirection + 2; t++) { dispatcher.tick(t, () => {}) } expect(dispatcher.getAmmoQuantity()).toBe(98) // -1 }) }) })