/** * Diablo II: Lord of Destruction v1.13c — Amazon Bow & Crossbow Kinematics Stress Suite * * Comprehensive adversarial and E2E kinematics integration verification: * 1. Guided Arrow homing continuous angle steering and strict zero-pierce invariant under 100% Pierce skill. * 2. Multiple Shot 2..24 arrow scaling, symmetrical fan arc, center 2 proc flag, and single-target hit immunity. * 3. Strafe 4..10 arrow scaling, multi-target cycling, and stationary lock-in state. * 4. Immolation Arrow 25-frame casting cooldown and 75-frame ground fire patch ticking. * 5. Freezing Arrow 80 sub-pixel AoE cold splash and solid freeze application. * 6. Magic Arrow physical-to-magic conversion and zero-ammo requirement invariant. * 7. System invariant: BATCH1_SKILLS length strictly maintained at 38; zero mocks. * * Ground Truth: * - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt */ import { describe, expect, it } from 'vitest' import { BATCH1_SKILLS, getSkillManaCost, getSkillCooldownTicks, calculateSkillDamage, } from '../../../src/game/skills.ts' import { calculateMagicArrowStats, calculateFireArrowStats, calculateColdArrowStats, calculateMultipleShotStats, calculateExplodingArrowStats, calculateIceArrowStats, calculateGuidedArrowStats, calculateStrafeStats, calculateImmolationArrowStats, calculateFreezingArrowStats, generateMultipleShotAngles, isMultipleShotCenterArrow, } from '../../../src/game/skills/amazon-bow.ts' import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/game/engine/missile-engine.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts' import { StateBus } from '../../../src/game/engine/state-bus.ts' import type { CombatUnitContext, SUnitDmgPacket } from '../../../src/game/engine/combat-pipeline.ts' function createTestCombatUnit( id: string, pos: { x: number; y: number }, registry: any, opts?: { hp?: number; cannotBeFrozen?: boolean; isBoss?: boolean } ): CombatUnitContext { const statList = new UnitStatList(registry, { level: 80, hitpoints: (opts?.hp ?? 10000) * FIXED_ONE, maxhp: (opts?.hp ?? 10000) * FIXED_ONE, armorclass: 400, fireresist: 0, coldresist: 0, lightresist: 0, poisonresist: 0, damageresist: 0, magicresist: 0, x: pos.x, y: pos.y, ...(opts?.cannotBeFrozen ? { cannot_be_frozen: 1 } : {}), }) const stateBus = new StateBus(statList, registry) return { id, name: id, statList, stateBus, isBoss: opts?.isBoss ?? false, } } describe('Amazon Bow & Crossbow Kinematics Adversarial Stress Suite (1.13c Ground Truth)', () => { it('enforces System Invariants: BATCH1_SKILLS length strictly equals 38 and zero mocks utilized', () => { expect(Object.keys(BATCH1_SKILLS).length).toBe(38) expect(BATCH1_SKILLS[6]).toBeDefined() // Magic Arrow expect(BATCH1_SKILLS[7]).toBeDefined() // Fire Arrow expect(BATCH1_SKILLS[11]).toBeDefined() // Cold Arrow }) describe('Guided Arrow: Homing Kinematics, Continuous Steering & Strict Zero-Pierce', () => { it('strictly forces canPierce = false and pierceChancePct = 0 even with 100% Pierce skill', async () => { const registry = await getSharedDataRegistry() const missileEngine = new MissileEngine(registry) const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry) // Simulate 100% Pierce skill from Skill 33 or Razortail owner.statList.setBaseSkillLevel(33, 20) const dmgPacket: SUnitDmgPacket = { skillId: 22, attackKind: 'missile', srcDam: 128, physDamagePct: 95, // Level 20 GA: +95% ED autoHit: true, } // Spawn Guided Arrow with pierceChancePct: 100 explicitly requested const missile = missileEngine.spawnMissile({ missileNameOrId: 'guidedarrow', sourceSkillId: 22, slvl: 20, owner, startX: 0, startY: 0, targetX: 100, targetY: 0, dmgPacket, pierceChancePct: 100, // Adversarial input: 100% pierce }) expect(missile).not.toBeNull() // In 1.13c, Guided Arrow NEVER pierces under any circumstances expect(missile!.canPierce).toBe(false) expect(missile!.pierceChancePct).toBe(0) }) it('continuously steers towards moving targets in 2:1 isometric space and auto-hits target defense', async () => { const registry = await getSharedDataRegistry() const missileEngine = new MissileEngine(registry) const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry) const targetPos = { x: 100, y: 60 } const target = createTestCombatUnit('moving-target', targetPos, registry) const targetPositions = new Map() targetPositions.set(target.id, targetPos) const dmgPacket: SUnitDmgPacket = { skillId: 22, attackKind: 'missile', srcDam: 128, physDamagePct: 95, autoHit: true, } // Fire in opposite direction (x: -100, y: 0) const missile = missileEngine.spawnMissile({ missileNameOrId: 'guidedarrow', sourceSkillId: 22, slvl: 20, owner, startX: 0, startY: 0, targetX: -100, targetY: 0, dmgPacket, })! const initialAngle = missile.angleRad expect(initialAngle).toBeCloseTo(Math.PI) // Step missile engine ticks and verify steering redirects towards target for (let tick = 1; tick <= 5; tick++) { missileEngine.tick(tick, [target], targetPositions) } // Missile angle should have turned away from Math.PI towards positive angle expect(missile.angleRad).not.toBe(initialAngle) expect(missile.expired).toBe(false) // Now move target directly in front of current missile position and step until hit targetPositions.set(target.id, { x: missile.x + 20, y: missile.y }) const stepRes = missileEngine.tick(6, [target], targetPositions) expect(stepRes.hits.length).toBeGreaterThanOrEqual(1) expect(stepRes.hits[0]!.targetId).toBe(target.id) expect(stepRes.hits[0]!.damage).toBeGreaterThan(0) // Missile must expire immediately on hit (zero pierce) expect(missile.expired).toBe(true) }) }) describe('Multiple Shot: 2..24 Scaling, Fan Distribution, Center Procs & Hit Immunity', () => { it('scales arrow count from 3 at level 1 to hard engine cap of 24 at level 22+', () => { expect(calculateMultipleShotStats(1).arrowCount).toBe(3) expect(calculateMultipleShotStats(5).arrowCount).toBe(7) expect(calculateMultipleShotStats(10).arrowCount).toBe(12) expect(calculateMultipleShotStats(20).arrowCount).toBe(22) expect(calculateMultipleShotStats(22).arrowCount).toBe(24) expect(calculateMultipleShotStats(30).arrowCount).toBe(24) expect(calculateMultipleShotStats(50).arrowCount).toBe(24) }) it('generates symmetric monotonic fan angles within 45-50 degree cone', () => { const angles = generateMultipleShotAngles(0, 7, (50 * Math.PI) / 180) expect(angles.length).toBe(7) // Symmetrical about 0 expect(angles[3]).toBeCloseTo(0) expect(angles[0]).toBeCloseTo(-angles[6]!) expect(angles[1]).toBeCloseTo(-angles[5]!) // Monotonic progression for (let i = 0; i < angles.length - 1; i++) { expect(angles[i + 1]!).toBeGreaterThan(angles[i]!) } }) it('flags strictly the center 2 arrows for on-striking procs; outer arrows cannot proc', () => { // For a 6-arrow volley (indices 0, 1, 2, 3, 4, 5): center two are 2 and 3 expect(isMultipleShotCenterArrow(0, 6)).toBe(false) expect(isMultipleShotCenterArrow(1, 6)).toBe(false) expect(isMultipleShotCenterArrow(2, 6)).toBe(true) expect(isMultipleShotCenterArrow(3, 6)).toBe(true) expect(isMultipleShotCenterArrow(4, 6)).toBe(false) expect(isMultipleShotCenterArrow(5, 6)).toBe(false) // For a 24-arrow volley: center two are 11 and 12 expect(isMultipleShotCenterArrow(11, 24)).toBe(true) expect(isMultipleShotCenterArrow(12, 24)).toBe(true) expect(isMultipleShotCenterArrow(10, 24)).toBe(false) expect(isMultipleShotCenterArrow(13, 24)).toBe(false) }) it('enforces single-target hit immunity per Multiple Shot volley (no multi-hit shotgunning)', async () => { const registry = await getSharedDataRegistry() const missileEngine = new MissileEngine(registry) const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry) const target = createTestCombatUnit('boss-dummy', { x: 80, y: 0 }, registry) const targetPositions = new Map() targetPositions.set(target.id, { x: 80, y: 0 }) const dmgPacket: SUnitDmgPacket = { skillId: 12, attackKind: 'missile', srcDam: 96, // 75% weapon penalty flatPhysMin256: 100 * FIXED_ONE, flatPhysMax256: 100 * FIXED_ONE, autoHit: true, } // Spawn a 24-arrow volley where multiple arrows pass through the target point const arrows = missileEngine.spawnMultipleShotVolley({ sourceSkillId: 12, slvl: 24, owner, startX: 0, startY: 0, targetX: 80, targetY: 0, dmgPacket, }) expect(arrows.length).toBe(24) const volleyId = arrows[0]!.volleyId expect(volleyId).toBeDefined() for (const a of arrows) { expect(a.volleyId).toBe(volleyId) } // Step missile engine until arrows reach target at x=80 let totalHitsOnTarget = 0 for (let tick = 1; tick <= 10; tick++) { const step = missileEngine.tick(tick, [target], targetPositions) for (const h of step.hits) { if (h.targetId === target.id) { totalHitsOnTarget += 1 } } } // Crucial 1.13c Invariant: Despite 24 arrows in the volley, a single monster receives damage exactly ONCE! expect(totalHitsOnTarget).toBe(1) }) }) describe('Strafe: Target Cycling, Rapid Burst Cadence & Stationary Lock-in', () => { it('scales arrow count min(10, 4 + floor(slvl/2)) and applies 75% weapon damage + %ED', () => { const s1 = calculateStrafeStats(1) expect(s1.arrowCount).toBe(4) expect(s1.damageMultiplier).toBe(0.75) expect(s1.srcDam).toBe(96) expect(s1.enhancedDamagePct).toBe(0) expect(s1.manaCost).toBe(11) const s2 = calculateStrafeStats(2) expect(s2.arrowCount).toBe(5) const s10 = calculateStrafeStats(10) expect(s10.arrowCount).toBe(9) expect(s10.enhancedDamagePct).toBe(45) const s12 = calculateStrafeStats(12) expect(s12.arrowCount).toBe(10) const s20 = calculateStrafeStats(20) expect(s20.arrowCount).toBe(10) expect(s20.enhancedDamagePct).toBe(95) }) it('distributes shots sequentially across multiple targets and applies stationary lock-in state', async () => { const registry = await getSharedDataRegistry() const missileEngine = new MissileEngine(registry) const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry) const t1 = createTestCombatUnit('enemy-1', { x: 100, y: 0 }, registry) const t2 = createTestCombatUnit('enemy-2', { x: 0, y: 100 }, registry) const t3 = createTestCombatUnit('enemy-3', { x: -100, y: 0 }, registry) const targets = [t1, t2, t3] const targetPositions = new Map() targetPositions.set(t1.id, { x: 100, y: 0 }) targetPositions.set(t2.id, { x: 0, y: 100 }) targetPositions.set(t3.id, { x: -100, y: 0 }) const dmgPacket: SUnitDmgPacket = { skillId: 26, attackKind: 'missile', srcDam: 96, autoHit: true, } const arrows = missileEngine.spawnStrafeVolley({ sourceSkillId: 26, slvl: 20, // 10 arrows owner, startX: 0, startY: 0, targets, targetPositions, dmgPacket, }) expect(arrows.length).toBe(10) // Player is locked in stationary state expect(owner.stateBus.hasState('strafe_lockin')).toBe(true) const lockInState = owner.stateBus.getState('strafe_lockin') expect(lockInState?.durationFrames).toBe(24) // 10 * 2 + 4 // Verify arrows target different positions cyclically expect(arrows[0]!.angleRad).not.toBe(arrows[1]!.angleRad) expect(arrows[1]!.angleRad).not.toBe(arrows[2]!.angleRad) }) }) describe('Immolation Arrow: Casting Cooldown (25 frames) & Lingering Fire Patch (75 frames)', () => { it('returns exact 25-frame casting cooldown and 75-frame ground fire duration', () => { expect(getSkillCooldownTicks(27)).toBe(25) // 1.0 second delay in 1.13c const stats = calculateImmolationArrowStats(20, { fireArrow: 20 }) expect(stats.cooldownFrames).toBe(25) expect(stats.firePatchDurationFrames).toBe(75) // 3.0 seconds expect(stats.explosionSynergyMultiplier).toBe(2.0) expect(stats.firePatchSynergyMultiplier).toBe(3.0) }) it('persists ground fire patch across 75 frames and ticks damage at 5-frame intervals', async () => { const registry = await getSharedDataRegistry() const missileEngine = new MissileEngine(registry) const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry) const target = createTestCombatUnit('standing-monster', { x: 50, y: 0 }, registry) const targetPositions = new Map() targetPositions.set(target.id, { x: 50, y: 0 }) const dmgPacket: SUnitDmgPacket = { skillId: 27, attackKind: 'missile', elemType: 'fire', elemMin256: 50 * FIXED_ONE, elemMax256: 50 * FIXED_ONE, autoHit: true, } // Spawn primary immolation arrow const mainArrow = missileEngine.spawnMissile({ missileNameOrId: 'immolationarrow', sourceSkillId: 27, slvl: 20, owner, startX: 0, startY: 0, targetX: 50, targetY: 0, dmgPacket, })! // Step until impact let firePatchSpawned = false for (let tick = 1; tick <= 5; tick++) { const step = missileEngine.tick(tick, [target], targetPositions) if (step.subMissilesSpawned.includes('immolationfire')) { firePatchSpawned = true } } expect(firePatchSpawned).toBe(true) // Find the spawned immolationfire patch missile const activeMissiles = missileEngine.getActiveMissiles() const patch = activeMissiles.find(m => m.name === 'immolationfire') expect(patch).toBeDefined() expect(patch!.maxRangeTicks).toBe(75) // Step through 20 ticks and count ticking damage let tickHits = 0 for (let tick = 6; tick <= 25; tick++) { const step = missileEngine.tick(tick, [target], targetPositions) for (const h of step.hits) { if (h.missileName === 'immolationfire') { tickHits += 1 } } } // Ground patch ticks every 5 ticks -> 20 ticks / 5 = 4 ticks expect(tickHits).toBeGreaterThanOrEqual(3) expect(patch!.expired).toBe(false) }) }) describe('Freezing Arrow: 80 Sub-pixel Cold Splash & AoE Solid Freeze', () => { it('splashes cold damage and applies freeze state to all enemies within 80 sub-pixels', async () => { const registry = await getSharedDataRegistry() const missileEngine = new MissileEngine(registry) const owner = createTestCombatUnit('amazon-player', { x: 0, y: 0 }, registry) const primary = createTestCombatUnit('primary-enemy', { x: 60, y: 0 }, registry) const close1 = createTestCombatUnit('splash-enemy-1', { x: 80, y: 20 }, registry) // dist ~28px <= 80 const close2 = createTestCombatUnit('splash-enemy-2', { x: 40, y: -20 }, registry) // dist ~28px <= 80 const farEnemy = createTestCombatUnit('far-enemy', { x: 200, y: 0 }, registry) // dist 140px > 80 const allTargets = [primary, close1, close2, farEnemy] const targetPositions = new Map() targetPositions.set(primary.id, { x: 60, y: 0 }) targetPositions.set(close1.id, { x: 80, y: 20 }) targetPositions.set(close2.id, { x: 40, y: -20 }) targetPositions.set(farEnemy.id, { x: 200, y: 0 }) const dmgPacket: SUnitDmgPacket = { skillId: 31, attackKind: 'missile', elemType: 'cold', elemMin256: 300 * FIXED_ONE, elemMax256: 300 * FIXED_ONE, autoHit: true, } // Spawn Freezing Arrow towards primary missileEngine.spawnMissile({ missileNameOrId: 'freezingarrow', sourceSkillId: 31, slvl: 20, owner, startX: 0, startY: 0, targetX: 60, targetY: 0, dmgPacket, }) // Step until impact and splash detonation for (let tick = 1; tick <= 5; tick++) { missileEngine.tick(tick, allTargets, targetPositions) } // Primary and close splash targets must receive freeze state expect(primary.stateBus.hasState('freeze')).toBe(true) expect(close1.stateBus.hasState('freeze')).toBe(true) expect(close2.stateBus.hasState('freeze')).toBe(true) // Far enemy outside 80 sub-pixels must NOT be frozen or damaged expect(farEnemy.stateBus.hasState('freeze')).toBe(false) expect(farEnemy.statList.getHp256()).toBe(farEnemy.statList.getMaxHp256()) }) }) describe('Integration with WorldArena & Central Skill Dispatchers', () => { it('correctly maps calculateSkillDamage, getSkillManaCost, and getSkillCooldownTicks across all 10 bow skills', () => { // Skill 6: Magic Arrow expect(getSkillManaCost(6, 1)).toBe(1.5) expect(getSkillManaCost(6, 13)).toBe(0.0) expect(getSkillCooldownTicks(6)).toBe(0) const dmg6 = calculateSkillDamage(6, 1, 1.0, { min: 20, max: 30 }) expect(dmg6.min).toBe(21) // 20 + 1 expect(dmg6.max).toBe(31) // 30 + 1 // Skill 7: Fire Arrow expect(getSkillManaCost(7, 1)).toBe(3.0) expect(getSkillCooldownTicks(7)).toBe(0) const dmg7 = calculateSkillDamage(7, 1) expect(dmg7.min).toBe(1) expect(dmg7.max).toBe(4) // Skill 11: Cold Arrow expect(getSkillManaCost(11, 1)).toBe(3.5) expect(getSkillCooldownTicks(11)).toBe(0) const dmg11 = calculateSkillDamage(11, 1) expect(dmg11.min).toBe(3) expect(dmg11.max).toBe(4) // Skill 12: Multiple Shot expect(getSkillManaCost(12, 1)).toBe(4) expect(getSkillCooldownTicks(12)).toBe(0) const dmg12 = calculateSkillDamage(12, 1, 1.0, { min: 100, max: 200 }) expect(dmg12.min).toBe(75) expect(dmg12.max).toBe(150) // Skill 16: Exploding Arrow expect(getSkillManaCost(16, 1)).toBe(5) expect(getSkillCooldownTicks(16)).toBe(0) const dmg16 = calculateSkillDamage(16, 1) expect(dmg16.min).toBe(2) expect(dmg16.max).toBe(6) // Skill 21: Ice Arrow expect(getSkillManaCost(21, 1)).toBe(4) expect(getSkillCooldownTicks(21)).toBe(0) const dmg21 = calculateSkillDamage(21, 1) expect(dmg21.min).toBe(6) expect(dmg21.max).toBe(10) // Skill 22: Guided Arrow expect(getSkillManaCost(22, 1)).toBe(8) expect(getSkillCooldownTicks(22)).toBe(0) const dmg22 = calculateSkillDamage(22, 10, 1.0, { min: 100, max: 200 }) expect(dmg22.min).toBe(145) // +45% ED expect(dmg22.max).toBe(290) // Skill 26: Strafe expect(getSkillManaCost(26, 1)).toBe(11) expect(getSkillCooldownTicks(26)).toBe(0) const dmg26 = calculateSkillDamage(26, 10, 1.0, { min: 100, max: 200 }) expect(dmg26.min).toBe(Math.floor(100 * 0.75 * 1.45)) // 75% * 145% ED expect(dmg26.max).toBe(Math.floor(200 * 0.75 * 1.45)) // Skill 27: Immolation Arrow expect(getSkillManaCost(27, 1)).toBe(6) expect(getSkillCooldownTicks(27)).toBe(25) // Cooldown = 25 frames const dmg27 = calculateSkillDamage(27, 1) expect(dmg27.min).toBe(12) expect(dmg27.max).toBe(23) // Skill 31: Freezing Arrow expect(getSkillManaCost(31, 1)).toBe(9) expect(getSkillCooldownTicks(31)).toBe(0) const dmg31 = calculateSkillDamage(31, 1) expect(dmg31.min).toBe(40) expect(dmg31.max).toBe(50) }) it('triggers and steps WorldArena cast cleanly across all 10 bow skills', async () => { const registry = await getSharedDataRegistry() const bowSkillIds = [6, 7, 11, 12, 16, 21, 22, 26, 27, 31] for (const id of bowSkillIds) { const arena = new WorldArena({ registry, classCode: 'ama', skillId: id, slvl: 20, }) const outcome = arena.triggerCast() arena.stepTicks(15) const dbg = arena.getDebugState() expect(dbg.ready).toBe(true) expect(dbg.skillId).toBe(id) expect(dbg.castCount).toBeGreaterThanOrEqual(1) expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) } }) }) })